From bcd876e1c9b2822aeb758192ab435b354f71b46e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Nov 2025 15:16:10 +0100 Subject: [PATCH 01/14] Optimize Swift app polling with lightweight session endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the /v1/sessions/workspace/{path} endpoint for active session polling instead of fetching all workspaces. This significantly reduces backend load during active Claude sessions. - Add latestUserPrompt, latestMessage, latestThought, stats to FullSessionData - Always populate session data fields (not just when full=true) - Update WorkspacePoller to use session endpoint for active sessions - Add effectiveLatestUserPrompt/Message computed properties in views - Fix "You asked" section disappearing during active sessions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/docs/docs.go | 118 ++++++++++++++++++ container/docs/swagger.json | 118 ++++++++++++++++++ container/docs/swagger.yaml | 90 +++++++++++++ container/internal/models/claude.go | 49 ++++++++ container/internal/services/claude.go | 60 +++++++++ container/internal/services/claude_monitor.go | 59 +++++---- xcode/catnip/Models/WorkspaceInfo.swift | 71 +++++++++++ xcode/catnip/Services/CatnipAPI.swift | 40 ++++++ xcode/catnip/Services/UITestingHelper.swift | 50 ++++++++ xcode/catnip/Services/WorkspacePoller.swift | 48 ++++++- xcode/catnip/Views/WorkspaceDetailView.swift | 35 +++++- 11 files changed, 709 insertions(+), 29 deletions(-) diff --git a/container/docs/docs.go b/container/docs/docs.go index 5909383f..e994ab61 100644 --- a/container/docs/docs.go +++ b/container/docs/docs.go @@ -2240,6 +2240,18 @@ const docTemplate = `{ "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.SessionListEntry" } }, + "latestMessage": { + "description": "Latest assistant message text (always populated when available)", + "type": "string" + }, + "latestThought": { + "description": "Latest thinking/reasoning content (always populated when available)", + "type": "string" + }, + "latestUserPrompt": { + "description": "Latest user prompt from history (always populated when available)", + "type": "string" + }, "messageCount": { "description": "Total message count in full data", "type": "integer" @@ -2259,6 +2271,14 @@ const docTemplate = `{ } ] }, + "stats": { + "description": "Session statistics (token counts, tool usage, etc.)", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.SessionStats" + } + ] + }, "userPrompts": { "description": "User prompts from ~/.claude.json (only when full=true)", "type": "array", @@ -2450,6 +2470,104 @@ const docTemplate = `{ } } }, + "github_com_vanpelt_catnip_internal_models.SessionStats": { + "description": "Session statistics including token counts and activity metrics", + "type": "object", + "properties": { + "activeDurationSeconds": { + "description": "Active duration in seconds (time Claude was actually working)", + "type": "number", + "example": 1800.25 + }, + "activeToolNames": { + "description": "Tool usage counts by name", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "apiCallCount": { + "description": "Number of API calls made", + "type": "integer", + "example": 12 + }, + "assistantMessages": { + "description": "Number of assistant messages", + "type": "integer", + "example": 27 + }, + "cacheCreationTokens": { + "description": "Cache creation tokens", + "type": "integer", + "example": 30000 + }, + "cacheReadTokens": { + "description": "Cache read tokens (context reuse)", + "type": "integer", + "example": 120000 + }, + "compactionCount": { + "description": "Number of context compactions", + "type": "integer", + "example": 1 + }, + "humanPromptCount": { + "description": "Number of human prompts (user messages with text, not tool results)", + "type": "integer", + "example": 10 + }, + "imageCount": { + "description": "Number of images in the conversation", + "type": "integer", + "example": 2 + }, + "lastContextSizeTokens": { + "description": "Last message's cache_read value (actual context size in tokens)", + "type": "integer", + "example": 125000 + }, + "sessionDurationSeconds": { + "description": "Session duration in seconds (wall-clock time)", + "type": "number", + "example": 3600.5 + }, + "subAgentCount": { + "description": "Number of sub-agents spawned", + "type": "integer", + "example": 3 + }, + "thinkingBlockCount": { + "description": "Number of thinking blocks", + "type": "integer", + "example": 8 + }, + "toolCallCount": { + "description": "Total tool calls made", + "type": "integer", + "example": 35 + }, + "totalInputTokens": { + "description": "Total input tokens used", + "type": "integer", + "example": 150000 + }, + "totalMessages": { + "description": "Total number of messages in the session", + "type": "integer", + "example": 42 + }, + "totalOutputTokens": { + "description": "Total output tokens generated", + "type": "integer", + "example": 85000 + }, + "userMessages": { + "description": "Number of user messages", + "type": "integer", + "example": 15 + } + } + }, "github_com_vanpelt_catnip_internal_models.TitleEntry": { "type": "object", "properties": { diff --git a/container/docs/swagger.json b/container/docs/swagger.json index d8783c42..29973492 100644 --- a/container/docs/swagger.json +++ b/container/docs/swagger.json @@ -2237,6 +2237,18 @@ "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.SessionListEntry" } }, + "latestMessage": { + "description": "Latest assistant message text (always populated when available)", + "type": "string" + }, + "latestThought": { + "description": "Latest thinking/reasoning content (always populated when available)", + "type": "string" + }, + "latestUserPrompt": { + "description": "Latest user prompt from history (always populated when available)", + "type": "string" + }, "messageCount": { "description": "Total message count in full data", "type": "integer" @@ -2256,6 +2268,14 @@ } ] }, + "stats": { + "description": "Session statistics (token counts, tool usage, etc.)", + "allOf": [ + { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.SessionStats" + } + ] + }, "userPrompts": { "description": "User prompts from ~/.claude.json (only when full=true)", "type": "array", @@ -2447,6 +2467,104 @@ } } }, + "github_com_vanpelt_catnip_internal_models.SessionStats": { + "description": "Session statistics including token counts and activity metrics", + "type": "object", + "properties": { + "activeDurationSeconds": { + "description": "Active duration in seconds (time Claude was actually working)", + "type": "number", + "example": 1800.25 + }, + "activeToolNames": { + "description": "Tool usage counts by name", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "apiCallCount": { + "description": "Number of API calls made", + "type": "integer", + "example": 12 + }, + "assistantMessages": { + "description": "Number of assistant messages", + "type": "integer", + "example": 27 + }, + "cacheCreationTokens": { + "description": "Cache creation tokens", + "type": "integer", + "example": 30000 + }, + "cacheReadTokens": { + "description": "Cache read tokens (context reuse)", + "type": "integer", + "example": 120000 + }, + "compactionCount": { + "description": "Number of context compactions", + "type": "integer", + "example": 1 + }, + "humanPromptCount": { + "description": "Number of human prompts (user messages with text, not tool results)", + "type": "integer", + "example": 10 + }, + "imageCount": { + "description": "Number of images in the conversation", + "type": "integer", + "example": 2 + }, + "lastContextSizeTokens": { + "description": "Last message's cache_read value (actual context size in tokens)", + "type": "integer", + "example": 125000 + }, + "sessionDurationSeconds": { + "description": "Session duration in seconds (wall-clock time)", + "type": "number", + "example": 3600.5 + }, + "subAgentCount": { + "description": "Number of sub-agents spawned", + "type": "integer", + "example": 3 + }, + "thinkingBlockCount": { + "description": "Number of thinking blocks", + "type": "integer", + "example": 8 + }, + "toolCallCount": { + "description": "Total tool calls made", + "type": "integer", + "example": 35 + }, + "totalInputTokens": { + "description": "Total input tokens used", + "type": "integer", + "example": 150000 + }, + "totalMessages": { + "description": "Total number of messages in the session", + "type": "integer", + "example": 42 + }, + "totalOutputTokens": { + "description": "Total output tokens generated", + "type": "integer", + "example": 85000 + }, + "userMessages": { + "description": "Number of user messages", + "type": "integer", + "example": 15 + } + } + }, "github_com_vanpelt_catnip_internal_models.TitleEntry": { "type": "object", "properties": { diff --git a/container/docs/swagger.yaml b/container/docs/swagger.yaml index 886e4484..1062ae79 100644 --- a/container/docs/swagger.yaml +++ b/container/docs/swagger.yaml @@ -306,6 +306,15 @@ definitions: items: $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.SessionListEntry' type: array + latestMessage: + description: Latest assistant message text (always populated when available) + type: string + latestThought: + description: Latest thinking/reasoning content (always populated when available) + type: string + latestUserPrompt: + description: Latest user prompt from history (always populated when available) + type: string messageCount: description: Total message count in full data type: integer @@ -318,6 +327,10 @@ definitions: allOf: - $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.ClaudeSessionSummary' description: Basic session information + stats: + allOf: + - $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.SessionStats' + description: Session statistics (token counts, tool usage, etc.) userPrompts: description: User prompts from ~/.claude.json (only when full=true) items: @@ -465,6 +478,83 @@ definitions: example: "2024-01-15T14:30:00Z" type: string type: object + github_com_vanpelt_catnip_internal_models.SessionStats: + description: Session statistics including token counts and activity metrics + properties: + activeDurationSeconds: + description: Active duration in seconds (time Claude was actually working) + example: 1800.25 + type: number + activeToolNames: + additionalProperties: + type: integer + description: Tool usage counts by name + type: object + apiCallCount: + description: Number of API calls made + example: 12 + type: integer + assistantMessages: + description: Number of assistant messages + example: 27 + type: integer + cacheCreationTokens: + description: Cache creation tokens + example: 30000 + type: integer + cacheReadTokens: + description: Cache read tokens (context reuse) + example: 120000 + type: integer + compactionCount: + description: Number of context compactions + example: 1 + type: integer + humanPromptCount: + description: Number of human prompts (user messages with text, not tool results) + example: 10 + type: integer + imageCount: + description: Number of images in the conversation + example: 2 + type: integer + lastContextSizeTokens: + description: Last message's cache_read value (actual context size in tokens) + example: 125000 + type: integer + sessionDurationSeconds: + description: Session duration in seconds (wall-clock time) + example: 3600.5 + type: number + subAgentCount: + description: Number of sub-agents spawned + example: 3 + type: integer + thinkingBlockCount: + description: Number of thinking blocks + example: 8 + type: integer + toolCallCount: + description: Total tool calls made + example: 35 + type: integer + totalInputTokens: + description: Total input tokens used + example: 150000 + type: integer + totalMessages: + description: Total number of messages in the session + example: 42 + type: integer + totalOutputTokens: + description: Total output tokens generated + example: 85000 + type: integer + userMessages: + description: Number of user messages + example: 15 + type: integer + type: object github_com_vanpelt_catnip_internal_models.TitleEntry: properties: commit_hash: diff --git a/container/internal/models/claude.go b/container/internal/models/claude.go index 578884e5..3f4e84e6 100644 --- a/container/internal/models/claude.go +++ b/container/internal/models/claude.go @@ -124,6 +124,55 @@ type FullSessionData struct { UserPrompts []ClaudeHistoryEntry `json:"userPrompts,omitempty"` // Total message count in full data MessageCount int `json:"messageCount,omitempty"` + // Latest user prompt from history (always populated when available) + LatestUserPrompt string `json:"latestUserPrompt,omitempty"` + // Latest assistant message text (always populated when available) + LatestMessage string `json:"latestMessage,omitempty"` + // Latest thinking/reasoning content (always populated when available) + LatestThought string `json:"latestThought,omitempty"` + // Session statistics (token counts, tool usage, etc.) + Stats *SessionStats `json:"stats,omitempty"` +} + +// SessionStats contains aggregated statistics about a Claude session +// @Description Session statistics including token counts and activity metrics +type SessionStats struct { + // Total number of messages in the session + TotalMessages int `json:"totalMessages" example:"42"` + // Number of user messages + UserMessages int `json:"userMessages" example:"15"` + // Number of assistant messages + AssistantMessages int `json:"assistantMessages" example:"27"` + // Number of human prompts (user messages with text, not tool results) + HumanPromptCount int `json:"humanPromptCount" example:"10"` + // Total tool calls made + ToolCallCount int `json:"toolCallCount" example:"35"` + // Total input tokens used + TotalInputTokens int64 `json:"totalInputTokens" example:"150000"` + // Total output tokens generated + TotalOutputTokens int64 `json:"totalOutputTokens" example:"85000"` + // Cache read tokens (context reuse) + CacheReadTokens int64 `json:"cacheReadTokens" example:"120000"` + // Cache creation tokens + CacheCreationTokens int64 `json:"cacheCreationTokens" example:"30000"` + // Last message's cache_read value (actual context size in tokens) + LastContextSizeTokens int64 `json:"lastContextSizeTokens" example:"125000"` + // Number of API calls made + APICallCount int `json:"apiCallCount" example:"12"` + // Session duration in seconds (wall-clock time) + SessionDurationSeconds float64 `json:"sessionDurationSeconds" example:"3600.5"` + // Active duration in seconds (time Claude was actually working) + ActiveDurationSeconds float64 `json:"activeDurationSeconds" example:"1800.25"` + // Number of thinking blocks + ThinkingBlockCount int `json:"thinkingBlockCount" example:"8"` + // Number of sub-agents spawned + SubAgentCount int `json:"subAgentCount" example:"3"` + // Number of context compactions + CompactionCount int `json:"compactionCount" example:"1"` + // Number of images in the conversation + ImageCount int `json:"imageCount" example:"2"` + // Tool usage counts by name + ActiveToolNames map[string]int `json:"activeToolNames,omitempty"` } // CreateCompletionRequest represents a request to create a completion using claude CLI diff --git a/container/internal/services/claude.go b/container/internal/services/claude.go index 75bd0c92..54cabefc 100644 --- a/container/internal/services/claude.go +++ b/container/internal/services/claude.go @@ -398,6 +398,9 @@ func (s *ClaudeService) GetFullSessionData(worktreePath string, includeFullData } fullData.AllSessions = allSessions + // Always populate latest data from parser (lightweight - uses cached state) + s.populateLatestDataFromParser(worktreePath, fullData) + // Only include full message data if requested if includeFullData { // Get messages from current/latest session @@ -426,6 +429,63 @@ func (s *ClaudeService) GetFullSessionData(worktreePath string, includeFullData return fullData, nil } +// populateLatestDataFromParser populates latest user prompt, message, thought and stats from the parser +func (s *ClaudeService) populateLatestDataFromParser(worktreePath string, fullData *models.FullSessionData) { + if s.parserService == nil { + return + } + + reader, err := s.parserService.GetOrCreateParser(worktreePath) + if err != nil { + return // Parser not available yet + } + + // Get latest user prompt from history + latestUserPrompt, err := reader.GetLatestUserPrompt() + if err == nil && latestUserPrompt != "" { + fullData.LatestUserPrompt = latestUserPrompt + } + + // Get latest assistant message + latestMsg := reader.GetLatestMessage() + if latestMsg != nil { + fullData.LatestMessage = parser.ExtractTextContent(*latestMsg) + } + + // Get latest thought/thinking + latestThought := reader.GetLatestThought() + if latestThought != nil { + thinkingBlocks := parser.ExtractThinking(*latestThought) + if len(thinkingBlocks) > 0 { + // Get the last thinking block + fullData.LatestThought = thinkingBlocks[len(thinkingBlocks)-1].Content + } + } + + // Get session stats + parserStats := reader.GetStats() + fullData.Stats = &models.SessionStats{ + TotalMessages: parserStats.TotalMessages, + UserMessages: parserStats.UserMessages, + AssistantMessages: parserStats.AssistantMessages, + HumanPromptCount: parserStats.HumanPromptCount, + ToolCallCount: parserStats.ToolCallCount, + TotalInputTokens: parserStats.TotalInputTokens, + TotalOutputTokens: parserStats.TotalOutputTokens, + CacheReadTokens: parserStats.CacheReadTokens, + CacheCreationTokens: parserStats.CacheCreationTokens, + LastContextSizeTokens: parserStats.LastContextSizeTokens, + APICallCount: parserStats.APICallCount, + SessionDurationSeconds: parserStats.SessionDuration.Seconds(), + ActiveDurationSeconds: parserStats.ActiveDuration.Seconds(), + ThinkingBlockCount: parserStats.ThinkingBlockCount, + SubAgentCount: parserStats.SubAgentCount, + CompactionCount: parserStats.CompactionCount, + ImageCount: parserStats.ImageCount, + ActiveToolNames: parserStats.ActiveToolNames, + } +} + // GetAllSessionsForWorkspace returns all session IDs for a workspace with metadata func (s *ClaudeService) GetAllSessionsForWorkspace(worktreePath string) ([]models.SessionListEntry, error) { projectDirName := WorktreePathToProjectDir(worktreePath) diff --git a/container/internal/services/claude_monitor.go b/container/internal/services/claude_monitor.go index d6100e5b..3a7f20b0 100644 --- a/container/internal/services/claude_monitor.go +++ b/container/internal/services/claude_monitor.go @@ -1236,37 +1236,50 @@ func (m *WorktreeTodoMonitor) checkForTodoUpdates(worktreeID string) { todosJSONStr := string(todosJSON) // Check if todos have changed - if todosJSONStr == m.lastTodosJSON { - return // No change in todos - } + todosChanged := todosJSONStr != m.lastTodosJSON - // Todos have changed! - logger.Debugf("📝 Todo update detected for worktree %s: %d todos", m.workDir, len(todos)) + if todosChanged { + // Todos have changed! + logger.Debugf("📝 Todo update detected for worktree %s: %d todos", m.workDir, len(todos)) - // Update activity time for todo monitoring (but don't update Claude service activity - // as todo monitoring is passive and should not keep workspaces "active") - now := time.Now() - m.claudeMonitor.activityMutex.Lock() - m.claudeMonitor.lastActivityTimes[m.workDir] = now - m.claudeMonitor.activityMutex.Unlock() + // Update activity time for todo monitoring (but don't update Claude service activity + // as todo monitoring is passive and should not keep workspaces "active") + now := time.Now() + m.claudeMonitor.activityMutex.Lock() + m.claudeMonitor.lastActivityTimes[m.workDir] = now + m.claudeMonitor.activityMutex.Unlock() + + // Note: We intentionally don't call UpdateActivity here because todo monitoring + // is passive and should not prevent workspaces from transitioning to inactive - // Note: We intentionally don't call UpdateActivity here because todo monitoring - // is passive and should not prevent workspaces from transitioning to inactive + // Update state + m.lastTodos = todos + m.lastTodosJSON = todosJSONStr + + // Check if we should trigger branch renaming based on todos + m.checkTodoBasedBranchRenaming(todos) + } - // Update state - m.lastTodos = todos - m.lastTodosJSON = todosJSONStr + // Build updates map - always check for user prompt changes during active sessions + // This ensures the "You asked" section stays populated + updates := make(map[string]interface{}) - // Check if we should trigger branch renaming based on todos - m.checkTodoBasedBranchRenaming(todos) + if todosChanged { + updates["todos"] = todos + } - // Update worktree state - updates := map[string]interface{}{ - "todos": todos, + // Always update the latest user prompt from history during active sessions + // This ensures the "You asked" section stays populated even before todos are created + latestUserPrompt, err := reader.GetLatestUserPrompt() + if err == nil && latestUserPrompt != "" { + updates["latest_user_prompt"] = latestUserPrompt } - if err := m.gitService.stateManager.UpdateWorktree(worktreeID, updates); err != nil { - logger.Warnf("⚠️ Failed to update worktree todos for %s: %v", worktreeID, err) + // Only update if we have changes + if len(updates) > 0 { + if err := m.gitService.stateManager.UpdateWorktree(worktreeID, updates); err != nil { + logger.Warnf("⚠️ Failed to update worktree for %s: %v", worktreeID, err) + } } // Also check for latest Claude message changes from parser diff --git a/xcode/catnip/Models/WorkspaceInfo.swift b/xcode/catnip/Models/WorkspaceInfo.swift index 526f8d1a..be92dcb9 100644 --- a/xcode/catnip/Models/WorkspaceInfo.swift +++ b/xcode/catnip/Models/WorkspaceInfo.swift @@ -169,3 +169,74 @@ struct PRSummary: Codable { let title: String let description: String } + +// MARK: - Session Data Models + +/// Full session data returned by /v1/sessions/workspace/{path} endpoint +/// This is a lightweight endpoint for polling during active sessions +struct SessionData: Codable { + let sessionInfo: SessionSummary? + let allSessions: [SessionListEntry]? + let latestUserPrompt: String? + let latestMessage: String? + let latestThought: String? + let stats: SessionStats? + // Messages and userPrompts are only included when full=true + // We don't include them here as we're using this for lightweight polling +} + +/// Session summary information +struct SessionSummary: Codable { + let worktreePath: String? + let sessionStartTime: String? + let sessionEndTime: String? + let turnCount: Int? + let isActive: Bool? + let lastSessionId: String? + let currentSessionId: String? + let header: String? + let lastCost: Double? + let lastDuration: Int? + let lastTotalInputTokens: Int? + let lastTotalOutputTokens: Int? +} + +/// Entry in the sessions list +struct SessionListEntry: Codable { + let sessionId: String + let lastModified: String? + let startTime: String? + let endTime: String? + let isActive: Bool? +} + +/// Session statistics including token counts and activity metrics +struct SessionStats: Codable, Hashable { + let totalMessages: Int? + let userMessages: Int? + let assistantMessages: Int? + let humanPromptCount: Int? + let toolCallCount: Int? + let totalInputTokens: Int64? + let totalOutputTokens: Int64? + let cacheReadTokens: Int64? + let cacheCreationTokens: Int64? + let lastContextSizeTokens: Int64? + let apiCallCount: Int? + let sessionDurationSeconds: Double? + let activeDurationSeconds: Double? + let thinkingBlockCount: Int? + let subAgentCount: Int? + let compactionCount: Int? + let imageCount: Int? + let activeToolNames: [String: Int]? + + /// Returns the context size in a human-readable format (e.g., "125K tokens") + var contextSizeDisplay: String? { + guard let tokens = lastContextSizeTokens, tokens > 0 else { return nil } + if tokens >= 1000 { + return "\(tokens / 1000)K tokens" + } + return "\(tokens) tokens" + } +} diff --git a/xcode/catnip/Services/CatnipAPI.swift b/xcode/catnip/Services/CatnipAPI.swift index 552955ef..6e682e7b 100644 --- a/xcode/catnip/Services/CatnipAPI.swift +++ b/xcode/catnip/Services/CatnipAPI.swift @@ -223,6 +223,46 @@ class CatnipAPI: ObservableObject { } } + /// Fetch session data for a specific workspace - lightweight polling endpoint + /// This endpoint returns latest user prompt, latest message, latest thought, and session stats + /// Use this for polling during active sessions instead of the heavier /v1/git/worktrees endpoint + func getSessionData(workspacePath: String) async throws -> SessionData? { + // Return mock data in UI testing mode + if UITestingHelper.shouldUseMockData { + return UITestingHelper.getMockSessionData(workspacePath: workspacePath) + } + + let headers = try await getHeaders(includeCodespace: true) + + // The workspace path needs to be the workspace name (e.g., "vanpelt-catnip") + // not the full path, as it's passed in the URL path + let workspaceName = workspacePath.components(separatedBy: "/").last ?? workspacePath + guard let encodedPath = workspaceName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let url = URL(string: "\(baseURL)/v1/sessions/workspace/\(encodedPath)") else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.allHTTPHeaderFields = headers + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.networkError(NSError(domain: "Invalid response", code: -1)) + } + + if httpResponse.statusCode == 404 { + // No session data yet - this is normal for new workspaces + return nil + } + + if httpResponse.statusCode != 200 { + throw APIError.serverError(httpResponse.statusCode, "Failed to fetch session data") + } + + return try decoder.decode(SessionData.self, from: data) + } + func startPTY(workspacePath: String, agent: String = "claude") async throws { NSLog("🚀 [CatnipAPI] startPTY called with workspacePath: \(workspacePath), agent: \(agent)") diff --git a/xcode/catnip/Services/UITestingHelper.swift b/xcode/catnip/Services/UITestingHelper.swift index 739aa2c4..88495664 100644 --- a/xcode/catnip/Services/UITestingHelper.swift +++ b/xcode/catnip/Services/UITestingHelper.swift @@ -221,6 +221,56 @@ struct UITestingHelper { ) } + static func getMockSessionData(workspacePath: String) -> SessionData? { + guard shouldUseMockData else { return nil } + + // Return mock session data for active workspaces + if workspacePath.contains("feature-auth") { + return SessionData( + sessionInfo: SessionSummary( + worktreePath: workspacePath, + sessionStartTime: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3600)), + sessionEndTime: nil, + turnCount: 5, + isActive: true, + lastSessionId: "mock-session-1", + currentSessionId: "mock-session-1", + header: "Implementing GitHub OAuth", + lastCost: 0.15, + lastDuration: 1800, + lastTotalInputTokens: 25000, + lastTotalOutputTokens: 12000 + ), + allSessions: nil, + latestUserPrompt: "Add GitHub authentication", + latestMessage: "I've started implementing the OAuth flow...", + latestThought: "Let me analyze the current authentication structure...", + stats: SessionStats( + totalMessages: 12, + userMessages: 5, + assistantMessages: 7, + humanPromptCount: 5, + toolCallCount: 15, + totalInputTokens: 45000, + totalOutputTokens: 18000, + cacheReadTokens: 35000, + cacheCreationTokens: 10000, + lastContextSizeTokens: 125000, + apiCallCount: 8, + sessionDurationSeconds: 1800.5, + activeDurationSeconds: 900.25, + thinkingBlockCount: 5, + subAgentCount: 2, + compactionCount: 0, + imageCount: 0, + activeToolNames: ["Read": 8, "Edit": 5, "Bash": 2] + ) + ) + } + + return nil + } + static func shouldAutoNavigateToWorkspaces() -> Bool { isUITesting && shouldShowWorkspacesList } diff --git a/xcode/catnip/Services/WorkspacePoller.swift b/xcode/catnip/Services/WorkspacePoller.swift index cb0c756c..d1c45fdb 100644 --- a/xcode/catnip/Services/WorkspacePoller.swift +++ b/xcode/catnip/Services/WorkspacePoller.swift @@ -46,10 +46,12 @@ class WorkspacePoller: ObservableObject { @Published private(set) var currentInterval: PollingInterval = .idle @Published private(set) var lastUpdate: Date? @Published private(set) var workspace: WorkspaceInfo? + @Published private(set) var sessionData: SessionData? @Published private(set) var error: String? // MARK: - Private Properties private let workspaceId: String + private let workspacePath: String private var pollingTask: Task? private var appStateObserver: NSObjectProtocol? private var lastETag: String? @@ -60,13 +62,14 @@ class WorkspacePoller: ObservableObject { init(workspaceId: String, initialWorkspace: WorkspaceInfo? = nil) { self.workspaceId = workspaceId + self.workspacePath = initialWorkspace?.path ?? "" // Initialize with provided workspace if available if let initialWorkspace = initialWorkspace { self.workspace = initialWorkspace self.lastActivityStateChange = Date() self.previousActivityState = initialWorkspace.claudeActivityState - NSLog("📊 Initialized poller with existing workspace data") + NSLog("📊 Initialized poller with existing workspace data, path: \(initialWorkspace.path)") } setupAppStateObservers() @@ -164,6 +167,49 @@ class WorkspacePoller: ObservableObject { } private func pollWorkspace() async { + // For active or recently active sessions, use the lightweight session endpoint + let isActiveSession = workspace?.claudeActivityState == .active || + Date().timeIntervalSince(lastActivityStateChange) < 120 + + if isActiveSession && !workspacePath.isEmpty { + await pollSessionData() + } else { + await pollFullWorkspace() + } + } + + /// Poll the lightweight session endpoint for active sessions + private func pollSessionData() async { + do { + let newSessionData = try await CatnipAPI.shared.getSessionData(workspacePath: workspacePath) + + if let sessionData = newSessionData { + self.sessionData = sessionData + lastUpdate = Date() + error = nil + + // Check if session is still active based on session info + let isActive = sessionData.sessionInfo?.isActive ?? false + let previousState = workspace?.claudeActivityState + + // Track activity state changes for polling interval adaptation + if !isActive && previousState == .active { + lastActivityStateChange = Date() + NSLog("📊 Session became inactive, will switch to full polling soon") + } + + NSLog("📊 Session data updated - Active: \(isActive), Prompt: \(sessionData.latestUserPrompt?.prefix(30) ?? "nil")...") + } + + } catch { + // Fall back to full workspace polling on error + NSLog("⚠️ Session polling failed, falling back to full workspace: \(error.localizedDescription)") + await pollFullWorkspace() + } + } + + /// Poll the full workspace endpoint (heavier, used for idle workspaces) + private func pollFullWorkspace() async { do { let result = try await CatnipAPI.shared.getWorkspace( id: workspaceId, diff --git a/xcode/catnip/Views/WorkspaceDetailView.swift b/xcode/catnip/Views/WorkspaceDetailView.swift index e8847bf5..af9fc47d 100644 --- a/xcode/catnip/Views/WorkspaceDetailView.swift +++ b/xcode/catnip/Views/WorkspaceDetailView.swift @@ -59,6 +59,31 @@ struct WorkspaceDetailView: View { poller.workspace } + /// Get the latest user prompt, preferring session data over workspace data + private var effectiveLatestUserPrompt: String? { + // First check session data (more up-to-date during active polling) + if let prompt = poller.sessionData?.latestUserPrompt, !prompt.isEmpty { + return prompt + } + // Fall back to workspace data + return workspace?.latestUserPrompt + } + + /// Get the latest Claude message, preferring session data over workspace data + private var effectiveLatestMessage: String? { + // First check session data (more up-to-date during active polling) + if let msg = poller.sessionData?.latestMessage, !msg.isEmpty { + return msg + } + // Fall back to latestMessage state + return latestMessage + } + + /// Get session stats from session data + private var sessionStats: SessionStats? { + poller.sessionData?.stats + } + private var navigationTitle: String { // Show session title if available (in both working and completed phases) if let title = workspace?.latestSessionTitle, !title.isEmpty { @@ -312,8 +337,8 @@ struct WorkspaceDetailView: View { .foregroundStyle(.secondary) } - // Show the user's prompt (either pending or from workspace) - if let userPrompt = pendingUserPrompt ?? workspace?.latestUserPrompt, !userPrompt.isEmpty { + // Show the user's prompt (either pending or from session/workspace) + if let userPrompt = pendingUserPrompt ?? effectiveLatestUserPrompt, !userPrompt.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("You asked:") .font(.caption.weight(.semibold)) @@ -330,7 +355,7 @@ struct WorkspaceDetailView: View { } // Show Claude's latest message while working - if let claudeMessage = latestMessage, !claudeMessage.isEmpty { + if let claudeMessage = effectiveLatestMessage, !claudeMessage.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Claude is saying:") .font(.caption.weight(.semibold)) @@ -403,7 +428,7 @@ struct WorkspaceDetailView: View { // Session content with padding VStack(alignment: .leading, spacing: 8) { // User prompt - if let userPrompt = workspace?.latestUserPrompt, !userPrompt.isEmpty { + if let userPrompt = effectiveLatestUserPrompt, !userPrompt.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("You asked:") .font(.caption.weight(.semibold)) @@ -420,7 +445,7 @@ struct WorkspaceDetailView: View { } // Claude's response - if let claudeMessage = latestMessage, !claudeMessage.isEmpty { + if let claudeMessage = effectiveLatestMessage, !claudeMessage.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Claude responded:") .font(.caption.weight(.semibold)) From 284ceb1ecc8c126f1875ce3f2ae835918f1134d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Nov 2025 15:29:18 +0100 Subject: [PATCH 02/14] Fix empty state display for new workspaces without session history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the "Start Working" button instead of an empty gray box when a workspace is in completed phase but has no session content (no user prompt, no Claude message, no session title, no todos). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- xcode/catnip/Views/WorkspaceDetailView.swift | 29 ++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/xcode/catnip/Views/WorkspaceDetailView.swift b/xcode/catnip/Views/WorkspaceDetailView.swift index af9fc47d..9a179c2c 100644 --- a/xcode/catnip/Views/WorkspaceDetailView.swift +++ b/xcode/catnip/Views/WorkspaceDetailView.swift @@ -84,6 +84,28 @@ struct WorkspaceDetailView: View { poller.sessionData?.stats } + /// Check if we have any session content to display + /// Used to determine if we should show empty state vs completed state + private var hasSessionContent: Bool { + // Has user prompt + if let prompt = effectiveLatestUserPrompt, !prompt.isEmpty { + return true + } + // Has Claude message + if let msg = effectiveLatestMessage, !msg.isEmpty { + return true + } + // Has session title + if let title = workspace?.latestSessionTitle, !title.isEmpty { + return true + } + // Has todos + if let todos = workspace?.todos, !todos.isEmpty { + return true + } + return false + } + private var navigationTitle: String { // Show session title if available (in both working and completed phases) if let title = workspace?.latestSessionTitle, !title.isEmpty { @@ -276,7 +298,9 @@ struct WorkspaceDetailView: View { private var contentView: some View { ScrollView { VStack(spacing: 20) { - if phase == .input { + if phase == .input || (phase == .completed && !hasSessionContent) { + // Show empty state for input phase OR completed phase with no content + // (e.g., new workspace with commits but no Claude session) emptyStateView .padding(.horizontal, 16) } else if phase == .working { @@ -529,7 +553,8 @@ struct WorkspaceDetailView: View { private var footerView: some View { Group { - if phase == .completed { + if phase == .completed && hasSessionContent { + // Only show footer buttons if we have actual session content to show HStack(spacing: 12) { Button { showPromptSheet = true From 9ea04f8626f8106ae9700f49bac600ff9699d110 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Nov 2025 15:36:52 +0100 Subject: [PATCH 03/14] Fix session endpoint to return latestUserPrompt and correct path encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues fixed: 1. Backend: Return full FullSessionData object (not just SessionInfo) when full=false, so latestUserPrompt, latestMessage, latestThought, and stats are included in the response 2. Swift: Send full workspace path (e.g., "/worktrees/catnip/ruby") instead of just the last component, so backend can match against config 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/internal/handlers/sessions.go | 13 ++++--------- xcode/catnip/Services/CatnipAPI.swift | 7 +++---- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/container/internal/handlers/sessions.go b/container/internal/handlers/sessions.go index 9081978e..bf8f9cb3 100644 --- a/container/internal/handlers/sessions.go +++ b/container/internal/handlers/sessions.go @@ -105,13 +105,9 @@ func (h *SessionsHandler) GetSessionByWorkspace(c *fiber.Ctx) error { return c.JSON(fullData) } else { - // Try session service first (for active PTY sessions) - session, exists := h.sessionService.GetActiveSession(workspace) - if exists { - return c.JSON(session) - } - - // Fallback to Claude service for basic info (without full data) + // Return session data with latest prompt/message/stats but without full message history + // The full=false mode still includes latestUserPrompt, latestMessage, latestThought, stats + // It just omits the Messages and UserPrompts arrays fullData, err := h.claudeService.GetFullSessionData(workspace, false) if err != nil { return c.Status(500).JSON(fiber.Map{ @@ -126,8 +122,7 @@ func (h *SessionsHandler) GetSessionByWorkspace(c *fiber.Ctx) error { }) } - // Return just the session info part for basic requests - return c.JSON(fullData.SessionInfo) + return c.JSON(fullData) } } diff --git a/xcode/catnip/Services/CatnipAPI.swift b/xcode/catnip/Services/CatnipAPI.swift index 6e682e7b..d910e268 100644 --- a/xcode/catnip/Services/CatnipAPI.swift +++ b/xcode/catnip/Services/CatnipAPI.swift @@ -234,10 +234,9 @@ class CatnipAPI: ObservableObject { let headers = try await getHeaders(includeCodespace: true) - // The workspace path needs to be the workspace name (e.g., "vanpelt-catnip") - // not the full path, as it's passed in the URL path - let workspaceName = workspacePath.components(separatedBy: "/").last ?? workspacePath - guard let encodedPath = workspaceName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + // URL-encode the full workspace path for the path parameter + // The backend expects the full path (e.g., "/worktrees/catnip/ruby") + guard let encodedPath = workspacePath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), let url = URL(string: "\(baseURL)/v1/sessions/workspace/\(encodedPath)") else { throw APIError.invalidURL } From 5875bf3785cb6040ed1a88f8ce9e9ac67be353f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Nov 2025 15:42:21 +0100 Subject: [PATCH 04/14] Fix session endpoint URL routing for paths with slashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace path (e.g., /worktrees/catnip/ruby) contains slashes which break URL routing when used as a path parameter. Changed to use query parameter instead: - Backend: Accept workspace via ?workspace= query param (with path param fallback for backward compatibility) - Swift: Use URLComponents with query items to properly encode the path - Added route without path param that must come before parameterized route Now the URL is: /v1/sessions/workspace?workspace=/worktrees/catnip/ruby 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/docs/docs.go | 10 ++++--- container/docs/swagger.json | 10 ++++--- container/docs/swagger.yaml | 37 +++++++++++++------------ container/internal/cmd/serve.go | 3 ++ container/internal/handlers/sessions.go | 15 ++++++++-- xcode/catnip/Services/CatnipAPI.swift | 24 ++++++++++++---- 6 files changed, 65 insertions(+), 34 deletions(-) diff --git a/container/docs/docs.go b/container/docs/docs.go index e994ab61..dce6073b 100644 --- a/container/docs/docs.go +++ b/container/docs/docs.go @@ -1702,7 +1702,7 @@ const docTemplate = `{ } } }, - "/v1/sessions/workspace/{workspace}": { + "/v1/sessions/workspace": { "get": { "description": "Returns session information for a specific workspace directory. Use ?full=true for complete session data including messages.", "produces": [ @@ -1715,9 +1715,9 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Workspace directory path", + "description": "Workspace directory path (e.g., /worktrees/catnip/ruby)", "name": "workspace", - "in": "path", + "in": "query", "required": true }, { @@ -1735,7 +1735,9 @@ const docTemplate = `{ } } } - }, + } + }, + "/v1/sessions/workspace/{workspace}": { "delete": { "description": "Removes a session from the active sessions mapping", "produces": [ diff --git a/container/docs/swagger.json b/container/docs/swagger.json index 29973492..6e9cd791 100644 --- a/container/docs/swagger.json +++ b/container/docs/swagger.json @@ -1699,7 +1699,7 @@ } } }, - "/v1/sessions/workspace/{workspace}": { + "/v1/sessions/workspace": { "get": { "description": "Returns session information for a specific workspace directory. Use ?full=true for complete session data including messages.", "produces": [ @@ -1712,9 +1712,9 @@ "parameters": [ { "type": "string", - "description": "Workspace directory path", + "description": "Workspace directory path (e.g., /worktrees/catnip/ruby)", "name": "workspace", - "in": "path", + "in": "query", "required": true }, { @@ -1732,7 +1732,9 @@ } } } - }, + } + }, + "/v1/sessions/workspace/{workspace}": { "delete": { "description": "Removes a session from the active sessions mapping", "produces": [ diff --git a/container/docs/swagger.yaml b/container/docs/swagger.yaml index 1062ae79..129130a8 100644 --- a/container/docs/swagger.yaml +++ b/container/docs/swagger.yaml @@ -2409,46 +2409,47 @@ paths: summary: Get active sessions tags: - sessions - /v1/sessions/workspace/{workspace}: - delete: - description: Removes a session from the active sessions mapping + /v1/sessions/workspace: + get: + description: Returns session information for a specific workspace directory. + Use ?full=true for complete session data including messages. parameters: - - description: Workspace directory path - in: path + - description: Workspace directory path (e.g., /worktrees/catnip/ruby) + in: query name: workspace required: true type: string + - description: Include full session data with messages and user prompts + in: query + name: full + type: boolean produces: - application/json responses: "200": - description: OK + description: Basic session info when full=false schema: - $ref: '#/definitions/internal_handlers.DeleteSessionResponse' - summary: Delete session + $ref: '#/definitions/internal_handlers.ActiveSessionInfo' + summary: Get session by workspace tags: - sessions - get: - description: Returns session information for a specific workspace directory. - Use ?full=true for complete session data including messages. + /v1/sessions/workspace/{workspace}: + delete: + description: Removes a session from the active sessions mapping parameters: - description: Workspace directory path in: path name: workspace required: true type: string - - description: Include full session data with messages and user prompts - in: query - name: full - type: boolean produces: - application/json responses: "200": - description: Basic session info when full=false + description: OK schema: - $ref: '#/definitions/internal_handlers.ActiveSessionInfo' - summary: Get session by workspace + $ref: '#/definitions/internal_handlers.DeleteSessionResponse' + summary: Delete session tags: - sessions /v1/sessions/workspace/{workspace}/session/{sessionId}: diff --git a/container/internal/cmd/serve.go b/container/internal/cmd/serve.go index 416c9530..9781a884 100644 --- a/container/internal/cmd/serve.go +++ b/container/internal/cmd/serve.go @@ -316,6 +316,9 @@ func startServer(cmd *cobra.Command) { // Session management routes v1.Get("/sessions/active", sessionHandler.GetActiveSessions) v1.Get("/sessions", sessionHandler.GetAllSessions) + // Query param version (preferred) - must come before path param version + v1.Get("/sessions/workspace", sessionHandler.GetSessionByWorkspace) + // Path param version (legacy, for backward compatibility) v1.Get("/sessions/workspace/:workspace", sessionHandler.GetSessionByWorkspace) v1.Get("/sessions/workspace/:workspace/session/:sessionId", sessionHandler.GetSessionById) v1.Delete("/sessions/workspace/:workspace", sessionHandler.DeleteSession) diff --git a/container/internal/handlers/sessions.go b/container/internal/handlers/sessions.go index bf8f9cb3..e5c584a6 100644 --- a/container/internal/handlers/sessions.go +++ b/container/internal/handlers/sessions.go @@ -78,12 +78,21 @@ func (h *SessionsHandler) GetAllSessions(c *fiber.Ctx) error { // @Description Returns session information for a specific workspace directory. Use ?full=true for complete session data including messages. // @Tags sessions // @Produce json -// @Param workspace path string true "Workspace directory path" +// @Param workspace query string true "Workspace directory path (e.g., /worktrees/catnip/ruby)" // @Param full query boolean false "Include full session data with messages and user prompts" // @Success 200 {object} ActiveSessionInfo "Basic session info when full=false" -// @Router /v1/sessions/workspace/{workspace} [get] +// @Router /v1/sessions/workspace [get] func (h *SessionsHandler) GetSessionByWorkspace(c *fiber.Ctx) error { - workspace := c.Params("workspace") + // Get workspace from query parameter (preferred) or path parameter (legacy) + workspace := c.Query("workspace") + if workspace == "" { + workspace = c.Params("workspace") + } + if workspace == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "workspace query parameter is required", + }) + } fullParam := c.Query("full", "false") includeFull := fullParam == "true" diff --git a/xcode/catnip/Services/CatnipAPI.swift b/xcode/catnip/Services/CatnipAPI.swift index d910e268..335fdfaa 100644 --- a/xcode/catnip/Services/CatnipAPI.swift +++ b/xcode/catnip/Services/CatnipAPI.swift @@ -234,13 +234,17 @@ class CatnipAPI: ObservableObject { let headers = try await getHeaders(includeCodespace: true) - // URL-encode the full workspace path for the path parameter - // The backend expects the full path (e.g., "/worktrees/catnip/ruby") - guard let encodedPath = workspacePath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), - let url = URL(string: "\(baseURL)/v1/sessions/workspace/\(encodedPath)") else { + // Use query parameter for workspace path (handles slashes correctly) + guard var components = URLComponents(string: "\(baseURL)/v1/sessions/workspace") else { + throw APIError.invalidURL + } + components.queryItems = [URLQueryItem(name: "workspace", value: workspacePath)] + guard let url = components.url else { throw APIError.invalidURL } + NSLog("📊 [CatnipAPI] Fetching session data from: \(url.absoluteString)") + var request = URLRequest(url: url) request.allHTTPHeaderFields = headers @@ -259,7 +263,17 @@ class CatnipAPI: ObservableObject { throw APIError.serverError(httpResponse.statusCode, "Failed to fetch session data") } - return try decoder.decode(SessionData.self, from: data) + // Debug: log the raw JSON response + if let jsonString = String(data: data, encoding: .utf8) { + NSLog("📊 [CatnipAPI] Session data response: \(jsonString.prefix(500))...") + } + + do { + return try decoder.decode(SessionData.self, from: data) + } catch { + NSLog("❌ [CatnipAPI] Failed to decode SessionData: \(error)") + throw error + } } func startPTY(workspacePath: String, agent: String = "claude") async throws { From 9cc6d0186572ce941cd611d6942cc01b95103017 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Nov 2025 15:46:08 +0100 Subject: [PATCH 05/14] Use workspace ID (UUID) for session endpoint instead of path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Much simpler approach - the Swift app already has the workspace ID from the worktrees list. Using the ID in the URL path is cleaner than trying to encode paths with slashes. - Backend: Accept workspace ID or path in the path param, resolve ID to path using GitService.GetWorktree() - Swift: Use workspaceId directly in URL path - Remove unused workspacePath from WorkspacePoller URL is now: /v1/sessions/workspace/{workspace-uuid} 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/docs/docs.go | 12 +++--- container/docs/swagger.json | 12 +++--- container/docs/swagger.yaml | 39 +++++++++--------- container/internal/cmd/serve.go | 6 +-- container/internal/handlers/sessions.go | 44 +++++++++++++++------ xcode/catnip/Services/CatnipAPI.swift | 12 ++---- xcode/catnip/Services/WorkspacePoller.swift | 8 ++-- 7 files changed, 70 insertions(+), 63 deletions(-) diff --git a/container/docs/docs.go b/container/docs/docs.go index dce6073b..b541a318 100644 --- a/container/docs/docs.go +++ b/container/docs/docs.go @@ -1702,9 +1702,9 @@ const docTemplate = `{ } } }, - "/v1/sessions/workspace": { + "/v1/sessions/workspace/{workspace}": { "get": { - "description": "Returns session information for a specific workspace directory. Use ?full=true for complete session data including messages.", + "description": "Returns session information for a specific workspace. Accepts workspace ID (UUID) or path. Use ?full=true for complete session data including messages.", "produces": [ "application/json" ], @@ -1715,9 +1715,9 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Workspace directory path (e.g., /worktrees/catnip/ruby)", + "description": "Workspace ID (UUID) or directory path", "name": "workspace", - "in": "query", + "in": "path", "required": true }, { @@ -1735,9 +1735,7 @@ const docTemplate = `{ } } } - } - }, - "/v1/sessions/workspace/{workspace}": { + }, "delete": { "description": "Removes a session from the active sessions mapping", "produces": [ diff --git a/container/docs/swagger.json b/container/docs/swagger.json index 6e9cd791..541303bc 100644 --- a/container/docs/swagger.json +++ b/container/docs/swagger.json @@ -1699,9 +1699,9 @@ } } }, - "/v1/sessions/workspace": { + "/v1/sessions/workspace/{workspace}": { "get": { - "description": "Returns session information for a specific workspace directory. Use ?full=true for complete session data including messages.", + "description": "Returns session information for a specific workspace. Accepts workspace ID (UUID) or path. Use ?full=true for complete session data including messages.", "produces": [ "application/json" ], @@ -1712,9 +1712,9 @@ "parameters": [ { "type": "string", - "description": "Workspace directory path (e.g., /worktrees/catnip/ruby)", + "description": "Workspace ID (UUID) or directory path", "name": "workspace", - "in": "query", + "in": "path", "required": true }, { @@ -1732,9 +1732,7 @@ } } } - } - }, - "/v1/sessions/workspace/{workspace}": { + }, "delete": { "description": "Removes a session from the active sessions mapping", "produces": [ diff --git a/container/docs/swagger.yaml b/container/docs/swagger.yaml index 129130a8..3e53212e 100644 --- a/container/docs/swagger.yaml +++ b/container/docs/swagger.yaml @@ -2409,47 +2409,46 @@ paths: summary: Get active sessions tags: - sessions - /v1/sessions/workspace: - get: - description: Returns session information for a specific workspace directory. - Use ?full=true for complete session data including messages. + /v1/sessions/workspace/{workspace}: + delete: + description: Removes a session from the active sessions mapping parameters: - - description: Workspace directory path (e.g., /worktrees/catnip/ruby) - in: query + - description: Workspace directory path + in: path name: workspace required: true type: string - - description: Include full session data with messages and user prompts - in: query - name: full - type: boolean produces: - application/json responses: "200": - description: Basic session info when full=false + description: OK schema: - $ref: '#/definitions/internal_handlers.ActiveSessionInfo' - summary: Get session by workspace + $ref: '#/definitions/internal_handlers.DeleteSessionResponse' + summary: Delete session tags: - sessions - /v1/sessions/workspace/{workspace}: - delete: - description: Removes a session from the active sessions mapping + get: + description: Returns session information for a specific workspace. Accepts workspace + ID (UUID) or path. Use ?full=true for complete session data including messages. parameters: - - description: Workspace directory path + - description: Workspace ID (UUID) or directory path in: path name: workspace required: true type: string + - description: Include full session data with messages and user prompts + in: query + name: full + type: boolean produces: - application/json responses: "200": - description: OK + description: Basic session info when full=false schema: - $ref: '#/definitions/internal_handlers.DeleteSessionResponse' - summary: Delete session + $ref: '#/definitions/internal_handlers.ActiveSessionInfo' + summary: Get session by workspace tags: - sessions /v1/sessions/workspace/{workspace}/session/{sessionId}: diff --git a/container/internal/cmd/serve.go b/container/internal/cmd/serve.go index 9781a884..90dc124a 100644 --- a/container/internal/cmd/serve.go +++ b/container/internal/cmd/serve.go @@ -236,7 +236,7 @@ func startServer(cmd *cobra.Command) { authHandler := handlers.NewAuthHandler() uploadHandler := handlers.NewUploadHandler() gitHandler := handlers.NewGitHandler(gitService, gitHTTPService, sessionService, claudeMonitor) - sessionHandler := handlers.NewSessionsHandler(sessionService, claudeService) + sessionHandler := handlers.NewSessionsHandler(sessionService, claudeService, gitService) eventsHandler := handlers.NewEventsHandler(portMonitor, gitService) claudeHandler := handlers.NewClaudeHandler(claudeService, gitService).WithEvents(eventsHandler).WithOnboardingService(claudeOnboardingService).WithPTYHandler(ptyHandler) defer eventsHandler.Stop() @@ -316,9 +316,7 @@ func startServer(cmd *cobra.Command) { // Session management routes v1.Get("/sessions/active", sessionHandler.GetActiveSessions) v1.Get("/sessions", sessionHandler.GetAllSessions) - // Query param version (preferred) - must come before path param version - v1.Get("/sessions/workspace", sessionHandler.GetSessionByWorkspace) - // Path param version (legacy, for backward compatibility) + // Workspace param can be either a workspace ID (UUID) or a path v1.Get("/sessions/workspace/:workspace", sessionHandler.GetSessionByWorkspace) v1.Get("/sessions/workspace/:workspace/session/:sessionId", sessionHandler.GetSessionById) v1.Delete("/sessions/workspace/:workspace", sessionHandler.DeleteSession) diff --git a/container/internal/handlers/sessions.go b/container/internal/handlers/sessions.go index e5c584a6..769b1ab5 100644 --- a/container/internal/handlers/sessions.go +++ b/container/internal/handlers/sessions.go @@ -11,6 +11,7 @@ import ( type SessionsHandler struct { sessionService *services.SessionService claudeService *services.ClaudeService + gitService *services.GitService } // SessionsResponse represents the response containing all sessions @@ -42,10 +43,11 @@ type DeleteSessionResponse struct { } // NewSessionsHandler creates a new sessions handler -func NewSessionsHandler(sessionService *services.SessionService, claudeService *services.ClaudeService) *SessionsHandler { +func NewSessionsHandler(sessionService *services.SessionService, claudeService *services.ClaudeService, gitService *services.GitService) *SessionsHandler { return &SessionsHandler{ sessionService: sessionService, claudeService: claudeService, + gitService: gitService, } } @@ -75,30 +77,37 @@ func (h *SessionsHandler) GetAllSessions(c *fiber.Ctx) error { // GetSessionByWorkspace returns session for a specific workspace // @Summary Get session by workspace -// @Description Returns session information for a specific workspace directory. Use ?full=true for complete session data including messages. +// @Description Returns session information for a specific workspace. Accepts workspace ID (UUID) or path. Use ?full=true for complete session data including messages. // @Tags sessions // @Produce json -// @Param workspace query string true "Workspace directory path (e.g., /worktrees/catnip/ruby)" +// @Param workspace path string true "Workspace ID (UUID) or directory path" // @Param full query boolean false "Include full session data with messages and user prompts" // @Success 200 {object} ActiveSessionInfo "Basic session info when full=false" -// @Router /v1/sessions/workspace [get] +// @Router /v1/sessions/workspace/{workspace} [get] func (h *SessionsHandler) GetSessionByWorkspace(c *fiber.Ctx) error { - // Get workspace from query parameter (preferred) or path parameter (legacy) - workspace := c.Query("workspace") - if workspace == "" { - workspace = c.Params("workspace") - } + // Get workspace from path parameter + workspace := c.Params("workspace") if workspace == "" { return c.Status(400).JSON(fiber.Map{ - "error": "workspace query parameter is required", + "error": "workspace parameter is required", }) } + + // Try to resolve as a workspace ID first (UUID) + // If it looks like a UUID (contains hyphen, no slashes), try to resolve it + worktreePath := workspace + if h.gitService != nil && !containsSlash(workspace) { + if worktree, exists := h.gitService.GetWorktree(workspace); exists && worktree != nil { + worktreePath = worktree.Path + } + } + fullParam := c.Query("full", "false") includeFull := fullParam == "true" if includeFull { // Return full session data using Claude service - fullData, err := h.claudeService.GetFullSessionData(workspace, true) + fullData, err := h.claudeService.GetFullSessionData(worktreePath, true) if err != nil { return c.Status(500).JSON(fiber.Map{ "error": "Failed to get full session data", @@ -117,7 +126,7 @@ func (h *SessionsHandler) GetSessionByWorkspace(c *fiber.Ctx) error { // Return session data with latest prompt/message/stats but without full message history // The full=false mode still includes latestUserPrompt, latestMessage, latestThought, stats // It just omits the Messages and UserPrompts arrays - fullData, err := h.claudeService.GetFullSessionData(workspace, false) + fullData, err := h.claudeService.GetFullSessionData(worktreePath, false) if err != nil { return c.Status(500).JSON(fiber.Map{ "error": "Failed to get session data", @@ -187,3 +196,14 @@ func (h *SessionsHandler) GetSessionById(c *fiber.Ctx) error { return c.JSON(sessionData) } + +// containsSlash checks if a string contains a forward slash +// Used to distinguish between workspace IDs (UUIDs) and paths +func containsSlash(s string) bool { + for _, c := range s { + if c == '/' { + return true + } + } + return false +} diff --git a/xcode/catnip/Services/CatnipAPI.swift b/xcode/catnip/Services/CatnipAPI.swift index 335fdfaa..8947f815 100644 --- a/xcode/catnip/Services/CatnipAPI.swift +++ b/xcode/catnip/Services/CatnipAPI.swift @@ -226,20 +226,16 @@ class CatnipAPI: ObservableObject { /// Fetch session data for a specific workspace - lightweight polling endpoint /// This endpoint returns latest user prompt, latest message, latest thought, and session stats /// Use this for polling during active sessions instead of the heavier /v1/git/worktrees endpoint - func getSessionData(workspacePath: String) async throws -> SessionData? { + func getSessionData(workspaceId: String) async throws -> SessionData? { // Return mock data in UI testing mode if UITestingHelper.shouldUseMockData { - return UITestingHelper.getMockSessionData(workspacePath: workspacePath) + return UITestingHelper.getMockSessionData(workspacePath: workspaceId) } let headers = try await getHeaders(includeCodespace: true) - // Use query parameter for workspace path (handles slashes correctly) - guard var components = URLComponents(string: "\(baseURL)/v1/sessions/workspace") else { - throw APIError.invalidURL - } - components.queryItems = [URLQueryItem(name: "workspace", value: workspacePath)] - guard let url = components.url else { + // Use workspace ID (UUID) in URL path - simple and clean + guard let url = URL(string: "\(baseURL)/v1/sessions/workspace/\(workspaceId)") else { throw APIError.invalidURL } diff --git a/xcode/catnip/Services/WorkspacePoller.swift b/xcode/catnip/Services/WorkspacePoller.swift index d1c45fdb..47af4c3b 100644 --- a/xcode/catnip/Services/WorkspacePoller.swift +++ b/xcode/catnip/Services/WorkspacePoller.swift @@ -51,7 +51,6 @@ class WorkspacePoller: ObservableObject { // MARK: - Private Properties private let workspaceId: String - private let workspacePath: String private var pollingTask: Task? private var appStateObserver: NSObjectProtocol? private var lastETag: String? @@ -62,14 +61,13 @@ class WorkspacePoller: ObservableObject { init(workspaceId: String, initialWorkspace: WorkspaceInfo? = nil) { self.workspaceId = workspaceId - self.workspacePath = initialWorkspace?.path ?? "" // Initialize with provided workspace if available if let initialWorkspace = initialWorkspace { self.workspace = initialWorkspace self.lastActivityStateChange = Date() self.previousActivityState = initialWorkspace.claudeActivityState - NSLog("📊 Initialized poller with existing workspace data, path: \(initialWorkspace.path)") + NSLog("📊 Initialized poller with existing workspace data, id: \(workspaceId)") } setupAppStateObservers() @@ -171,7 +169,7 @@ class WorkspacePoller: ObservableObject { let isActiveSession = workspace?.claudeActivityState == .active || Date().timeIntervalSince(lastActivityStateChange) < 120 - if isActiveSession && !workspacePath.isEmpty { + if isActiveSession { await pollSessionData() } else { await pollFullWorkspace() @@ -181,7 +179,7 @@ class WorkspacePoller: ObservableObject { /// Poll the lightweight session endpoint for active sessions private func pollSessionData() async { do { - let newSessionData = try await CatnipAPI.shared.getSessionData(workspacePath: workspacePath) + let newSessionData = try await CatnipAPI.shared.getSessionData(workspaceId: workspaceId) if let sessionData = newSessionData { self.sessionData = sessionData From b3f81b2fa4772923d9b761936c4847a4d174e57e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Dec 2025 12:34:25 -0500 Subject: [PATCH 06/14] Fix session file selection to use same logic as catnip reflect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use paths.FindBestSessionFile in ClaudeService instead of SessionService - This filters out agent-*.jsonl subagent sessions via UUID validation - Checks for conversation content (not just snapshots) - Filters out forked sessions (queue-operation) - Prioritizes most recently modified, then largest size Also adds latestClaudeMessage field to Swift WorkspaceInfo model for fallback display when session endpoint returns empty. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/internal/handlers/sessions.go | 3 + container/internal/services/claude.go | 65 ++++++++------------ xcode/catnip/Models/PreviewData.swift | 3 + xcode/catnip/Models/WorkspaceInfo.swift | 2 + xcode/catnip/Services/CatnipAPI.swift | 2 +- xcode/catnip/Services/UITestingHelper.swift | 3 + xcode/catnip/Services/WorkspacePoller.swift | 11 ++-- xcode/catnip/Views/WorkspaceDetailView.swift | 16 +++-- xcode/catnipTests/TestHelpers.swift | 2 + xcode/catnipTests/WorkspaceInfoTests.swift | 18 ++++++ 10 files changed, 75 insertions(+), 50 deletions(-) diff --git a/container/internal/handlers/sessions.go b/container/internal/handlers/sessions.go index 769b1ab5..85f736e3 100644 --- a/container/internal/handlers/sessions.go +++ b/container/internal/handlers/sessions.go @@ -99,6 +99,9 @@ func (h *SessionsHandler) GetSessionByWorkspace(c *fiber.Ctx) error { if h.gitService != nil && !containsSlash(workspace) { if worktree, exists := h.gitService.GetWorktree(workspace); exists && worktree != nil { worktreePath = worktree.Path + // Debug: log the resolution + c.Set("X-Debug-Workspace-ID", workspace) + c.Set("X-Debug-Worktree-Path", worktreePath) } } diff --git a/container/internal/services/claude.go b/container/internal/services/claude.go index 54cabefc..93acfcbd 100644 --- a/container/internal/services/claude.go +++ b/container/internal/services/claude.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "io/fs" "os" "os/exec" "path/filepath" @@ -17,6 +16,7 @@ import ( "github.com/creack/pty" "github.com/vanpelt/catnip/internal/claude/parser" + "github.com/vanpelt/catnip/internal/claude/paths" "github.com/vanpelt/catnip/internal/config" "github.com/vanpelt/catnip/internal/logger" "github.com/vanpelt/catnip/internal/models" @@ -55,6 +55,14 @@ func WorktreePathToProjectDir(worktreePath string) string { return projectDirName } +// truncate returns the first n characters of a string, adding "..." if truncated +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + // NewClaudeService creates a new Claude service func NewClaudeService() *ClaudeService { // Use runtime-appropriate directories @@ -274,47 +282,13 @@ func (s *ClaudeService) getSessionTiming(worktreePath string) (*SessionTimingWit } // findLatestSessionFile finds the best session file with content -// Uses SessionService's size-based logic to avoid warmup/small sessions +// Uses the same logic as `catnip reflect` via paths.FindBestSessionFile: +// - Validates UUID format (filters out agent-*.jsonl) +// - Checks for conversation content (filters out snapshot-only files) +// - Filters out forked sessions (queue-operation) +// - Prioritizes most recently modified, then largest size func (s *ClaudeService) findLatestSessionFile(projectDir string) (string, error) { - // Use SessionService's proven logic that filters by size (>10KB) and prefers largest sessions - if s.sessionService != nil { - sessionFile := s.sessionService.FindBestSessionFile(projectDir) - if sessionFile != "" { - return sessionFile, nil - } - } - - // Fallback to old logic if SessionService not available (shouldn't happen in production) - logger.Warn("⚠️ SessionService not set in ClaudeService, using fallback session selection") - - entries, err := os.ReadDir(projectDir) - if err != nil { - if os.IsNotExist(err) { - return "", fmt.Errorf("project directory does not exist: %s", projectDir) - } - return "", fmt.Errorf("failed to read project directory: %w", err) - } - - var sessionFiles []fs.DirEntry - for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".jsonl") { - sessionFiles = append(sessionFiles, entry) - } - } - - if len(sessionFiles) == 0 { - return "", fmt.Errorf("no session files found in %s", projectDir) - } - - // Sort by modification time (most recent first) - sort.Slice(sessionFiles, func(i, j int) bool { - infoI, _ := sessionFiles[i].Info() - infoJ, _ := sessionFiles[j].Info() - return infoI.ModTime().After(infoJ.ModTime()) - }) - - // Return the most recent file - return filepath.Join(projectDir, sessionFiles[0].Name()), nil + return paths.FindBestSessionFile(projectDir) } // readSessionTiming reads the first and last timestamps from a session file @@ -432,24 +406,33 @@ func (s *ClaudeService) GetFullSessionData(worktreePath string, includeFullData // populateLatestDataFromParser populates latest user prompt, message, thought and stats from the parser func (s *ClaudeService) populateLatestDataFromParser(worktreePath string, fullData *models.FullSessionData) { if s.parserService == nil { + logger.Debugf("📊 populateLatestDataFromParser: parserService is nil for %s", worktreePath) return } reader, err := s.parserService.GetOrCreateParser(worktreePath) if err != nil { + logger.Debugf("📊 populateLatestDataFromParser: failed to get parser for %s: %v", worktreePath, err) return // Parser not available yet } + logger.Debugf("📊 populateLatestDataFromParser: got parser for %s", worktreePath) // Get latest user prompt from history latestUserPrompt, err := reader.GetLatestUserPrompt() if err == nil && latestUserPrompt != "" { fullData.LatestUserPrompt = latestUserPrompt + logger.Debugf("📊 populateLatestDataFromParser: got user prompt: %s...", truncate(latestUserPrompt, 50)) + } else { + logger.Debugf("📊 populateLatestDataFromParser: no user prompt (err=%v)", err) } // Get latest assistant message latestMsg := reader.GetLatestMessage() if latestMsg != nil { fullData.LatestMessage = parser.ExtractTextContent(*latestMsg) + logger.Debugf("📊 populateLatestDataFromParser: got latest message: %s...", truncate(fullData.LatestMessage, 50)) + } else { + logger.Debugf("📊 populateLatestDataFromParser: no latest message") } // Get latest thought/thinking diff --git a/xcode/catnip/Models/PreviewData.swift b/xcode/catnip/Models/PreviewData.swift index f3189da6..3032c4bb 100644 --- a/xcode/catnip/Models/PreviewData.swift +++ b/xcode/catnip/Models/PreviewData.swift @@ -26,6 +26,7 @@ extension WorkspaceInfo { ], latestSessionTitle: "I'll help you update the API documentation for v2.0. I've reviewed the endpoint descriptions and added code examples for better clarity.", latestUserPrompt: "Can you help me update our API documentation for the v2.0 endpoints?", + latestClaudeMessage: "I've reviewed the endpoint descriptions and added code examples for better clarity. The documentation now includes detailed explanations of all request/response formats.", pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -46,6 +47,7 @@ extension WorkspaceInfo { todos: nil, latestSessionTitle: nil, latestUserPrompt: "Fix the token refresh logic in the authentication module", + latestClaudeMessage: nil, pullRequestUrl: "https://github.com/wandb/catnip/pull/123", pullRequestState: "OPEN", hasCommitsAheadOfRemote: nil, @@ -66,6 +68,7 @@ extension WorkspaceInfo { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, diff --git a/xcode/catnip/Models/WorkspaceInfo.swift b/xcode/catnip/Models/WorkspaceInfo.swift index be92dcb9..a62ffd68 100644 --- a/xcode/catnip/Models/WorkspaceInfo.swift +++ b/xcode/catnip/Models/WorkspaceInfo.swift @@ -20,6 +20,7 @@ struct WorkspaceInfo: Codable, Identifiable, Hashable { let todos: [Todo]? let latestSessionTitle: String? let latestUserPrompt: String? + let latestClaudeMessage: String? let pullRequestUrl: String? let pullRequestState: String? let hasCommitsAheadOfRemote: Bool? @@ -36,6 +37,7 @@ struct WorkspaceInfo: Codable, Identifiable, Hashable { case createdAt = "created_at" case latestSessionTitle = "latest_session_title" case latestUserPrompt = "latest_user_prompt" + case latestClaudeMessage = "latest_claude_message" case pullRequestUrl = "pull_request_url" case pullRequestState = "pull_request_state" case hasCommitsAheadOfRemote = "has_commits_ahead_of_remote" diff --git a/xcode/catnip/Services/CatnipAPI.swift b/xcode/catnip/Services/CatnipAPI.swift index 8947f815..4b4d1ad8 100644 --- a/xcode/catnip/Services/CatnipAPI.swift +++ b/xcode/catnip/Services/CatnipAPI.swift @@ -63,7 +63,7 @@ class CatnipAPI: ObservableObject { private func getHeaders(includeCodespace: Bool = false) async throws -> [String: String] { let token = try await getSessionToken() - + NSLog("🐱 [CatnipAPI] Session Token: \(token)") var headers = [ "Content-Type": "application/json", "Authorization": "Bearer \(token)" diff --git a/xcode/catnip/Services/UITestingHelper.swift b/xcode/catnip/Services/UITestingHelper.swift index 88495664..574f0175 100644 --- a/xcode/catnip/Services/UITestingHelper.swift +++ b/xcode/catnip/Services/UITestingHelper.swift @@ -103,6 +103,7 @@ struct UITestingHelper { ], latestSessionTitle: "Implementing GitHub OAuth", latestUserPrompt: "Add GitHub authentication", + latestClaudeMessage: "I've implemented the OAuth flow with GitHub. The token validation is in progress.", pullRequestUrl: "https://github.com/wandb/catnip/pull/123", pullRequestState: "OPEN", hasCommitsAheadOfRemote: nil, @@ -122,6 +123,7 @@ struct UITestingHelper { todos: nil, latestSessionTitle: "Fixed API error handling", latestUserPrompt: "Fix API errors", + latestClaudeMessage: "The API error handling has been fixed.", pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -141,6 +143,7 @@ struct UITestingHelper { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, diff --git a/xcode/catnip/Services/WorkspacePoller.swift b/xcode/catnip/Services/WorkspacePoller.swift index 47af4c3b..519e50ff 100644 --- a/xcode/catnip/Services/WorkspacePoller.swift +++ b/xcode/catnip/Services/WorkspacePoller.swift @@ -149,8 +149,9 @@ class WorkspacePoller: ObservableObject { let timeSinceLastChange = Date().timeIntervalSince(lastActivityStateChange) - // Active: Claude is actively working - if workspace.claudeActivityState == .active { + // Active session: Poll frequently when Claude is actively working (.active) or session is running + // .running means session exists but Claude may start working soon + if workspace.claudeActivityState == .active || workspace.claudeActivityState == .running { return .active } @@ -166,7 +167,8 @@ class WorkspacePoller: ObservableObject { private func pollWorkspace() async { // For active or recently active sessions, use the lightweight session endpoint - let isActiveSession = workspace?.claudeActivityState == .active || + let activityState = workspace?.claudeActivityState + let isActiveSession = activityState == .active || activityState == .running || Date().timeIntervalSince(lastActivityStateChange) < 120 if isActiveSession { @@ -191,7 +193,8 @@ class WorkspacePoller: ObservableObject { let previousState = workspace?.claudeActivityState // Track activity state changes for polling interval adaptation - if !isActive && previousState == .active { + let wasActive = previousState == .active || previousState == .running + if !isActive && wasActive { lastActivityStateChange = Date() NSLog("📊 Session became inactive, will switch to full polling soon") } diff --git a/xcode/catnip/Views/WorkspaceDetailView.swift b/xcode/catnip/Views/WorkspaceDetailView.swift index 9a179c2c..ac0049f6 100644 --- a/xcode/catnip/Views/WorkspaceDetailView.swift +++ b/xcode/catnip/Views/WorkspaceDetailView.swift @@ -75,7 +75,11 @@ struct WorkspaceDetailView: View { if let msg = poller.sessionData?.latestMessage, !msg.isEmpty { return msg } - // Fall back to latestMessage state + // Fall back to workspace's latestClaudeMessage (from worktrees endpoint) + if let msg = workspace?.latestClaudeMessage, !msg.isEmpty { + return msg + } + // Final fallback to latestMessage state return latestMessage } @@ -708,8 +712,9 @@ struct WorkspaceDetailView: View { } // Show "working" phase when: - // 1. Claude is ACTIVE (actively working), OR + // 1. Claude is ACTIVE (actively processing), OR // 2. We have a pending prompt (just sent a prompt but backend hasn't updated yet) + // Note: .running means session exists but Claude isn't actively working - show completed phase if workspace.claudeActivityState == .active || pendingUserPrompt != nil { phase = .working @@ -822,7 +827,8 @@ struct WorkspaceDetailView: View { // Skip if we already have a cached diff and Claude is still actively working // We want to refetch periodically during active work, but avoid spamming requests // When work completes, we'll refetch one final time from the completed phase - if cachedDiff != nil && workspace.claudeActivityState == .active { + let isActive = workspace.claudeActivityState == .active + if cachedDiff != nil && isActive { NSLog("📊 Diff already cached and workspace still active, skipping fetch to avoid spam") return } @@ -830,7 +836,7 @@ struct WorkspaceDetailView: View { NSLog("📊 Fetching diff for workspace with changes (dirty: %@, commits: %d, active: %@)", workspace.isDirty.map { "\($0)" } ?? "nil", workspace.commitCount ?? 0, - workspace.claudeActivityState == .active ? "yes" : "no") + isActive ? "yes" : "no") do { let diff = try await CatnipAPI.shared.getWorkspaceDiff(id: workspace.id) @@ -1077,6 +1083,7 @@ private struct WorkspaceDetailPreview: View { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -1097,6 +1104,7 @@ private struct WorkspaceDetailPreview: View { todos: Todo.previewList, latestSessionTitle: "Implementing new feature", latestUserPrompt: nil, + latestClaudeMessage: "Working on the new feature...", pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: workspace.hasCommitsAheadOfRemote, diff --git a/xcode/catnipTests/TestHelpers.swift b/xcode/catnipTests/TestHelpers.swift index 13db2366..125727c5 100644 --- a/xcode/catnipTests/TestHelpers.swift +++ b/xcode/catnipTests/TestHelpers.swift @@ -27,6 +27,7 @@ struct MockDataFactory { todos: [Todo]? = nil, latestSessionTitle: String? = nil, latestUserPrompt: String? = nil, + latestClaudeMessage: String? = nil, pullRequestUrl: String? = nil, pullRequestState: String? = nil, hasCommitsAheadOfRemote: Bool? = nil, @@ -46,6 +47,7 @@ struct MockDataFactory { todos: todos, latestSessionTitle: latestSessionTitle, latestUserPrompt: latestUserPrompt, + latestClaudeMessage: latestClaudeMessage, pullRequestUrl: pullRequestUrl, pullRequestState: pullRequestState, hasCommitsAheadOfRemote: hasCommitsAheadOfRemote, diff --git a/xcode/catnipTests/WorkspaceInfoTests.swift b/xcode/catnipTests/WorkspaceInfoTests.swift index a91f5780..c676a771 100644 --- a/xcode/catnipTests/WorkspaceInfoTests.swift +++ b/xcode/catnipTests/WorkspaceInfoTests.swift @@ -27,6 +27,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -51,6 +52,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -77,6 +79,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -101,6 +104,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -125,6 +129,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -149,6 +154,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -173,6 +179,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -199,6 +206,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -223,6 +231,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -247,6 +256,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -271,6 +281,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -304,6 +315,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -336,6 +348,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -360,6 +373,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -386,6 +400,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: "Implementing new feature", latestUserPrompt: "Write some code", + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -410,6 +425,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: "Fix the bug", + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -434,6 +450,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: "", latestUserPrompt: "Do something", + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, @@ -458,6 +475,7 @@ struct WorkspaceInfoTests { todos: nil, latestSessionTitle: nil, latestUserPrompt: nil, + latestClaudeMessage: nil, pullRequestUrl: nil, pullRequestState: nil, hasCommitsAheadOfRemote: nil, From b797e97f2be7e91a13361ff3c1bdad0583744d45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Dec 2025 15:19:35 -0500 Subject: [PATCH 07/14] Fix stale session data by refreshing parser before reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit populateLatestDataFromParser was returning cached/stale session data because it didn't call ReadIncremental() to refresh the parser. The user prompt was correct because it reads from ~/.claude/history.jsonl fresh each time, but latestMessage/thought/stats came from cached parser state. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/internal/services/claude.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/container/internal/services/claude.go b/container/internal/services/claude.go index 93acfcbd..a27ba007 100644 --- a/container/internal/services/claude.go +++ b/container/internal/services/claude.go @@ -417,6 +417,10 @@ func (s *ClaudeService) populateLatestDataFromParser(worktreePath string, fullDa } logger.Debugf("📊 populateLatestDataFromParser: got parser for %s", worktreePath) + // Refresh parser to get latest data from session file + // This is critical - without it we return stale cached data + _, _ = reader.ReadIncremental() // Ignore errors - use cached data if refresh fails + // Get latest user prompt from history latestUserPrompt, err := reader.GetLatestUserPrompt() if err == nil && latestUserPrompt != "" { From 739fa45def1354acb47edc082ceb813d94b98bdc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Dec 2025 15:27:47 -0500 Subject: [PATCH 08/14] Fix ParserService to use paths.FindBestSessionFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ParserService had its own findBestSessionInDir() that did NOT filter by UUID format, so it was selecting agent-*.jsonl files when they were larger than the main session file. Now uses paths.FindBestSessionFile which properly: - Validates UUID format (filters out agent-*.jsonl) - Checks for conversation content (not just snapshots) - Filters out forked sessions (queue-operation) - Prioritizes most recently modified, then largest size This ensures the session endpoint returns data from the same session file as `catnip reflect`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/internal/services/claude_parser.go | 56 +++---------------- .../internal/services/claude_parser_test.go | 49 ---------------- 2 files changed, 9 insertions(+), 96 deletions(-) diff --git a/container/internal/services/claude_parser.go b/container/internal/services/claude_parser.go index 621e1767..c9d9d7f5 100644 --- a/container/internal/services/claude_parser.go +++ b/container/internal/services/claude_parser.go @@ -2,12 +2,12 @@ package services import ( "fmt" - "os" "path/filepath" "sync" "time" "github.com/vanpelt/catnip/internal/claude/parser" + "github.com/vanpelt/catnip/internal/claude/paths" "github.com/vanpelt/catnip/internal/config" "github.com/vanpelt/catnip/internal/logger" ) @@ -77,6 +77,7 @@ func (s *ParserService) GetOrCreateParser(worktreePath string) (*parser.SessionF if err != nil { return nil, fmt.Errorf("failed to find session file for %s: %w", worktreePath, err) } + logger.Debugf("📖 GetOrCreateParser: using session file %s for worktree %s", sessionFile, worktreePath) s.parsersMutex.Lock() defer s.parsersMutex.Unlock() @@ -161,7 +162,11 @@ func (s *ParserService) RemoveParser(worktreePath string) { } // findSessionFile finds the best session file for a given worktree -// Uses the same logic as ClaudeService to ensure consistency +// Uses paths.FindBestSessionFile which properly: +// - Validates UUID format (filters out agent-*.jsonl) +// - Checks for conversation content (not just snapshots) +// - Filters out forked sessions (queue-operation) +// - Prioritizes most recently modified, then largest size func (s *ParserService) findSessionFile(worktreePath string) (string, error) { projectDirName := WorktreePathToProjectDir(worktreePath) @@ -169,7 +174,7 @@ func (s *ParserService) findSessionFile(worktreePath string) (string, error) { homeDir := config.Runtime.HomeDir localDir := filepath.Join(homeDir, ".claude", "projects", projectDirName) - if sessionFile := s.findBestSessionInDir(localDir); sessionFile != "" { + if sessionFile, err := paths.FindBestSessionFile(localDir); err == nil && sessionFile != "" { return sessionFile, nil } @@ -177,56 +182,13 @@ func (s *ParserService) findSessionFile(worktreePath string) (string, error) { volumeDir := config.Runtime.VolumeDir volumeProjectDir := filepath.Join(volumeDir, ".claude", ".claude", "projects", projectDirName) - if sessionFile := s.findBestSessionInDir(volumeProjectDir); sessionFile != "" { + if sessionFile, err := paths.FindBestSessionFile(volumeProjectDir); err == nil && sessionFile != "" { return sessionFile, nil } return "", fmt.Errorf("no session file found for worktree: %s", worktreePath) } -// findBestSessionInDir finds the best (largest, most recent) session file in a directory -func (s *ParserService) findBestSessionInDir(dir string) string { - // Check if directory exists - if _, err := os.Stat(dir); os.IsNotExist(err) { - return "" - } - - entries, err := os.ReadDir(dir) - if err != nil { - return "" - } - - var bestFile string - var bestSize int64 - var bestModTime time.Time - - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".jsonl" { - continue - } - - filePath := filepath.Join(dir, entry.Name()) - info, err := os.Stat(filePath) - if err != nil { - continue - } - - // Skip very small files (likely warmup-only sessions) - if info.Size() < 10000 { - continue - } - - // Prefer larger files (more content), use mod time as tie-breaker - if info.Size() > bestSize || (info.Size() == bestSize && info.ModTime().After(bestModTime)) { - bestFile = filePath - bestSize = info.Size() - bestModTime = info.ModTime() - } - } - - return bestFile -} - // evictIfNeeded evicts least recently used parsers if we exceed maxParsers // Must be called with parsersMutex held func (s *ParserService) evictIfNeeded() { diff --git a/container/internal/services/claude_parser_test.go b/container/internal/services/claude_parser_test.go index 68d9aa03..8ecd7ad7 100644 --- a/container/internal/services/claude_parser_test.go +++ b/container/internal/services/claude_parser_test.go @@ -241,55 +241,6 @@ func TestParserService_GetStats(t *testing.T) { assert.Equal(t, 100, stats["max_parsers"]) } -// Test findBestSessionInDir with multiple files -func TestParserService_FindBestSessionInDir(t *testing.T) { - service := setupTestParserService(t) - - testDir := t.TempDir() - - // Create multiple session files with different sizes - smallFile := filepath.Join(testDir, "small.jsonl") - err := os.WriteFile(smallFile, []byte("small"), 0644) - require.NoError(t, err) - - largeFile := filepath.Join(testDir, "large.jsonl") - largeData := make([]byte, 20000) - err = os.WriteFile(largeFile, largeData, 0644) - require.NoError(t, err) - - // findBestSessionInDir should return the larger file - bestFile := service.findBestSessionInDir(testDir) - - assert.Equal(t, largeFile, bestFile) -} - -// Test findBestSessionInDir with no valid files -func TestParserService_FindBestSessionInDir_NoFiles(t *testing.T) { - service := setupTestParserService(t) - - testDir := t.TempDir() - - bestFile := service.findBestSessionInDir(testDir) - - assert.Empty(t, bestFile) -} - -// Test findBestSessionInDir skips small files -func TestParserService_FindBestSessionInDir_SkipsSmallFiles(t *testing.T) { - service := setupTestParserService(t) - - testDir := t.TempDir() - - // Create only small files (< 10KB) - smallFile := filepath.Join(testDir, "small.jsonl") - err := os.WriteFile(smallFile, []byte("tiny"), 0644) - require.NoError(t, err) - - bestFile := service.findBestSessionInDir(testDir) - - assert.Empty(t, bestFile) -} - // Test ClaudeService.GetLatestTodos integration func TestClaudeService_GetLatestTodos_Integration(t *testing.T) { // Setup From c690d70f125a9da9f75925d2ff5c9fc4276c71bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Dec 2025 15:43:10 -0500 Subject: [PATCH 09/14] Unify all session file selection to use paths.FindBestSessionFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes a bug where the PTY terminal was resuming a different session than what the parser was reading. All session file selection logic now uses the centralized paths.FindBestSessionFile which properly: - Validates UUID format (filters out agent-*.jsonl files) - Checks for conversation content (user/assistant messages) - Filters out forked sessions (queue-operation) - Prioritizes most recently modified, with size as tie-breaker Updated: - SessionService.findNewestClaudeSessionFile - SessionService.FindBestSessionFile - PTYHandler.findNewestClaudeSession - ClaudeSessionDetector.findClaudeSessionFromFiles Also added comprehensive tests for paths.FindBestSessionFile. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/internal/claude/paths/paths_test.go | 268 ++++++++++++++++++ container/internal/git/claude_detector.go | 51 +--- container/internal/handlers/pty.go | 49 +--- .../internal/services/claude_parser_test.go | 5 +- container/internal/services/session.go | 87 +----- container/internal/services/session_test.go | 15 +- 6 files changed, 310 insertions(+), 165 deletions(-) create mode 100644 container/internal/claude/paths/paths_test.go diff --git a/container/internal/claude/paths/paths_test.go b/container/internal/claude/paths/paths_test.go new file mode 100644 index 00000000..1baa878c --- /dev/null +++ b/container/internal/claude/paths/paths_test.go @@ -0,0 +1,268 @@ +package paths + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test IsValidSessionUUID +func TestIsValidSessionUUID(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"valid UUID", "cf568042-7147-4fba-a2ca-c6a646581260", true}, + {"valid UUID all same", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", true}, + {"agent file", "agent-d221d088", false}, + {"too short", "abc-123", false}, + {"too long", "cf568042-7147-4fba-a2ca-c6a646581260-extra", false}, + {"wrong number of dashes", "cf5680427147-4fba-a2ca-c6a646581260", false}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsValidSessionUUID(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Test EncodePathForClaude +func TestEncodePathForClaude(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"simple path", "/workspaces/myproject", "-workspaces-myproject"}, + {"path with dots", "/home/user/my.project", "-home-user-my-project"}, + {"already has leading dash", "-foo/bar", "-foo-bar"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := EncodePathForClaude(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Test FindBestSessionFile with multiple files - selects largest valid UUID file +func TestFindBestSessionFile_SelectsLargestValidUUID(t *testing.T) { + testDir := t.TempDir() + + // Create a valid UUID session file (large, with conversation content) + validUUID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.jsonl" + validContent := []byte(`{"type":"user","message":"hello"}` + "\n" + `{"type":"assistant","message":"hi"}`) + require.NoError(t, os.WriteFile(filepath.Join(testDir, validUUID), validContent, 0644)) + + // Create an agent file (should be ignored even if larger) + agentFile := "agent-d221d088.jsonl" + agentContent := make([]byte, 50000) // Much larger + copy(agentContent, []byte(`{"type":"user","message":"agent task"}`)) + require.NoError(t, os.WriteFile(filepath.Join(testDir, agentFile), agentContent, 0644)) + + // FindBestSessionFile should return the valid UUID file, not the agent file + bestFile, err := FindBestSessionFile(testDir) + require.NoError(t, err) + assert.Equal(t, filepath.Join(testDir, validUUID), bestFile) +} + +// Test FindBestSessionFile with no valid files +func TestFindBestSessionFile_NoValidFiles(t *testing.T) { + testDir := t.TempDir() + + // Create only agent files (no valid UUIDs) + agentFile := "agent-abc123.jsonl" + require.NoError(t, os.WriteFile(filepath.Join(testDir, agentFile), []byte("content"), 0644)) + + // Should return error + bestFile, err := FindBestSessionFile(testDir) + assert.Error(t, err) + assert.Empty(t, bestFile) +} + +// Test FindBestSessionFile with empty directory +func TestFindBestSessionFile_EmptyDirectory(t *testing.T) { + testDir := t.TempDir() + + bestFile, err := FindBestSessionFile(testDir) + assert.Error(t, err) + assert.Empty(t, bestFile) +} + +// Test FindBestSessionFile with nonexistent directory +func TestFindBestSessionFile_NonexistentDirectory(t *testing.T) { + bestFile, err := FindBestSessionFile("/nonexistent/path") + assert.Error(t, err) + assert.Empty(t, bestFile) +} + +// Test FindBestSessionFile prefers most recently modified +func TestFindBestSessionFile_PrefersRecentlyModified(t *testing.T) { + testDir := t.TempDir() + + // Create an older file + olderUUID := "11111111-1111-1111-1111-111111111111.jsonl" + olderContent := []byte(`{"type":"user","message":"old"}` + "\n" + `{"type":"assistant","message":"old reply"}`) + require.NoError(t, os.WriteFile(filepath.Join(testDir, olderUUID), olderContent, 0644)) + + // Wait a bit to ensure different mod times + time.Sleep(50 * time.Millisecond) + + // Create a newer file + newerUUID := "22222222-2222-2222-2222-222222222222.jsonl" + newerContent := []byte(`{"type":"user","message":"new"}` + "\n" + `{"type":"assistant","message":"new reply"}`) + require.NoError(t, os.WriteFile(filepath.Join(testDir, newerUUID), newerContent, 0644)) + + // Should prefer the newer file + bestFile, err := FindBestSessionFile(testDir) + require.NoError(t, err) + assert.Equal(t, filepath.Join(testDir, newerUUID), bestFile) +} + +// Test FindBestSessionFile skips snapshot-only files +func TestFindBestSessionFile_SkipsSnapshotOnlyFiles(t *testing.T) { + testDir := t.TempDir() + + // Create a snapshot-only file (no user/assistant messages) + snapshotUUID := "33333333-3333-3333-3333-333333333333.jsonl" + snapshotContent := []byte(`{"type":"file-history-snapshot","data":{}}` + "\n" + `{"type":"summary","content":""}`) + require.NoError(t, os.WriteFile(filepath.Join(testDir, snapshotUUID), snapshotContent, 0644)) + + // Wait a bit + time.Sleep(50 * time.Millisecond) + + // Create a valid conversation file + validUUID := "44444444-4444-4444-4444-444444444444.jsonl" + validContent := []byte(`{"type":"user","message":"hello"}` + "\n" + `{"type":"assistant","message":"hi"}`) + require.NoError(t, os.WriteFile(filepath.Join(testDir, validUUID), validContent, 0644)) + + // Should return the valid conversation file, not the snapshot-only file + bestFile, err := FindBestSessionFile(testDir) + require.NoError(t, err) + assert.Equal(t, filepath.Join(testDir, validUUID), bestFile) +} + +// Test FindBestSessionFile skips forked sessions (queue-operation) +func TestFindBestSessionFile_SkipsForkedSessions(t *testing.T) { + testDir := t.TempDir() + + // Create a forked session file (starts with queue-operation) + forkedUUID := "55555555-5555-5555-5555-555555555555.jsonl" + forkedContent := []byte(`{"type":"queue-operation","task":"branch-naming"}` + "\n" + `{"type":"user","message":"name this"}`) + require.NoError(t, os.WriteFile(filepath.Join(testDir, forkedUUID), forkedContent, 0644)) + + // Wait a bit + time.Sleep(50 * time.Millisecond) + + // Create a valid main session file + validUUID := "66666666-6666-6666-6666-666666666666.jsonl" + validContent := []byte(`{"type":"user","message":"real task"}` + "\n" + `{"type":"assistant","message":"working on it"}`) + require.NoError(t, os.WriteFile(filepath.Join(testDir, validUUID), validContent, 0644)) + + // Should return the valid main session, not the forked one + bestFile, err := FindBestSessionFile(testDir) + require.NoError(t, err) + assert.Equal(t, filepath.Join(testDir, validUUID), bestFile) +} + +// Test FindBestSessionFile uses size as tie-breaker when mod times are equal +func TestFindBestSessionFile_UsesSizeAsTieBreaker(t *testing.T) { + testDir := t.TempDir() + + // Create two files with same mod time but different sizes + smallUUID := "77777777-7777-7777-7777-777777777777.jsonl" + smallContent := []byte(`{"type":"user","message":"hi"}` + "\n" + `{"type":"assistant","message":"hi"}`) + + largeUUID := "88888888-8888-8888-8888-888888888888.jsonl" + largeContent := []byte(`{"type":"user","message":"hello world this is a longer message"}` + "\n" + `{"type":"assistant","message":"hello there, this is also a longer response message"}`) + + // Write both files + require.NoError(t, os.WriteFile(filepath.Join(testDir, smallUUID), smallContent, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(testDir, largeUUID), largeContent, 0644)) + + // Set same mod time on both + now := time.Now() + require.NoError(t, os.Chtimes(filepath.Join(testDir, smallUUID), now, now)) + require.NoError(t, os.Chtimes(filepath.Join(testDir, largeUUID), now, now)) + + // Should prefer the larger file as tie-breaker + bestFile, err := FindBestSessionFile(testDir) + require.NoError(t, err) + assert.Equal(t, filepath.Join(testDir, largeUUID), bestFile) +} + +// Test that agent files are never selected even with valid conversation content +func TestFindBestSessionFile_NeverSelectsAgentFiles(t *testing.T) { + testDir := t.TempDir() + + // Create multiple agent files with valid conversation content + for _, name := range []string{"agent-abc123.jsonl", "agent-def456.jsonl", "agent-ghi789.jsonl"} { + content := []byte(`{"type":"user","message":"agent task"}` + "\n" + `{"type":"assistant","message":"doing it"}`) + require.NoError(t, os.WriteFile(filepath.Join(testDir, name), content, 0644)) + } + + // Should return error since no valid UUID files exist + bestFile, err := FindBestSessionFile(testDir) + assert.Error(t, err) + assert.Empty(t, bestFile) +} + +// Test hasConversationContent helper +func TestHasConversationContent(t *testing.T) { + tests := []struct { + name string + content string + expected bool + }{ + { + name: "has user and assistant", + content: `{"type":"user","message":"hi"}` + "\n" + `{"type":"assistant","message":"hello"}`, + expected: true, + }, + { + name: "only user message", + content: `{"type":"user","message":"hi"}`, + expected: true, + }, + { + name: "only assistant message", + content: `{"type":"assistant","message":"hi"}`, + expected: true, + }, + { + name: "only snapshots", + content: `{"type":"file-history-snapshot","data":{}}` + "\n" + `{"type":"summary","content":""}`, + expected: false, + }, + { + name: "starts with queue-operation (forked)", + content: `{"type":"queue-operation","task":"branch"}` + "\n" + `{"type":"user","message":"hi"}`, + expected: false, + }, + { + name: "empty file", + content: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testDir := t.TempDir() + testFile := filepath.Join(testDir, "test.jsonl") + require.NoError(t, os.WriteFile(testFile, []byte(tt.content), 0644)) + + result := hasConversationContent(testFile) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/container/internal/git/claude_detector.go b/container/internal/git/claude_detector.go index 12ccad33..78f40c42 100644 --- a/container/internal/git/claude_detector.go +++ b/container/internal/git/claude_detector.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/vanpelt/catnip/internal/logger" + "github.com/vanpelt/catnip/internal/claude/paths" ) // ClaudeSessionDetector detects and monitors Claude sessions running in a worktree @@ -65,58 +65,23 @@ func (d *ClaudeSessionDetector) DetectClaudeSession() (*ClaudeSessionInfo, error } // findClaudeSessionFromFiles looks for Claude session files and extracts information +// Uses paths.FindBestSessionFile which validates UUIDs, checks conversation content, +// and filters out forked sessions func (d *ClaudeSessionDetector) findClaudeSessionFromFiles() *ClaudeSessionInfo { claudeProjectsDir := filepath.Join(d.workDir, ".claude", "projects") - if _, err := os.Stat(claudeProjectsDir); os.IsNotExist(err) { - return nil - } - - files, err := os.ReadDir(claudeProjectsDir) - if err != nil { - logger.Debugf("⚠️ Failed to read Claude projects directory: %v", err) - return nil - } - - var newestFile string - var newestTime time.Time - - // Find the most recent JSONL file - for _, file := range files { - if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") { - continue - } - - // Extract session ID from filename (remove .jsonl extension) - sessionID := strings.TrimSuffix(file.Name(), ".jsonl") - - // Validate that it looks like a UUID - if len(sessionID) != 36 || strings.Count(sessionID, "-") != 4 { - continue - } - - filePath := filepath.Join(claudeProjectsDir, file.Name()) - fileInfo, err := os.Stat(filePath) - if err != nil { - continue - } - - if fileInfo.ModTime().After(newestTime) { - newestTime = fileInfo.ModTime() - newestFile = filePath - } - } - if newestFile == "" { + sessionFile, err := paths.FindBestSessionFile(claudeProjectsDir) + if err != nil || sessionFile == "" { return nil } // Extract session ID from filename - sessionID := strings.TrimSuffix(filepath.Base(newestFile), ".jsonl") + sessionID := strings.TrimSuffix(filepath.Base(sessionFile), ".jsonl") // Try to extract title from the JSONL file - title := d.extractTitleFromJSONL(newestFile) + title := d.extractTitleFromJSONL(sessionFile) - fileInfo, _ := os.Stat(newestFile) + fileInfo, _ := os.Stat(sessionFile) return &ClaudeSessionInfo{ SessionID: sessionID, Title: title, diff --git a/container/internal/handlers/pty.go b/container/internal/handlers/pty.go index 7599d876..f8e819f3 100644 --- a/container/internal/handlers/pty.go +++ b/container/internal/handlers/pty.go @@ -24,6 +24,7 @@ import ( "github.com/creack/pty" "github.com/gofiber/fiber/v2" "github.com/gofiber/websocket/v2" + "github.com/vanpelt/catnip/internal/claude/paths" "github.com/vanpelt/catnip/internal/config" "github.com/vanpelt/catnip/internal/git" "github.com/vanpelt/catnip/internal/models" @@ -2333,50 +2334,18 @@ func (h *PTYHandler) getClaudeSessionLogModTime(workDir string) time.Time { return newestTime } -// findNewestClaudeSession finds the newest JSONL file in .claude/projects directory +// findNewestClaudeSession finds the best JSONL file in .claude/projects directory +// Uses paths.FindBestSessionFile which validates UUIDs, checks conversation content, +// and filters out forked sessions func (h *PTYHandler) findNewestClaudeSession(claudeProjectsDir string) string { - // Check if .claude/projects directory exists - if _, err := os.Stat(claudeProjectsDir); os.IsNotExist(err) { + sessionFile, err := paths.FindBestSessionFile(claudeProjectsDir) + if err != nil || sessionFile == "" { return "" } - files, err := os.ReadDir(claudeProjectsDir) - if err != nil { - logger.Infof("⚠️ Failed to read .claude/projects directory: %v", err) - return "" - } - - var newestFile string - var newestTime time.Time - - for _, file := range files { - if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") { - continue - } - - // Extract session ID from filename (remove .jsonl extension) - sessionID := strings.TrimSuffix(file.Name(), ".jsonl") - - // Validate that it looks like a UUID - if len(sessionID) != 36 || strings.Count(sessionID, "-") != 4 { - continue - } - - // Get file modification time - filePath := filepath.Join(claudeProjectsDir, file.Name()) - fileInfo, err := os.Stat(filePath) - if err != nil { - continue - } - - // Track the newest file - if fileInfo.ModTime().After(newestTime) { - newestTime = fileInfo.ModTime() - newestFile = sessionID - } - } - - return newestFile + // Extract session ID from the full path (remove directory and .jsonl extension) + sessionID := strings.TrimSuffix(filepath.Base(sessionFile), ".jsonl") + return sessionID } // handleTitleUpdate processes a new terminal title, committing previous work and updating session state diff --git a/container/internal/services/claude_parser_test.go b/container/internal/services/claude_parser_test.go index 8ecd7ad7..aed6bf8c 100644 --- a/container/internal/services/claude_parser_test.go +++ b/container/internal/services/claude_parser_test.go @@ -42,8 +42,9 @@ func copyTestFile(t *testing.T, testdataFile, destDir string) string { data, err := os.ReadFile(srcPath) require.NoError(t, err, "Failed to read test file: %s", srcPath) - // Write to destination - destPath := filepath.Join(destDir, testdataFile) + // Write to destination with a valid UUID filename + // paths.FindBestSessionFile validates that filenames are valid UUIDs (36 chars, 4 dashes) + destPath := filepath.Join(destDir, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.jsonl") err = os.WriteFile(destPath, data, 0644) require.NoError(t, err) diff --git a/container/internal/services/session.go b/container/internal/services/session.go index f37a959f..d9775157 100644 --- a/container/internal/services/session.go +++ b/container/internal/services/session.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/vanpelt/catnip/internal/claude/paths" "github.com/vanpelt/catnip/internal/config" "github.com/vanpelt/catnip/internal/logger" "github.com/vanpelt/catnip/internal/models" @@ -373,91 +374,29 @@ func (s *SessionService) isProcessAlive(pid string) bool { } // FindBestSessionFile finds the best JSONL session file in a project directory -// It filters out small/warmup sessions and prefers larger files with more content +// Uses paths.FindBestSessionFile which validates UUIDs, checks conversation content, +// and filters out forked sessions // Returns the full file path to the best session, or empty string if none found func (s *SessionService) FindBestSessionFile(projectDir string) string { - sessionID := s.findNewestClaudeSessionFile(projectDir) - if sessionID == "" { + sessionFile, err := paths.FindBestSessionFile(projectDir) + if err != nil { return "" } - return filepath.Join(projectDir, sessionID+".jsonl") + return sessionFile } // findNewestClaudeSessionFile finds the best JSONL file in .claude/projects directory -// It filters out "Warmup" sessions and prefers larger, more active sessions +// Uses paths.FindBestSessionFile which properly validates UUIDs, checks conversation content, +// and filters out forked sessions func (s *SessionService) findNewestClaudeSessionFile(claudeProjectsDir string) string { - // Check if .claude/projects directory exists - if _, err := os.Stat(claudeProjectsDir); os.IsNotExist(err) { + sessionFile, err := paths.FindBestSessionFile(claudeProjectsDir) + if err != nil || sessionFile == "" { return "" } - files, err := os.ReadDir(claudeProjectsDir) - if err != nil { - return "" - } - - type sessionCandidate struct { - sessionID string - size int64 - modTime time.Time - } - - var candidates []sessionCandidate - - for _, file := range files { - if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") { - continue - } - - // Extract session ID from filename (remove .jsonl extension) - sessionID := strings.TrimSuffix(file.Name(), ".jsonl") - - // Validate that it looks like a UUID - if len(sessionID) != 36 || strings.Count(sessionID, "-") != 4 { - continue - } - - filePath := filepath.Join(claudeProjectsDir, file.Name()) - fileInfo, err := os.Stat(filePath) - if err != nil { - continue - } - - // Skip very small files (likely empty or warmup sessions) - // Real sessions with actual conversation are typically >10KB - if fileInfo.Size() < 10000 { - continue - } - - // Note: We used to filter out "Warmup" sessions here, but that was too aggressive - // If a session started with "Warmup" but has grown to >10KB, it clearly has real content - // The size check above is sufficient to filter out truly empty warmup sessions - - // This is a valid session candidate - candidates = append(candidates, sessionCandidate{ - sessionID: sessionID, - size: fileInfo.Size(), - modTime: fileInfo.ModTime(), - }) - } - - // No valid sessions found - if len(candidates) == 0 { - return "" - } - - // Pick the largest session (most conversation history) - // Tie-breaker: newest modification time - bestSession := candidates[0] - for _, candidate := range candidates[1:] { - if candidate.size > bestSession.size { - bestSession = candidate - } else if candidate.size == bestSession.size && candidate.modTime.After(bestSession.modTime) { - bestSession = candidate - } - } - - return bestSession.sessionID + // Extract session ID from the full path (remove directory and .jsonl extension) + sessionID := strings.TrimSuffix(filepath.Base(sessionFile), ".jsonl") + return sessionID } // DeleteSessionState removes session state from disk diff --git a/container/internal/services/session_test.go b/container/internal/services/session_test.go index cd98f001..6ad3d644 100644 --- a/container/internal/services/session_test.go +++ b/container/internal/services/session_test.go @@ -339,23 +339,26 @@ func TestSessionServiceDirectory(t *testing.T) { assert.Equal(t, "55555555-5555-5555-5555-555555555555", newest, "Should ignore files < 10KB") }) - // Test 3: Only small files (all < 10KB) - should return empty - t.Run("AllFilesTooSmall", func(t *testing.T) { + // Test 3: Files without conversation content still get returned via fallback + // paths.FindBestSessionFile prefers files with conversation content but falls back + // to the most recent valid UUID file if none have content + t.Run("FallbackToMostRecentWithoutContent", func(t *testing.T) { subDir := filepath.Join(claudeDir, "all-small-test") require.NoError(t, os.MkdirAll(subDir, 0755)) - // Create multiple small files + // Create multiple files without conversation content (just zeros) for i := 0; i < 3; i++ { uuid := strings.Repeat(string(rune('0'+i)), 8) + "-" + strings.Repeat(string(rune('0'+i)), 4) + "-" + strings.Repeat(string(rune('0'+i)), 4) + "-" + strings.Repeat(string(rune('0'+i)), 4) + "-" + strings.Repeat(string(rune('0'+i)), 12) smallFile := filepath.Join(subDir, uuid+".jsonl") - smallContent := make([]byte, 9000) // All < 10KB + smallContent := make([]byte, 9000) require.NoError(t, os.WriteFile(smallFile, smallContent, 0644)) time.Sleep(10 * time.Millisecond) } - // Should return empty since all files are too small + // Should return most recent file via fallback since none have conversation content + // The last file created (22222222-...) is the most recent newest := service.findNewestClaudeSessionFile(subDir) - assert.Empty(t, newest, "Should return empty when all files are < 10KB") + assert.Equal(t, "22222222-2222-2222-2222-222222222222", newest, "Should return most recent file via fallback") }) // Test 4: Invalid UUID formats should be ignored From b284a9694e8bc273cc15989a18e5f6bd0bb6c808 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Dec 2025 19:35:32 -0500 Subject: [PATCH 10/14] Add todos and latestSessionTitle to session endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Todos and LatestSessionTitle fields to FullSessionData model - Populate fields in populateLatestDataFromParser from parser cache and session service - Update Swift SessionData model to include the new fields - Add effectiveSessionTitle and effectiveTodos computed properties in WorkspaceDetailView to prefer session data over workspace data - Wrap session token logging in #if DEBUG directive - Update UITestingHelper with mock todos and session title This enables the iOS app to display session title and todos during active polling without needing to fetch from the heavier /git/worktrees endpoint. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/docs/docs.go | 11 +++++ container/docs/swagger.json | 11 +++++ container/docs/swagger.yaml | 8 ++++ container/internal/models/claude.go | 4 ++ container/internal/services/claude.go | 24 ++++++++++ xcode/catnip/Models/WorkspaceInfo.swift | 2 + xcode/catnip/Services/CatnipAPI.swift | 2 + xcode/catnip/Services/UITestingHelper.swift | 8 +++- xcode/catnip/Views/WorkspaceDetailView.swift | 46 +++++++++++++++----- 9 files changed, 104 insertions(+), 12 deletions(-) diff --git a/container/docs/docs.go b/container/docs/docs.go index b541a318..63fad14a 100644 --- a/container/docs/docs.go +++ b/container/docs/docs.go @@ -2244,6 +2244,10 @@ const docTemplate = `{ "description": "Latest assistant message text (always populated when available)", "type": "string" }, + "latestSessionTitle": { + "description": "Latest session title (from PTY escape sequences or session history)", + "type": "string" + }, "latestThought": { "description": "Latest thinking/reasoning content (always populated when available)", "type": "string" @@ -2279,6 +2283,13 @@ const docTemplate = `{ } ] }, + "todos": { + "description": "Current todo items from the session", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.Todo" + } + }, "userPrompts": { "description": "User prompts from ~/.claude.json (only when full=true)", "type": "array", diff --git a/container/docs/swagger.json b/container/docs/swagger.json index 541303bc..57c316bf 100644 --- a/container/docs/swagger.json +++ b/container/docs/swagger.json @@ -2241,6 +2241,10 @@ "description": "Latest assistant message text (always populated when available)", "type": "string" }, + "latestSessionTitle": { + "description": "Latest session title (from PTY escape sequences or session history)", + "type": "string" + }, "latestThought": { "description": "Latest thinking/reasoning content (always populated when available)", "type": "string" @@ -2276,6 +2280,13 @@ } ] }, + "todos": { + "description": "Current todo items from the session", + "type": "array", + "items": { + "$ref": "#/definitions/github_com_vanpelt_catnip_internal_models.Todo" + } + }, "userPrompts": { "description": "User prompts from ~/.claude.json (only when full=true)", "type": "array", diff --git a/container/docs/swagger.yaml b/container/docs/swagger.yaml index 3e53212e..dbd880e4 100644 --- a/container/docs/swagger.yaml +++ b/container/docs/swagger.yaml @@ -309,6 +309,9 @@ definitions: latestMessage: description: Latest assistant message text (always populated when available) type: string + latestSessionTitle: + description: Latest session title (from PTY escape sequences or session history) + type: string latestThought: description: Latest thinking/reasoning content (always populated when available) type: string @@ -331,6 +334,11 @@ definitions: allOf: - $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.SessionStats' description: Session statistics (token counts, tool usage, etc.) + todos: + description: Current todo items from the session + items: + $ref: '#/definitions/github_com_vanpelt_catnip_internal_models.Todo' + type: array userPrompts: description: User prompts from ~/.claude.json (only when full=true) items: diff --git a/container/internal/models/claude.go b/container/internal/models/claude.go index 3f4e84e6..6f67810e 100644 --- a/container/internal/models/claude.go +++ b/container/internal/models/claude.go @@ -132,6 +132,10 @@ type FullSessionData struct { LatestThought string `json:"latestThought,omitempty"` // Session statistics (token counts, tool usage, etc.) Stats *SessionStats `json:"stats,omitempty"` + // Current todo items from the session + Todos []Todo `json:"todos,omitempty"` + // Latest session title (from PTY escape sequences or session history) + LatestSessionTitle string `json:"latestSessionTitle,omitempty"` } // SessionStats contains aggregated statistics about a Claude session diff --git a/container/internal/services/claude.go b/container/internal/services/claude.go index a27ba007..17f246de 100644 --- a/container/internal/services/claude.go +++ b/container/internal/services/claude.go @@ -471,6 +471,30 @@ func (s *ClaudeService) populateLatestDataFromParser(worktreePath string, fullDa ImageCount: parserStats.ImageCount, ActiveToolNames: parserStats.ActiveToolNames, } + + // Get todos from parser + todos := reader.GetTodos() + if len(todos) > 0 { + fullData.Todos = make([]models.Todo, len(todos)) + for i, t := range todos { + fullData.Todos[i] = models.Todo{ + ID: t.ID, + Content: t.Content, + Status: t.Status, + Priority: t.Priority, + } + } + logger.Debugf("📊 populateLatestDataFromParser: got %d todos", len(todos)) + } + + // Get session title from session service (PTY escape sequences/title tracking) + if s.sessionService != nil { + title := s.sessionService.GetPreviousTitle(worktreePath) + if title != "" { + fullData.LatestSessionTitle = title + logger.Debugf("📊 populateLatestDataFromParser: got session title: %s", truncate(title, 50)) + } + } } // GetAllSessionsForWorkspace returns all session IDs for a workspace with metadata diff --git a/xcode/catnip/Models/WorkspaceInfo.swift b/xcode/catnip/Models/WorkspaceInfo.swift index a62ffd68..57f2e2d4 100644 --- a/xcode/catnip/Models/WorkspaceInfo.swift +++ b/xcode/catnip/Models/WorkspaceInfo.swift @@ -183,6 +183,8 @@ struct SessionData: Codable { let latestMessage: String? let latestThought: String? let stats: SessionStats? + let todos: [Todo]? + let latestSessionTitle: String? // Messages and userPrompts are only included when full=true // We don't include them here as we're using this for lightweight polling } diff --git a/xcode/catnip/Services/CatnipAPI.swift b/xcode/catnip/Services/CatnipAPI.swift index 4b4d1ad8..8a7e33ae 100644 --- a/xcode/catnip/Services/CatnipAPI.swift +++ b/xcode/catnip/Services/CatnipAPI.swift @@ -63,7 +63,9 @@ class CatnipAPI: ObservableObject { private func getHeaders(includeCodespace: Bool = false) async throws -> [String: String] { let token = try await getSessionToken() + #if DEBUG NSLog("🐱 [CatnipAPI] Session Token: \(token)") + #endif var headers = [ "Content-Type": "application/json", "Authorization": "Bearer \(token)" diff --git a/xcode/catnip/Services/UITestingHelper.swift b/xcode/catnip/Services/UITestingHelper.swift index 574f0175..cbe0065c 100644 --- a/xcode/catnip/Services/UITestingHelper.swift +++ b/xcode/catnip/Services/UITestingHelper.swift @@ -267,7 +267,13 @@ struct UITestingHelper { compactionCount: 0, imageCount: 0, activeToolNames: ["Read": 8, "Edit": 5, "Bash": 2] - ) + ), + todos: [ + Todo(content: "Implement OAuth callback handler", status: .completed, activeForm: "Implementing OAuth callback handler"), + Todo(content: "Add token refresh logic", status: .inProgress, activeForm: "Adding token refresh logic"), + Todo(content: "Write unit tests for auth flow", status: .pending, activeForm: "Writing unit tests for auth flow") + ], + latestSessionTitle: "Implementing GitHub OAuth" ) } diff --git a/xcode/catnip/Views/WorkspaceDetailView.swift b/xcode/catnip/Views/WorkspaceDetailView.swift index ac0049f6..3550b503 100644 --- a/xcode/catnip/Views/WorkspaceDetailView.swift +++ b/xcode/catnip/Views/WorkspaceDetailView.swift @@ -88,6 +88,26 @@ struct WorkspaceDetailView: View { poller.sessionData?.stats } + /// Get the effective session title, preferring session data over workspace data + private var effectiveSessionTitle: String? { + // First check session data (more up-to-date during active polling) + if let title = poller.sessionData?.latestSessionTitle, !title.isEmpty { + return title + } + // Fall back to workspace data + return workspace?.latestSessionTitle + } + + /// Get effective todos, preferring session data over workspace data + private var effectiveTodos: [Todo]? { + // First check session data (more up-to-date during active polling) + if let todos = poller.sessionData?.todos, !todos.isEmpty { + return todos + } + // Fall back to workspace data + return workspace?.todos + } + /// Check if we have any session content to display /// Used to determine if we should show empty state vs completed state private var hasSessionContent: Bool { @@ -100,11 +120,11 @@ struct WorkspaceDetailView: View { return true } // Has session title - if let title = workspace?.latestSessionTitle, !title.isEmpty { + if let title = effectiveSessionTitle, !title.isEmpty { return true } // Has todos - if let todos = workspace?.todos, !todos.isEmpty { + if let todos = effectiveTodos, !todos.isEmpty { return true } return false @@ -112,7 +132,7 @@ struct WorkspaceDetailView: View { private var navigationTitle: String { // Show session title if available (in both working and completed phases) - if let title = workspace?.latestSessionTitle, !title.isEmpty { + if let title = effectiveSessionTitle, !title.isEmpty { // Truncate to first line or 50 chars let firstLine = title.components(separatedBy: .newlines).first ?? title return firstLine.count > 50 ? String(firstLine.prefix(50)) + "..." : firstLine @@ -395,7 +415,7 @@ struct WorkspaceDetailView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color(uiColor: .tertiarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 10)) - } else if workspace?.latestSessionTitle != nil { + } else if effectiveSessionTitle != nil { // Show loading state while fetching message VStack(alignment: .leading, spacing: 8) { Text("Claude is saying:") @@ -416,7 +436,7 @@ struct WorkspaceDetailView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) } - if let todos = workspace?.todos, !todos.isEmpty { + if let todos = effectiveTodos, !todos.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Progress:") .font(.callout.weight(.semibold)) @@ -485,7 +505,7 @@ struct WorkspaceDetailView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color(uiColor: .tertiarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 10)) - } else if workspace?.latestSessionTitle != nil { + } else if effectiveSessionTitle != nil { // Show loading state while fetching message VStack(alignment: .leading, spacing: 8) { Text("Claude responded:") @@ -506,7 +526,7 @@ struct WorkspaceDetailView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) } - if let todos = workspace?.todos, !todos.isEmpty { + if let todos = effectiveTodos, !todos.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Tasks:") .font(.callout.weight(.semibold)) @@ -686,10 +706,14 @@ struct WorkspaceDetailView: View { } private func determinePhase(for workspace: WorkspaceInfo) { + // Use effective values that prefer session data over workspace data + let currentTitle = effectiveSessionTitle + let currentTodos = effectiveTodos + NSLog("📊 determinePhase - claudeActivityState: %@, latestSessionTitle: %@, todos: %d, isDirty: %@, commits: %d, pendingPrompt: %@", workspace.claudeActivityState.map { "\($0)" } ?? "nil", - workspace.latestSessionTitle ?? "nil", - workspace.todos?.count ?? 0, + currentTitle ?? "nil", + currentTodos?.count ?? 0, workspace.isDirty.map { "\($0)" } ?? "nil", workspace.commitCount ?? 0, pendingUserPrompt != nil ? "yes" : "no") @@ -705,7 +729,7 @@ struct WorkspaceDetailView: View { pendingUserPrompt = nil } // Backend completed the session - else if workspace.latestSessionTitle != nil { + else if currentTitle != nil { NSLog("📊 Session created - clearing pending prompt") pendingUserPrompt = nil } @@ -723,7 +747,7 @@ struct WorkspaceDetailView: View { await fetchLatestMessage(for: workspace) await fetchDiffIfNeeded(for: workspace) } - } else if workspace.latestSessionTitle != nil || workspace.todos?.isEmpty == false { + } else if currentTitle != nil || currentTodos?.isEmpty == false { // Has a session title or todos - definitely completed phase = .completed From 7d6294bde104817181a3fad410112ef4c1ab1492 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Dec 2025 21:52:09 -0500 Subject: [PATCH 11/14] Optimize session endpoint by removing duplicate allSessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate GetAllSessionsForWorkspace call (was called twice per request) - Only populate allSessions when full=true is requested - Always clear sessionInfo.allSessions to avoid duplication in response This reduces polling response size from ~5.7KB to ~1.2KB (79% smaller) for the iOS app which never uses allSessions data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/internal/services/claude.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/container/internal/services/claude.go b/container/internal/services/claude.go index 17f246de..f0ed5f3b 100644 --- a/container/internal/services/claude.go +++ b/container/internal/services/claude.go @@ -365,12 +365,13 @@ func (s *ClaudeService) GetFullSessionData(worktreePath string, includeFullData SessionInfo: sessionSummary, } - // Get all sessions for this workspace - allSessions, err := s.GetAllSessionsForWorkspace(worktreePath) - if err != nil { - return nil, fmt.Errorf("failed to get all sessions: %w", err) + // Only populate top-level allSessions when full data is requested + // Always clear sessionInfo.allSessions to avoid duplication in the response + if includeFullData { + fullData.AllSessions = sessionSummary.AllSessions } - fullData.AllSessions = allSessions + // Clear from sessionInfo - FullSessionData has its own top-level AllSessions field + sessionSummary.AllSessions = nil // Always populate latest data from parser (lightweight - uses cached state) s.populateLatestDataFromParser(worktreePath, fullData) From 822988e2b445210cb91df16ba18819d4c0de098d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Dec 2025 22:48:16 -0500 Subject: [PATCH 12/14] Add context usage progress ring to toolbar button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows Claude's token consumption as a circular progress indicator around the terminal/dashboard toggle button. Ring color changes based on usage: - Gray: < 40K tokens - Green: 40K - 80K tokens - Orange: 80K - 120K tokens - Red: > 120K tokens (approaching 155K compaction limit) Also removes verbose session data logging. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- xcode/catnip/Services/CatnipAPI.swift | 5 -- xcode/catnip/Services/WorkspacePoller.swift | 1 - xcode/catnip/Views/WorkspaceDetailView.swift | 86 +++++++++++++++++++- 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/xcode/catnip/Services/CatnipAPI.swift b/xcode/catnip/Services/CatnipAPI.swift index 8a7e33ae..aad93ea3 100644 --- a/xcode/catnip/Services/CatnipAPI.swift +++ b/xcode/catnip/Services/CatnipAPI.swift @@ -261,11 +261,6 @@ class CatnipAPI: ObservableObject { throw APIError.serverError(httpResponse.statusCode, "Failed to fetch session data") } - // Debug: log the raw JSON response - if let jsonString = String(data: data, encoding: .utf8) { - NSLog("📊 [CatnipAPI] Session data response: \(jsonString.prefix(500))...") - } - do { return try decoder.decode(SessionData.self, from: data) } catch { diff --git a/xcode/catnip/Services/WorkspacePoller.swift b/xcode/catnip/Services/WorkspacePoller.swift index 519e50ff..567d4ba3 100644 --- a/xcode/catnip/Services/WorkspacePoller.swift +++ b/xcode/catnip/Services/WorkspacePoller.swift @@ -199,7 +199,6 @@ class WorkspacePoller: ObservableObject { NSLog("📊 Session became inactive, will switch to full polling soon") } - NSLog("📊 Session data updated - Active: \(isActive), Prompt: \(sessionData.latestUserPrompt?.prefix(30) ?? "nil")...") } } catch { diff --git a/xcode/catnip/Views/WorkspaceDetailView.swift b/xcode/catnip/Views/WorkspaceDetailView.swift index 3550b503..cb164123 100644 --- a/xcode/catnip/Views/WorkspaceDetailView.swift +++ b/xcode/catnip/Views/WorkspaceDetailView.swift @@ -162,14 +162,19 @@ struct WorkspaceDetailView: View { @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { // Show terminal button when in portrait mode (not showing terminal) + // Wrapped in context progress ring to show Claude's token usage if !isLandscape && !showPortraitTerminal { ToolbarItem(placement: .topBarTrailing) { Button { showPortraitTerminal = true } label: { - Image(systemName: "terminal") - .font(.body) + ContextProgressRing(contextTokens: sessionStats?.lastContextSizeTokens) { + Image(systemName: "terminal") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary) + } } + .buttonStyle(.plain) } } } @@ -993,9 +998,13 @@ struct WorkspaceDetailView: View { Button { showPortraitTerminal = false } label: { - Image(systemName: "square.grid.2x2") - .font(.body) + ContextProgressRing(contextTokens: sessionStats?.lastContextSizeTokens) { + Image(systemName: "square.grid.2x2") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary) + } } + .buttonStyle(.plain) } } } @@ -1338,6 +1347,75 @@ struct MarkdownText: View { } } +// MARK: - Context Progress Ring + +/// A circular progress indicator showing Claude's context usage +/// Changes color based on token count thresholds: +/// - Gray: < 40K tokens +/// - Green: 40K - 80K tokens +/// - Orange: 80K - 120K tokens +/// - Red: > 120K tokens (approaching 155K limit) +struct ContextProgressRing: View { + let contextTokens: Int64? + let content: Content + + private let maxTokens: Int64 = 155_000 + private let lineWidth: CGFloat = 2.5 + private let buttonSize: CGFloat = 36 + // Inset for the ring - positions it just inside the button edge + private let ringInset: CGFloat = 1.0 + + init(contextTokens: Int64?, @ViewBuilder content: () -> Content) { + self.contextTokens = contextTokens + self.content = content() + } + + private var progress: Double { + guard let tokens = contextTokens, tokens > 0 else { return 0 } + return min(Double(tokens) / Double(maxTokens), 1.0) + } + + private var ringColor: Color { + guard let tokens = contextTokens else { return .gray.opacity(0.3) } + + switch tokens { + case ..<40_000: + return .gray.opacity(0.5) + case 40_000..<80_000: + return .green + case 80_000..<120_000: + return .orange + default: + return .red + } + } + + var body: some View { + Circle() + .fill(.ultraThinMaterial) + .overlay { + // Background ring (always visible, subtle) + Circle() + .strokeBorder(Color.gray.opacity(0.3), lineWidth: lineWidth) + .padding(ringInset) + } + .overlay { + // Progress ring - uses trim for animation + Circle() + .trim(from: 0, to: progress) + .stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding(ringInset + lineWidth / 2) + .animation(.easeInOut(duration: 0.3), value: progress) + } + .overlay { + // Icon content centered + content + } + .frame(width: buttonSize, height: buttonSize) + } +} + // MARK: - Preview Helper for Diff Viewer #if DEBUG From 55ad1ceba7da92b4639ab8a143519470f728f361 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 11:58:06 -0500 Subject: [PATCH 13/14] Fix Claude code review action with explicit github_token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workaround for OIDC token exchange failing with "Failed to parse JSON". Added explicit github_token and changed pull-requests permission to write. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/claude-code-review.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 205b0fe2..c64599e2 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read + pull-requests: write issues: read id-token: write @@ -36,6 +36,7 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} prompt: | REPO: ${{ github.repository }} PR NUMBER: ${{ github.event.pull_request.number }} From 725d5b541b8c05c18add906a332ffe0e3a5697d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 12:48:10 -0500 Subject: [PATCH 14/14] Fix polling state management and add pending prompt timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift changes: - Add 30s timeout for pending prompts to prevent stuck "working" state - Fix activity state semantics: .running = actively working, .active = PTY idle - Add WorkspaceInfo.with(claudeActivityState:) helper for idiomatic copying - Update WorkspacePoller to correctly transition .running → .active - Fix determinePollingInterval to use correct states for polling speeds Go changes: - Log ReadIncremental errors instead of silently ignoring them 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- container/internal/handlers/sessions.go | 8 ++----- container/internal/services/claude.go | 4 +++- xcode/catnip/Models/WorkspaceInfo.swift | 24 ++++++++++++++++++++ xcode/catnip/Services/CatnipAPI.swift | 3 --- xcode/catnip/Services/WorkspacePoller.swift | 19 ++++++++++------ xcode/catnip/Views/WorkspaceDetailView.swift | 21 +++++++++++++---- 6 files changed, 57 insertions(+), 22 deletions(-) diff --git a/container/internal/handlers/sessions.go b/container/internal/handlers/sessions.go index 85f736e3..2d24abb8 100644 --- a/container/internal/handlers/sessions.go +++ b/container/internal/handlers/sessions.go @@ -1,6 +1,7 @@ package handlers import ( + "strings" "time" "github.com/gofiber/fiber/v2" @@ -203,10 +204,5 @@ func (h *SessionsHandler) GetSessionById(c *fiber.Ctx) error { // containsSlash checks if a string contains a forward slash // Used to distinguish between workspace IDs (UUIDs) and paths func containsSlash(s string) bool { - for _, c := range s { - if c == '/' { - return true - } - } - return false + return strings.Contains(s, "/") } diff --git a/container/internal/services/claude.go b/container/internal/services/claude.go index f0ed5f3b..682ce4ff 100644 --- a/container/internal/services/claude.go +++ b/container/internal/services/claude.go @@ -420,7 +420,9 @@ func (s *ClaudeService) populateLatestDataFromParser(worktreePath string, fullDa // Refresh parser to get latest data from session file // This is critical - without it we return stale cached data - _, _ = reader.ReadIncremental() // Ignore errors - use cached data if refresh fails + if _, err := reader.ReadIncremental(); err != nil { + logger.Debugf("⚠️ populateLatestDataFromParser: ReadIncremental failed for %s: %v (using cached data)", worktreePath, err) + } // Get latest user prompt from history latestUserPrompt, err := reader.GetLatestUserPrompt() diff --git a/xcode/catnip/Models/WorkspaceInfo.swift b/xcode/catnip/Models/WorkspaceInfo.swift index 57f2e2d4..afbd55b3 100644 --- a/xcode/catnip/Models/WorkspaceInfo.swift +++ b/xcode/catnip/Models/WorkspaceInfo.swift @@ -114,6 +114,30 @@ struct WorkspaceInfo: Codable, Identifiable, Hashable { } return nil } + + /// Create a copy with a different activity state + func with(claudeActivityState: ClaudeActivityState) -> WorkspaceInfo { + WorkspaceInfo( + id: id, + name: name, + branch: branch, + repoId: repoId, + claudeActivityState: claudeActivityState, + commitCount: commitCount, + isDirty: isDirty, + lastAccessed: lastAccessed, + createdAt: createdAt, + todos: todos, + latestSessionTitle: latestSessionTitle, + latestUserPrompt: latestUserPrompt, + latestClaudeMessage: latestClaudeMessage, + pullRequestUrl: pullRequestUrl, + pullRequestState: pullRequestState, + hasCommitsAheadOfRemote: hasCommitsAheadOfRemote, + path: path, + cacheStatus: cacheStatus + ) + } } enum ClaudeActivityState: String, Codable { diff --git a/xcode/catnip/Services/CatnipAPI.swift b/xcode/catnip/Services/CatnipAPI.swift index aad93ea3..f8d94ed8 100644 --- a/xcode/catnip/Services/CatnipAPI.swift +++ b/xcode/catnip/Services/CatnipAPI.swift @@ -63,9 +63,6 @@ class CatnipAPI: ObservableObject { private func getHeaders(includeCodespace: Bool = false) async throws -> [String: String] { let token = try await getSessionToken() - #if DEBUG - NSLog("🐱 [CatnipAPI] Session Token: \(token)") - #endif var headers = [ "Content-Type": "application/json", "Authorization": "Bearer \(token)" diff --git a/xcode/catnip/Services/WorkspacePoller.swift b/xcode/catnip/Services/WorkspacePoller.swift index 567d4ba3..e6e76403 100644 --- a/xcode/catnip/Services/WorkspacePoller.swift +++ b/xcode/catnip/Services/WorkspacePoller.swift @@ -149,15 +149,15 @@ class WorkspacePoller: ObservableObject { let timeSinceLastChange = Date().timeIntervalSince(lastActivityStateChange) - // Active session: Poll frequently when Claude is actively working (.active) or session is running - // .running means session exists but Claude may start working soon - if workspace.claudeActivityState == .active || workspace.claudeActivityState == .running { + // .running = Claude is actively working → fast polling + if workspace.claudeActivityState == .running { return .active } + // .active = PTY exists but Claude not actively working // Recent work: Work finished less than 2 minutes ago // Keep polling at medium rate to catch final TODO updates and messages - if timeSinceLastChange < 120 { // 2 minutes + if workspace.claudeActivityState == .active || timeSinceLastChange < 120 { return .recentWork } @@ -193,10 +193,15 @@ class WorkspacePoller: ObservableObject { let previousState = workspace?.claudeActivityState // Track activity state changes for polling interval adaptation - let wasActive = previousState == .active || previousState == .running - if !isActive && wasActive { + // .running means Claude is actively working, .active means PTY exists but idle + let wasWorking = previousState == .running + if !isActive && wasWorking { lastActivityStateChange = Date() - NSLog("📊 Session became inactive, will switch to full polling soon") + NSLog("📊 Session stopped working, transitioning to idle polling") + + // Update workspace to .active (PTY still exists, just not working) + // This ensures determinePollingInterval() sees the correct state + workspace = workspace?.with(claudeActivityState: .active) } } diff --git a/xcode/catnip/Views/WorkspaceDetailView.swift b/xcode/catnip/Views/WorkspaceDetailView.swift index cb164123..9419875c 100644 --- a/xcode/catnip/Views/WorkspaceDetailView.swift +++ b/xcode/catnip/Views/WorkspaceDetailView.swift @@ -29,6 +29,7 @@ struct WorkspaceDetailView: View { @State private var latestMessage: String? @State private var cachedDiff: WorktreeDiffResponse? @State private var pendingUserPrompt: String? // Store prompt we just sent before backend updates + @State private var pendingUserPromptTimestamp: Date? // Track when prompt was sent for timeout @State private var isCreatingPR = false @State private var isUpdatingPR = false @State private var showingPRCreationSheet = false @@ -725,26 +726,35 @@ struct WorkspaceDetailView: View { let previousPhase = phase - // Clear pendingUserPrompt if backend has started processing or completed + // Clear pendingUserPrompt if backend has started processing, completed, or timed out // This prevents getting stuck in "working" phase if pendingUserPrompt != nil { // Backend received and started processing our prompt - if workspace.claudeActivityState == .active { + if workspace.claudeActivityState == .running { NSLog("📊 Backend started processing - clearing pending prompt") pendingUserPrompt = nil + pendingUserPromptTimestamp = nil } // Backend completed the session else if currentTitle != nil { NSLog("📊 Session created - clearing pending prompt") pendingUserPrompt = nil + pendingUserPromptTimestamp = nil + } + // Timeout: clear stale pending prompt after 30 seconds + else if let timestamp = pendingUserPromptTimestamp, + Date().timeIntervalSince(timestamp) > 30 { + NSLog("⚠️ Pending prompt timed out after 30s - clearing") + pendingUserPrompt = nil + pendingUserPromptTimestamp = nil } } // Show "working" phase when: - // 1. Claude is ACTIVE (actively processing), OR + // 1. Claude is .running (actively processing), OR // 2. We have a pending prompt (just sent a prompt but backend hasn't updated yet) - // Note: .running means session exists but Claude isn't actively working - show completed phase - if workspace.claudeActivityState == .active || pendingUserPrompt != nil { + // Note: .active means PTY exists but Claude isn't actively working - show completed phase + if workspace.claudeActivityState == .running || pendingUserPrompt != nil { phase = .working // Fetch latest message and diff while working @@ -806,6 +816,7 @@ struct WorkspaceDetailView: View { await MainActor.run { // Store the prompt we just sent for immediate display pendingUserPrompt = promptToSend + pendingUserPromptTimestamp = Date() NSLog("🐱 [WorkspaceDetailView] Stored pending prompt: \(promptToSend.prefix(50))...") prompt = ""