@@ -35,6 +35,8 @@ interface BackendSessionData {
3535 type ?: string ;
3636 created_at : string ;
3737 updated_at ?: string ;
38+ /** Data completeness tier (0=full, 1=partial, 2=stat-only, 3=name-only) */
39+ dataTier ?: number ;
3840 [ key : string ] : unknown ;
3941}
4042
@@ -99,6 +101,8 @@ export interface ApiError {
99101 message : string ;
100102 status : number ;
101103 code ?: string ;
104+ path ?: string ;
105+ reason ?: string ;
102106}
103107
104108// ========== CSRF Token Handling ==========
@@ -310,6 +314,8 @@ export async function fetchApi<T>(
310314 if ( body . message ) error . message = body . message ;
311315 else if ( body . error ) error . message = body . error ;
312316 if ( body . code ) error . code = body . code ;
317+ if ( body . path ) error . path = body . path ;
318+ if ( body . reason ) error . reason = body . reason ;
313319 } catch ( parseError ) {
314320 // Silently ignore JSON parse errors for non-JSON responses
315321 }
@@ -471,6 +477,10 @@ function transformBackendSession(
471477 summaries : ( backendSession as unknown as { summaries ?: SessionMetadata [ 'summaries' ] } ) . summaries ,
472478 tasks : ( ( backendSession as unknown as { tasks ?: TaskData [ ] } ) . tasks || [ ] )
473479 . map ( t => normalizeTask ( t as unknown as Record < string , unknown > ) ) ,
480+ // Pass through data tier from backend (clamped to valid range)
481+ dataTier : backendSession . dataTier != null
482+ ? ( Math . min ( Math . max ( backendSession . dataTier , 0 ) , 3 ) as 0 | 1 | 2 | 3 )
483+ : undefined ,
474484 } ;
475485}
476486
@@ -2175,12 +2185,45 @@ export interface SessionDetailResponse {
21752185
21762186/**
21772187 * Fetch session detail for a specific workspace
2178- * First fetches session list to get the session path, then fetches detail data
2188+ * Uses progressive path resolution: cache > by-id > fetchSessions fallback
21792189 * @param sessionId - Session ID to fetch details for
21802190 * @param projectPath - Optional project path to filter data by workspace
21812191 */
21822192export async function fetchSessionDetail ( sessionId : string , projectPath ?: string ) : Promise < SessionDetailResponse > {
2183- // Step 1: Fetch all sessions to get the session path
2193+ // Phase 1: Try TanStack Query cache for session path (zero API calls)
2194+ let sessionPath : string | undefined ;
2195+ let cachedSession : SessionMetadata | undefined ;
2196+
2197+ if ( projectPath ) {
2198+ try {
2199+ // Dynamic import to avoid circular dependency - api.ts is imported by hooks
2200+ const { default : queryClient } = await import ( './query-client' ) ;
2201+ const cacheKey = [ 'workspace' , projectPath , 'sessions' , 'list' ] as const ;
2202+ const cached = queryClient . getQueryData < { activeSessions : SessionMetadata [ ] ; archivedSessions : SessionMetadata [ ] } > ( cacheKey ) ;
2203+ if ( cached ) {
2204+ const allSessions = [ ...cached . activeSessions , ...cached . archivedSessions ] ;
2205+ cachedSession = allSessions . find ( s => s . session_id === sessionId ) ;
2206+ if ( cachedSession ?. path ) {
2207+ sessionPath = cachedSession . path ;
2208+ }
2209+ }
2210+ } catch {
2211+ // queryClient not available (SSR, test env, etc.) - fall through to other strategies
2212+ }
2213+ }
2214+
2215+ // Phase 3: Try /api/session-detail?id= endpoint (1 API call)
2216+ if ( ! sessionPath ) {
2217+ try {
2218+ const idParam = `/api/session-detail?id=${ encodeURIComponent ( sessionId ) } &type=all${ projectPath ? `&projectPath=${ encodeURIComponent ( projectPath ) } ` : '' } ` ;
2219+ const detailData = await fetchApi < Record < string , unknown > > ( idParam ) ;
2220+ return transformDetailResponse ( detailData , cachedSession , sessionId ) ;
2221+ } catch {
2222+ // by-id endpoint failed (session not found, network error) - fall through to fetchSessions
2223+ }
2224+ }
2225+
2226+ // Fallback: fetchSessions + session-detail (2 API calls, backward compatible)
21842227 const sessionsData = await fetchSessions ( projectPath ) ;
21852228 const allSessions = [ ...sessionsData . activeSessions , ...sessionsData . archivedSessions ] ;
21862229 const session = allSessions . find ( s => s . session_id === sessionId ) ;
@@ -2189,40 +2232,54 @@ export async function fetchSessionDetail(sessionId: string, projectPath?: string
21892232 throw new Error ( `Session not found: ${ sessionId } ` ) ;
21902233 }
21912234
2192- // Step 2: Use the session path to fetch detail data from the correct endpoint
2193- // Backend expects the actual session directory path, not the project path
2194- const sessionPath = ( session as any ) . path || session . session_id ;
2195- const detailData = await fetchApi < any > ( `/api/session-detail?path=${ encodeURIComponent ( sessionPath ) } &type=all` ) ;
2235+ // Use session path from fresh fetch
2236+ sessionPath = ( session as any ) . path || session . session_id ;
2237+ const detailData = await fetchApi < Record < string , unknown > > ( `/api/session-detail?path=${ encodeURIComponent ( sessionPath ! ) } &type=all` ) ;
2238+ return transformDetailResponse ( detailData , session , sessionId ) ;
2239+ }
21962240
2197- // Step 3: Transform the response to match SessionDetailResponse interface
2198- // Also check for summaries array and extract first one if summary is empty
2199- let finalSummary = detailData . summary ;
2200- if ( ! finalSummary && detailData . summaries && detailData . summaries . length > 0 ) {
2201- finalSummary = detailData . summaries [ 0 ] . content || detailData . summaries [ 0 ] . name || '' ;
2241+ /**
2242+ * Transform raw detail API response into SessionDetailResponse
2243+ */
2244+ function transformDetailResponse (
2245+ detailData : Record < string , unknown > ,
2246+ sessionMeta : SessionMetadata | undefined ,
2247+ sessionId : string
2248+ ) : SessionDetailResponse {
2249+ // Extract summary: prefer direct summary, fallback to first summaries entry
2250+ let finalSummary = detailData . summary as string | undefined ;
2251+ if ( ! finalSummary && Array . isArray ( detailData . summaries ) && detailData . summaries . length > 0 ) {
2252+ const first = detailData . summaries [ 0 ] as { content ?: string ; name ?: string } ;
2253+ finalSummary = first . content || first . name || '' ;
22022254 }
22032255
2204- // Step 4: Transform context to match SessionDetailContext interface
2205- // Backend returns raw context-package.json content, frontend expects it nested under 'context' field
2256+ // Backend returns raw context-package.json content, frontend expects it nested under 'context'
22062257 const transformedContext = detailData . context ? { context : detailData . context } : undefined ;
22072258
2208- // Step 5: Merge tasks from detailData into session object
2209- // Backend returns tasks at root level, frontend expects them on session object
2210- const sessionWithTasks = {
2211- ...session ,
2212- tasks : detailData . tasks || session . tasks || [ ] ,
2213- } ;
2259+ // Build session object: prefer metadata from cache/list, merge tasks from detail
2260+ const sessionWithTasks = sessionMeta
2261+ ? { ...sessionMeta , tasks : ( Array . isArray ( detailData . tasks ) ? detailData . tasks : sessionMeta . tasks || [ ] ) }
2262+ : {
2263+ session_id : sessionId ,
2264+ title : sessionId ,
2265+ status : 'in_progress' as const ,
2266+ created_at : '' ,
2267+ location : 'active' as const ,
2268+ tasks : Array . isArray ( detailData . tasks ) ? detailData . tasks : [ ] ,
2269+ } ;
22142270
22152271 return {
22162272 session : sessionWithTasks ,
22172273 context : transformedContext ,
22182274 summary : finalSummary ,
2219- summaries : detailData . summaries ,
2275+ summaries : detailData . summaries as SessionDetailResponse [ 'summaries' ] ,
22202276 implPlan : detailData . implPlan ,
2221- conflicts : detailData . conflictResolution , // Backend returns ' conflictResolution', not 'conflicts'
2277+ conflicts : Array . isArray ( detailData . conflictResolution ) ? detailData . conflictResolution : undefined ,
22222278 review : detailData . review ,
22232279 } ;
22242280}
22252281
2282+
22262283// ========== History / CLI Execution API ==========
22272284
22282285export interface CliExecution {
0 commit comments