From 8eb41081f7d3153f8026f305d61228b1f81ef69c Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 18 May 2026 18:24:04 +0800 Subject: [PATCH 1/2] fix: ui issues and add global search and briefing section --- .../RxCodeChatKit/MessageListView.swift | 45 +- .../Models/BranchBriefingRecord.swift | 71 +++ .../Models/QueuedMessageRecord.swift | 2 +- .../Models/ThreadEmbeddingChunk.swift | 58 ++ .../Models/ThreadSummaryRecord.swift | 74 +++ Packages/Sources/RxCodeCore/WindowState.swift | 4 + RxCode/App/AppState.swift | 377 ++++++++++++- RxCode/App/RxCodeApp.swift | 22 +- RxCode/Services/ClaudeService.swift | 94 ++++ RxCode/Services/CodexAppServer.swift | 180 +++++++ .../Services/OpenAISummarizationService.swift | 147 ++++++ .../RunProfile/RunProfileDetector.swift | 232 ++++++++ RxCode/Services/ThreadSearchService.swift | 397 ++++++++++++++ RxCode/Services/ThreadStore.swift | 191 ++++++- .../Chat/RecentChatsSuggestionList.swift | 27 +- RxCode/Views/EmptyProjectStateView.swift | 152 ++++++ .../Inspector/InspectorContentView.swift | 194 ++++++- .../Views/Inspector/RightInspectorPanel.swift | 151 +++++- RxCode/Views/MainView.swift | 58 +- RxCode/Views/ProjectWindowView.swift | 7 +- .../RunProfile/RunConfigurationsView.swift | 70 ++- RxCode/Views/Search/GlobalSearchOverlay.swift | 410 ++++++++++++++ RxCode/Views/Sidebar/BriefingView.swift | 498 ++++++++++++++++++ RxCode/Views/Sidebar/ProjectChatRow.swift | 3 + RxCode/Views/Sidebar/ProjectTreeView.swift | 87 ++- RxCode/Views/Terminal/TerminalView.swift | 7 + RxCodeTests/AppStateProjectSwitchTests.swift | 63 +++ 27 files changed, 3485 insertions(+), 136 deletions(-) create mode 100644 Packages/Sources/RxCodeCore/Models/BranchBriefingRecord.swift create mode 100644 Packages/Sources/RxCodeCore/Models/ThreadEmbeddingChunk.swift create mode 100644 Packages/Sources/RxCodeCore/Models/ThreadSummaryRecord.swift create mode 100644 RxCode/Services/RunProfile/RunProfileDetector.swift create mode 100644 RxCode/Services/ThreadSearchService.swift create mode 100644 RxCode/Views/EmptyProjectStateView.swift create mode 100644 RxCode/Views/Search/GlobalSearchOverlay.swift create mode 100644 RxCode/Views/Sidebar/BriefingView.swift diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index c5bea4e9..1de04f47 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -15,56 +15,14 @@ struct MessageListView: View { /// (which also writes to `scrollTask`) can't cancel the session-ready flip. @State private var readyTask: Task? @State private var anchor = AutoScrollAnchor() - @State private var isOlderCollapsed = true @State private var isSessionReady = false - private let foldThreshold = 30 private static let log = Logger(subsystem: "com.claudework", category: "MessageListView") var body: some View { ScrollView { VStack(spacing: 16) { - // Fold older messages when count exceeds threshold - if settledItems.count > foldThreshold { - let hiddenCount = settledItems.count - foldThreshold - - // Expanded state: show older messages - if !isOlderCollapsed { - messageRows(settledItems.prefix(hiddenCount)) - } - - // Fold toggle button - Button { - withAnimation(.easeInOut(duration: 0.25)) { - isOlderCollapsed.toggle() - } - } label: { - HStack(spacing: 6) { - Group { - if isOlderCollapsed { - Text(String(format: String(localized: "Show %lld earlier messages", bundle: .module), hiddenCount)) - } else { - Text("Collapse earlier messages", bundle: .module) - } - } - .font(.system(size: ClaudeTheme.size(12), weight: .medium)) - Image(systemName: isOlderCollapsed ? "chevron.down" : "chevron.up") - .font(.system(size: ClaudeTheme.size(10), weight: .medium)) - } - .foregroundStyle(ClaudeTheme.textTertiary) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .fill(ClaudeTheme.surfacePrimary.opacity(0.6)) - ) - } - .buttonStyle(.plain) - - messageRows(settledItems.suffix(foldThreshold)) - } else { - messageRows(settledItems[...]) - } + messageRows(settledItems[...]) } .padding(.horizontal, 20) .padding(.top, 16) @@ -139,7 +97,6 @@ struct MessageListView: View { isSessionReady = false scrollTask?.cancel() readyTask?.cancel() - isOlderCollapsed = true scrollPosition = ScrollPosition() rebuildSettledItems() Self.log.info("[MessageList.task] post-rebuild settled=\(settledItems.count) sid=\(sid, privacy: .public) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") diff --git a/Packages/Sources/RxCodeCore/Models/BranchBriefingRecord.swift b/Packages/Sources/RxCodeCore/Models/BranchBriefingRecord.swift new file mode 100644 index 00000000..ee1a6026 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/BranchBriefingRecord.swift @@ -0,0 +1,71 @@ +import Foundation +import SwiftData + +public struct BranchBriefingItem: Identifiable, Sendable, Equatable { + public var id: String { "\(projectId.uuidString)::\(branch)" } + + public let projectId: UUID + public let branch: String + public let briefing: String + public let updatedAt: Date + + public init(projectId: UUID, branch: String, briefing: String, updatedAt: Date) { + self.projectId = projectId + self.branch = branch + self.briefing = briefing + self.updatedAt = updatedAt + } +} + +@Model +public final class BranchBriefingRecord { + @Attribute(.unique) public var id: String + public var projectId: UUID + public var branch: String + public var briefing: String + public var updatedAt: Date + /// Last time the branch was observed (e.g. as the current branch of its + /// project, or when the briefing was regenerated). Used to garbage-collect + /// briefings for branches that no longer exist locally or remotely. + public var lastSeenAt: Date = Date.distantPast + + public init( + projectId: UUID, + branch: String, + briefing: String, + updatedAt: Date = .now, + lastSeenAt: Date = .now + ) { + self.id = Self.makeId(projectId: projectId, branch: branch) + self.projectId = projectId + self.branch = branch + self.briefing = briefing + self.updatedAt = updatedAt + self.lastSeenAt = lastSeenAt + } + + public static func makeId(projectId: UUID, branch: String) -> String { + "\(projectId.uuidString)::\(branch)" + } + + public func apply(briefing: String, updatedAt: Date = .now) { + self.briefing = briefing + self.updatedAt = updatedAt + self.lastSeenAt = updatedAt + } + + public func touch(at date: Date = .now) { + if date > lastSeenAt { + lastSeenAt = date + } + } + + public func toItem() -> BranchBriefingItem { + BranchBriefingItem( + projectId: projectId, + branch: branch, + briefing: briefing, + updatedAt: updatedAt + ) + } +} diff --git a/Packages/Sources/RxCodeCore/Models/QueuedMessageRecord.swift b/Packages/Sources/RxCodeCore/Models/QueuedMessageRecord.swift index 9b27a3fa..3a482b81 100644 --- a/Packages/Sources/RxCodeCore/Models/QueuedMessageRecord.swift +++ b/Packages/Sources/RxCodeCore/Models/QueuedMessageRecord.swift @@ -7,7 +7,7 @@ import SwiftData @Model public final class QueuedMessageRecord { @Attribute(.unique) public var id: UUID - /// Either a real session id, or "new" for the not-yet-started chat bucket. + /// Either a real session id or a project-scoped new-chat key. /// Matches the keying scheme used by `WindowState.draftQueues`. public var sessionKey: String public var order: Int diff --git a/Packages/Sources/RxCodeCore/Models/ThreadEmbeddingChunk.swift b/Packages/Sources/RxCodeCore/Models/ThreadEmbeddingChunk.swift new file mode 100644 index 00000000..39600d8e --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/ThreadEmbeddingChunk.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftData + +/// Persisted semantic-search chunk for a single thread. A thread is split into +/// one or more chunks at index time, each chunk gets its own embedding, and +/// search ranks chunks then collapses to one hit per thread. +@Model +public final class ThreadEmbeddingChunk { + @Attribute(.unique) public var id: String + public var threadId: String + public var projectId: UUID + public var chunkIndex: Int + public var text: String + /// L2-normalised `[Float]` packed as little-endian bytes. Length == dim * 4. + public var vector: Data + public var dim: Int + public var updatedAt: Date + + public init( + id: String, + threadId: String, + projectId: UUID, + chunkIndex: Int, + text: String, + vector: Data, + dim: Int, + updatedAt: Date = .now + ) { + self.id = id + self.threadId = threadId + self.projectId = projectId + self.chunkIndex = chunkIndex + self.text = text + self.vector = vector + self.dim = dim + self.updatedAt = updatedAt + } + + public static func makeId(threadId: String, index: Int) -> String { + "\(threadId)#\(index)" + } +} + +extension ThreadEmbeddingChunk { + /// Decode the persisted `Data` blob back into a `[Float]` of length `dim`. + public func floatVector() -> [Float] { + let count = vector.count / MemoryLayout.size + guard count == dim else { return [] } + return vector.withUnsafeBytes { raw -> [Float] in + let buf = raw.bindMemory(to: Float.self) + return Array(buf) + } + } + + public static func encode(_ vector: [Float]) -> Data { + vector.withUnsafeBufferPointer { Data(buffer: $0) } + } +} diff --git a/Packages/Sources/RxCodeCore/Models/ThreadSummaryRecord.swift b/Packages/Sources/RxCodeCore/Models/ThreadSummaryRecord.swift new file mode 100644 index 00000000..638e8f90 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/ThreadSummaryRecord.swift @@ -0,0 +1,74 @@ +import Foundation +import SwiftData + +public struct ThreadSummaryItem: Identifiable, Sendable, Equatable { + public var id: String { sessionId } + + public let sessionId: String + public let projectId: UUID + public let branch: String + public let title: String + public let summary: String + public let updatedAt: Date + + public init( + sessionId: String, + projectId: UUID, + branch: String, + title: String, + summary: String, + updatedAt: Date + ) { + self.sessionId = sessionId + self.projectId = projectId + self.branch = branch + self.title = title + self.summary = summary + self.updatedAt = updatedAt + } +} + +@Model +public final class ThreadSummaryRecord { + @Attribute(.unique) public var sessionId: String + public var projectId: UUID + public var branch: String + public var title: String + public var summary: String + public var updatedAt: Date + + public init( + sessionId: String, + projectId: UUID, + branch: String, + title: String, + summary: String, + updatedAt: Date = .now + ) { + self.sessionId = sessionId + self.projectId = projectId + self.branch = branch + self.title = title + self.summary = summary + self.updatedAt = updatedAt + } + + public func apply(projectId: UUID, branch: String, title: String, summary: String, updatedAt: Date = .now) { + self.projectId = projectId + self.branch = branch + self.title = title + self.summary = summary + self.updatedAt = updatedAt + } + + public func toItem() -> ThreadSummaryItem { + ThreadSummaryItem( + sessionId: sessionId, + projectId: projectId, + branch: branch, + title: title, + summary: summary, + updatedAt: updatedAt + ) + } +} diff --git a/Packages/Sources/RxCodeCore/WindowState.swift b/Packages/Sources/RxCodeCore/WindowState.swift index 83c5f726..25409296 100644 --- a/Packages/Sources/RxCodeCore/WindowState.swift +++ b/Packages/Sources/RxCodeCore/WindowState.swift @@ -139,7 +139,11 @@ public final class WindowState { public var inspectorReviewTab: InspectorReviewTab = .thisThread public var inspectorFile: PreviewFile? public var diffFile: PreviewFile? + public var showingBriefing = true public var showMarketplace = false + /// Whether the global thread-search overlay is presented. Toggled by the + /// toolbar magnifier button and the Cmd+K shortcut. + public var showGlobalSearch = false public var showModelPicker = false public var showEffortPicker = false /// Per-session model override. When set, this model is used instead of the global default. diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index b0f3a2a6..2cf50847 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -470,6 +470,8 @@ final class AppState { var openAISummarizationModels: [String] = [] var openAISummarizationModelsError: String? var isLoadingOpenAISummarizationModels = false + var threadSummaryRevision = 0 + var branchBriefingRevision = 0 // MARK: - Notifications @@ -893,6 +895,7 @@ final class AppState { let marketplace = MarketplaceService() let mcp: MCPService let threadStore: ThreadStore + let searchService = ThreadSearchService() let runService = RunService() let ideMCPServer = IDEMCPServer() @@ -971,6 +974,23 @@ final class AppState { await codex.setPermissionServer(permission) await ideMCPServer.setHandler(self) } + + // Boot the on-device thread search index in the background. The actor + // loads cached chunks on `start`, then kicks off a one-time backfill + // of any threads that don't have chunks yet. + let searchService = self.searchService + let threadStore = self.threadStore + let persistence = self.persistence + Task.detached(priority: .utility) { [weak self] in + await searchService.start(threadStore: threadStore) + await searchService.backfillIfNeeded( + loadAll: { @MainActor in threadStore.loadAllSummaries() }, + loadFull: { @MainActor summary -> ChatSession? in + let cwd = self?.projects.first(where: { $0.id == summary.projectId })?.path ?? "" + return await persistence.loadFullSession(summary: summary, cwd: cwd) + } + ) + } } // MARK: - Agent Backends @@ -1470,6 +1490,7 @@ final class AppState { // it does not drive thread discovery. allSessionSummaries = threadStore.loadAllSummaries() autoArchiveExpiredSessionsIfNeeded() + purgeStaleBranchBriefingsIfNeeded() persistedQueues = threadStore.loadAllQueues() @@ -1816,7 +1837,7 @@ final class AppState { } window.inputText = "" - window.draftTexts.removeValue(forKey: window.currentSessionId ?? "new") + window.draftTexts.removeValue(forKey: draftKey(for: window)) window.attachments = [] let (resolvedAttachments, tempFilePaths) = AttachmentFactory.resolvingClipboardImages(currentAttachments) @@ -2362,6 +2383,7 @@ final class AppState { if let state = sessionStates.removeValue(forKey: previousSessionKey) { sessionStates[sid] = state } + renameDraftState(from: previousSessionKey, to: sid, in: window) sessionIdRedirect[previousSessionKey] = sid sessionKey = sid startFlushTimer(for: sid) @@ -2659,22 +2681,28 @@ final class AppState { } if notificationsEnabled, !NSApp.isActive { - let title = allSessionSummaries.first(where: { $0.id == resultEvent.sessionId })?.title ?? "New Session" - let firstSentence = stateForSession(sessionKey).messages - .last(where: { $0.role == .assistant && !$0.isError }) - .flatMap { msg -> String? in - let text = msg.content.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return nil } - let sentence = text.components(separatedBy: CharacterSet(charactersIn: ".!?\n")).first ?? text - return sentence.trimmingCharacters(in: .whitespaces) - } ?? "" + let summary = allSessionSummaries.first(where: { $0.id == resultEvent.sessionId }) + let title = summary?.title ?? "New Session" + let responseText = lastAssistantResponseText(in: stateForSession(sessionKey).messages) + let fallbackBody = responseNotificationFallback(from: responseText) let pid = projectId let sid = resultEvent.sessionId - Task { @MainActor in - await NotificationService.shared.postResponseComplete(title: title, body: firstSentence, projectId: pid, sessionId: sid) + Task { [weak self] in + var body = fallbackBody + if let self, let summary { + body = await self.generateResponseNotificationSummary(responseText: responseText, summary: summary) ?? fallbackBody + } + await NotificationService.shared.postResponseComplete(title: title, body: body, projectId: pid, sessionId: sid) } } + scheduleThreadSummaryUpdate( + sessionId: resultEvent.sessionId, + projectId: projectId, + cwd: cwd, + messages: stateForSession(sessionKey).messages + ) + // If this session is running in the background, automatically process any queued messages. // Foreground sessions are handled by InputBarView via isStreaming onChange. if !isFg { @@ -3102,6 +3130,7 @@ final class AppState { if let sid = window.currentSessionId, window.pendingPlaceholderIds.contains(sid) { allSessionSummaries.removeAll { $0.id == sid } threadStore.delete(id: sid) + Task.detached(priority: .utility) { [searchService] in await searchService.removeThread(id: sid) } window.removePendingPlaceholder(sid) sessionStates.removeValue(forKey: sid) window.currentSessionId = nil @@ -3355,6 +3384,9 @@ final class AppState { func selectProject(_ project: Project, in window: WindowState) { guard window.selectedProject?.id != project.id else { return } + saveDraft(in: window) + saveQueue(in: window) + if isStreaming(in: window) { detachCurrentStream(in: window) } @@ -3394,10 +3426,10 @@ final class AppState { // animation: nil — all mutations land in the same frame; sessionStates.filter fires // one @Observable notification instead of N removeValue calls. withAnimation(nil) { + window.showingBriefing = false window.selectedProject = project sessionStates = sessionStates.filter { $0.value.isStreaming } - window.currentSessionId = nil - startNewChat(in: window) + resetToNewChat(in: window) } activeProjectPath = project.path @@ -3482,6 +3514,7 @@ final class AppState { updateState(session.id) { $0.hasUncheckedCompletion = false } + window.showingBriefing = false window.currentSessionId = session.id window.sessionAgentProvider = sessionStates[session.id]?.agentProvider ?? session.agentProvider window.sessionModel = sessionStates[session.id]?.model ?? session.model @@ -3612,6 +3645,11 @@ final class AppState { saveDraft(in: window) saveQueue(in: window) releaseOutgoingSession(window.currentSessionId, in: window) + resetToNewChat(in: window) + } + + private func resetToNewChat(in window: WindowState) { + window.showingBriefing = false window.currentSessionId = nil window.sessionAgentProvider = nil window.sessionModel = nil @@ -3619,8 +3657,8 @@ final class AppState { window.sessionPermissionMode = nil window.sessionPlanMode = false sessionStates.removeValue(forKey: window.newSessionKey) - window.inputText = window.draftTexts["new"] ?? "" - window.messageQueue = window.draftQueues["new"] ?? [] + window.inputText = window.draftTexts[newDraftKey(for: window)] ?? "" + window.messageQueue = window.draftQueues[newDraftKey(for: window)] ?? [] window.requestInputFocus = true } @@ -3712,6 +3750,149 @@ final class AppState { } } + private func generateResponseNotificationSummary(responseText: String, summary: ChatSession.Summary) async -> String? { + let trimmedResponse = responseText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedResponse.isEmpty else { return nil } + + switch summarizationProvider { + case .selectedClient: + let provider = summary.agentProvider + let model = summary.model ?? selectedSummarizationModel(for: provider) + return await generateResponseNotificationSummary(responseText: trimmedResponse, provider: provider, model: model) + case .openAI: + guard !openAISummarizationModel.isEmpty else { return nil } + return await openAISummarization.generateResponseNotificationSummary( + responseText: trimmedResponse, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + } + } + + private func scheduleThreadSummaryUpdate( + sessionId: String, + projectId: UUID, + cwd: String, + messages: [ChatMessage] + ) { + let userMessage = lastUserMessageText(in: messages) + let finalResponse = lastAssistantResponseText(in: messages) + guard !userMessage.isEmpty, !finalResponse.isEmpty else { return } + + let summary = allSessionSummaries.first(where: { $0.id == sessionId }) + ?? summaryFor(sessionId: sessionId, projectId: projectId) + + Task { [weak self] in + guard let self else { return } + await self.updateStoredThreadSummary( + sessionId: sessionId, + projectId: projectId, + cwd: cwd, + userMessage: userMessage, + finalResponse: finalResponse, + summary: summary + ) + } + } + + private func updateStoredThreadSummary( + sessionId: String, + projectId: UUID, + cwd: String, + userMessage: String, + finalResponse: String, + summary: ChatSession.Summary + ) async { + let previousSummary = threadStore.threadSummaryItem(sessionId: sessionId)?.summary + guard let threadSummary = await generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse, + summary: summary + ) else { return } + + let branchPath = summary.worktreePath ?? cwd + let currentBranch = await GitHelper.currentBranch(at: branchPath) + let branch = summary.worktreeBranch ?? currentBranch ?? "unknown" + let title = summary.title.isEmpty ? ChatSession.defaultTitle : summary.title + + threadStore.upsertThreadSummary( + sessionId: sessionId, + projectId: projectId, + branch: branch, + title: title, + summary: threadSummary + ) + threadSummaryRevision &+= 1 + + let allThreadSummaries = threadStore + .threadSummaryItems(projectId: projectId, branch: branch) + .map { (title: $0.title, summary: $0.summary) } + guard let briefing = await generateBranchBriefing( + threadSummaries: allThreadSummaries, + summary: summary + ) else { return } + + threadStore.upsertBranchBriefing(projectId: projectId, branch: branch, briefing: briefing) + branchBriefingRevision &+= 1 + } + + private func generateThreadSummary( + previousSummary: String?, + userMessage: String, + finalResponse: String, + summary: ChatSession.Summary + ) async -> String? { + switch summarizationProvider { + case .selectedClient: + let provider = summary.agentProvider + let model = summary.model ?? selectedSummarizationModel(for: provider) + return await generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse, + provider: provider, + model: model + ) + case .openAI: + guard !openAISummarizationModel.isEmpty else { return nil } + return await openAISummarization.generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + } + } + + private func generateBranchBriefing( + threadSummaries: [(title: String, summary: String)], + summary: ChatSession.Summary + ) async -> String? { + guard !threadSummaries.isEmpty else { return nil } + switch summarizationProvider { + case .selectedClient: + let provider = summary.agentProvider + let model = summary.model ?? selectedSummarizationModel(for: provider) + return await generateBranchBriefing( + threadSummaries: threadSummaries, + provider: provider, + model: model + ) + case .openAI: + guard !openAISummarizationModel.isEmpty else { return nil } + return await openAISummarization.generateBranchBriefing( + threadSummaries: threadSummaries, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + } + } + private func generateSessionTitle(firstUserMessage: String, provider: AgentProvider, model: String?) async -> String? { switch provider { case .claudeCode: @@ -3724,6 +3905,88 @@ final class AppState { } } + private func generateThreadSummary( + previousSummary: String?, + userMessage: String, + finalResponse: String, + provider: AgentProvider, + model: String? + ) async -> String? { + switch provider { + case .claudeCode: + return await claude.generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse, + model: model ?? "haiku" + ) + case .codex: + return await codex.generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse, + model: model + ) + case .acp: + return nil + } + } + + private func generateBranchBriefing( + threadSummaries: [(title: String, summary: String)], + provider: AgentProvider, + model: String? + ) async -> String? { + switch provider { + case .claudeCode: + return await claude.generateBranchBriefing( + threadSummaries: threadSummaries, + model: model ?? "haiku" + ) + case .codex: + return await codex.generateBranchBriefing( + threadSummaries: threadSummaries, + model: model + ) + case .acp: + return nil + } + } + + private func generateResponseNotificationSummary(responseText: String, provider: AgentProvider, model: String?) async -> String? { + switch provider { + case .claudeCode: + return await claude.generateResponseNotificationSummary(responseText: responseText, model: model ?? "haiku") + case .codex: + return await codex.generateResponseNotificationSummary(responseText: responseText, model: model) + case .acp: + // No standardized one-shot generation in ACP; keep the local preview fallback. + return nil + } + } + + private func lastAssistantResponseText(in messages: [ChatMessage]) -> String { + guard let message = messages.last(where: { $0.role == .assistant && !$0.isError }) else { + return "" + } + return message.content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func lastUserMessageText(in messages: [ChatMessage]) -> String { + guard let message = messages.last(where: { $0.role == .user && !$0.isError }) else { + return "" + } + return ChatSession.stripAttachmentMarkers(from: message.content) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func responseNotificationFallback(from responseText: String) -> String { + let text = responseText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return "" } + let sentence = text.components(separatedBy: CharacterSet(charactersIn: ".!?\n")).first ?? text + return sentence.trimmingCharacters(in: .whitespaces) + } + private func selectedSummarizationModel(for provider: AgentProvider) -> String? { if selectedAgentProvider == provider { return selectedModel @@ -3782,6 +4045,32 @@ final class AppState { } } + /// Retention window (days) after which a branch briefing is purged if its + /// branch hasn't been observed locally or remotely. A branch deleted both + /// places will simply stop being touched and age out. + private static let branchBriefingRetentionDays = 30 + + /// Mark a branch as still alive on disk so its briefing isn't garbage- + /// collected. Called from views that have just observed the branch via + /// `git symbolic-ref` or similar. + func touchBranchBriefing(projectId: UUID, branch: String) { + threadStore.touchBranchBriefing(projectId: projectId, branch: branch) + } + + /// Delete branch briefings for branches that haven't been seen for the + /// retention window. Run once at app launch. + func purgeStaleBranchBriefingsIfNeeded() { + let cutoff = Calendar.current.date( + byAdding: .day, + value: -Self.branchBriefingRetentionDays, + to: Date() + ) ?? Date() + let purged = threadStore.purgeStaleBranchBriefings(olderThan: cutoff) + guard !purged.isEmpty else { return } + branchBriefingRevision &+= 1 + logger.info("Purged \(purged.count) stale branch briefings older than \(Self.branchBriefingRetentionDays) days") + } + /// Apply the auto-archive policy: archive non-pinned chats whose `updatedAt` /// is older than `archiveRetentionDays`. Run once at app launch. func autoArchiveExpiredSessionsIfNeeded() { @@ -3945,6 +4234,8 @@ final class AppState { // Remove all in-memory session summaries for this project threadStore.deleteAll(projectId: project.id) + let projectId = project.id + Task.detached(priority: .utility) { [searchService] in await searchService.removeProject(id: projectId) } allSessionSummaries.removeAll { $0.projectId == project.id } // Remove from projects list and persist @@ -3976,6 +4267,8 @@ final class AppState { } allSessionSummaries.removeAll { $0.id == session.id } threadStore.delete(id: session.id) + let deletedId = session.id + Task.detached(priority: .utility) { [searchService] in await searchService.removeThread(id: deletedId) } sessionStates.removeValue(forKey: session.id) } @@ -4019,8 +4312,20 @@ final class AppState { for id in ids { threadStore.delete(id: id) } + let snapshotIds = ids + Task.detached(priority: .utility) { [searchService] in + for id in snapshotIds { await searchService.removeThread(id: id) } + } } else { threadStore.deleteAll(projectId: projectId) + if let projectId { + Task.detached(priority: .utility) { [searchService] in await searchService.removeProject(id: projectId) } + } else { + let snapshotIds = ids + Task.detached(priority: .utility) { [searchService] in + for id in snapshotIds { await searchService.removeThread(id: id) } + } + } } for id in ids { sessionStates.removeValue(forKey: id) @@ -4339,6 +4644,13 @@ final class AppState { } } threadStore.upsert(summary) + + // Update the on-device semantic index. Skipped while streaming so + // we only embed a thread once it has settled. + let snapshot = session + Task.detached(priority: .utility) { [searchService] in + await searchService.indexThread(snapshot) + } } // Update the project's lastSessionId @@ -4353,7 +4665,7 @@ final class AppState { } private func saveDraft(in window: WindowState) { - let key = window.currentSessionId ?? "new" + let key = draftKey(for: window) let trimmed = window.inputText.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { window.draftTexts.removeValue(forKey: key) } else { window.draftTexts[key] = window.inputText } @@ -4366,10 +4678,35 @@ final class AppState { } /// Persistence key used for both the in-memory `draftQueues` mirror and the - /// SwiftData `QueuedMessageRecord.sessionKey` column. Matches the legacy - /// keying used by `saveQueue` so existing in-memory state migrates without churn. + /// SwiftData `QueuedMessageRecord.sessionKey` column. private func queueKey(for window: WindowState) -> String { - window.currentSessionId ?? "new" + draftKey(for: window) + } + + private func draftKey(for window: WindowState) -> String { + window.currentSessionId ?? newDraftKey(for: window) + } + + private func newDraftKey(for window: WindowState) -> String { + guard let projectId = window.selectedProject?.id else { return "new" } + return "new:\(projectId.uuidString)" + } + + private func renameDraftState(from oldKey: String, to newKey: String, in window: WindowState) { + guard oldKey != newKey else { return } + + if let text = window.draftTexts.removeValue(forKey: oldKey), + window.draftTexts[newKey] == nil { + window.draftTexts[newKey] = text + } + + guard let queue = window.draftQueues.removeValue(forKey: oldKey) else { return } + if var existing = window.draftQueues[newKey] { + existing.append(contentsOf: queue) + window.draftQueues[newKey] = existing + } else { + window.draftQueues[newKey] = queue + } } // MARK: - Message Queue (persisted) diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index bebad15e..c956df43 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -515,7 +515,7 @@ struct ProjectWindowRoot: View { var body: some View { ZStack { if appState.isInitialized { - ProjectWindowView() + MainView() .environment(appState) .environment(windowState) .environment(chatBridge) @@ -534,6 +534,11 @@ struct ProjectWindowRoot: View { } } .animation(.easeInOut(duration: 0.3), value: appState.isInitialized) + .onAppear { + windowState.isProjectWindow = true + appState.registerOpenProjectWindow(projectId) + } + .onDisappear { appState.unregisterOpenProjectWindow(projectId) } .task { // Wait for the main window's AppState.initialize() to finish before // running per-window setup. State-restoration can spawn this window @@ -541,6 +546,7 @@ struct ProjectWindowRoot: View { while !appState.isInitialized { try? await Task.sleep(nanoseconds: 50_000_000) } + windowState.isProjectWindow = true appState.setupChatBridge(chatBridge, for: windowState) await appState.initializeWindow(windowState, selectingProjectId: projectId) // Apply pending notification navigation (new window case) @@ -548,14 +554,12 @@ struct ProjectWindowRoot: View { windowState.currentSessionId = sessionId } } - .onAppear { appState.registerOpenProjectWindow(projectId) } - .onDisappear { appState.unregisterOpenProjectWindow(projectId) } - // Apply pending notification navigation (already-open window case) - .onChange(of: appState.pendingNotificationSession[projectId]) { _, sessionId in - guard let sessionId else { return } - windowState.currentSessionId = sessionId - appState.pendingNotificationSession.removeValue(forKey: projectId) - } + // Apply pending notification navigation (already-open window case) + .onChange(of: appState.pendingNotificationSession[projectId]) { _, sessionId in + guard let sessionId else { return } + windowState.currentSessionId = sessionId + appState.pendingNotificationSession.removeValue(forKey: projectId) + } } } diff --git a/RxCode/Services/ClaudeService.swift b/RxCode/Services/ClaudeService.swift index 76528739..142707ba 100644 --- a/RxCode/Services/ClaudeService.swift +++ b/RxCode/Services/ClaudeService.swift @@ -311,6 +311,100 @@ actor ClaudeCodeServer { } } + func generateResponseNotificationSummary(responseText: String, model: String = "claude-haiku-4-5-20251001") async -> String? { + guard let binary = await findClaudeBinary() else { return nil } + let trimmedResponse = String(responseText.prefix(4000)) + let prompt = """ + Summarize the following assistant response for a macOS notification. \ + Reply with one concise sentence under 180 characters. Mention the outcome and the most important result. \ + No markdown. + + \(trimmedResponse) + """ + let emptyMCPConfigPath = writeEmptyMCPConfig() + var args: [String] = ["-p", prompt, "--output-format", "text", "--model", model] + if let emptyMCPConfigPath { + args.append(contentsOf: ["--strict-mcp-config", "--mcp-config", emptyMCPConfigPath]) + } + do { + let output = try await runShellCommand(binary, arguments: args) + let cleaned = output + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "claude error"] + if errorPrefixes.contains(where: { lower.hasPrefix($0) }) { + logger.warning("Notification summary produced an error string; ignoring: \(cleaned.prefix(120))") + return nil + } + return String(cleaned.prefix(180)) + } catch { + logger.warning("Notification summary generation failed: \(error.localizedDescription)") + return nil + } + } + + func generateThreadSummary( + previousSummary: String?, + userMessage: String, + finalResponse: String, + model: String = "claude-haiku-4-5-20251001" + ) async -> String? { + let previous = previousSummary?.trimmingCharacters(in: .whitespacesAndNewlines) + let prompt = """ + Update the stored summary for one project thread. Use the previous summary, latest user request, and final assistant response. + Keep it factual and concise, 3-6 bullet points max. Include completed work, important decisions, files or areas touched, and unresolved follow-ups. + Reply with only the updated summary. + + Previous summary: + \((previous?.isEmpty == false) ? previous! : "None") + + Latest user request: + \(String(userMessage.prefix(2000))) + + Final assistant response: + \(String(finalResponse.prefix(4000))) + """ + return await generatePlainSummary(prompt: prompt, model: model, limit: 1800) + } + + func generateBranchBriefing( + threadSummaries: [(title: String, summary: String)], + model: String = "claude-haiku-4-5-20251001" + ) async -> String? { + guard !threadSummaries.isEmpty else { return nil } + let prompt = OpenAISummarizationService.branchBriefingPrompt(threadSummaries: threadSummaries) + return await generatePlainSummary(prompt: prompt, model: model, limit: 1800) + } + + private func generatePlainSummary(prompt: String, model: String, limit: Int) async -> String? { + guard let binary = await findClaudeBinary() else { return nil } + let emptyMCPConfigPath = writeEmptyMCPConfig() + var args: [String] = ["-p", prompt, "--output-format", "text", "--model", model] + if let emptyMCPConfigPath { + args.append(contentsOf: ["--strict-mcp-config", "--mcp-config", emptyMCPConfigPath]) + } + do { + let output = try await runShellCommand(binary, arguments: args) + return cleanGeneratedSummary(output, limit: limit) + } catch { + logger.warning("Summary generation failed: \(error.localizedDescription)") + return nil + } + } + + private func cleanGeneratedSummary(_ raw: String, limit: Int) -> String? { + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "claude error"] + guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } + return String(cleaned.prefix(limit)) + } + /// Write a one-off MCP config file (with no servers) used by the title-generation /// call so it doesn't inherit user-level MCP servers. Returns nil on I/O failure; /// caller falls back to the default config. diff --git a/RxCode/Services/CodexAppServer.swift b/RxCode/Services/CodexAppServer.swift index 614970b3..e2c2e4cb 100644 --- a/RxCode/Services/CodexAppServer.swift +++ b/RxCode/Services/CodexAppServer.swift @@ -220,6 +220,170 @@ actor CodexAppServer { return cleanTitle(title) } + func generateResponseNotificationSummary(responseText: String, model: String?) async -> String? { + guard let binary = await findCodexBinary() else { return nil } + let trimmedResponse = String(responseText.prefix(4000)) + let prompt = """ + Summarize the following assistant response for a macOS notification. Reply with one concise sentence under 180 characters. Mention the outcome and the most important result. No markdown. + + \(trimmedResponse) + """ + + let streamId = UUID() + var summary = "" + + do { + let cwd = FileManager.default.homeDirectoryForCurrentUser.path + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) + defer { finalize(streamId: streamId) } + + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + + var activeThreadId: String? + var turnStarted = false + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard let object = Self.decodeObject(line) else { continue } + + if let id = Self.idString(object["id"]), object["method"] == nil { + switch id { + case "1": + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 2, method: "thread/start", params: threadParams(threadId: nil, cwd: cwd)), to: handles.stdin) + case "2": + if let result = object["result"] { + activeThreadId = Self.threadId(from: result) ?? UUID().uuidString + } + if let activeThreadId, !turnStarted { + try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: .default, planMode: false)), to: handles.stdin) + turnStarted = true + } + default: + break + } + continue + } + + guard let method = object["method"]?.stringValue else { continue } + if let requestId = Self.idString(object["id"]) { + try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) + continue + } + + let params = object["params"]?.objectValue ?? [:] + switch method { + case "item/agentMessage/delta", "item/agent_message/delta": + summary += Self.firstString(in: params, keys: ["delta", "text", "content"]) ?? "" + case "turn/completed": + return cleanNotificationSummary(summary) + case "turn/failed", "error": + return nil + default: + break + } + } + } catch { + logger.warning("Codex notification summary generation failed: \(error.localizedDescription)") + } + return cleanNotificationSummary(summary) + } + + func generateThreadSummary( + previousSummary: String?, + userMessage: String, + finalResponse: String, + model: String? + ) async -> String? { + let previous = previousSummary?.trimmingCharacters(in: .whitespacesAndNewlines) + let prompt = """ + Update the stored summary for one project thread. Use the previous summary, latest user request, and final assistant response. + Keep it factual and concise, 3-6 bullet points max. Include completed work, important decisions, files or areas touched, and unresolved follow-ups. + Reply with only the updated summary. + + Previous summary: + \((previous?.isEmpty == false) ? previous! : "None") + + Latest user request: + \(String(userMessage.prefix(2000))) + + Final assistant response: + \(String(finalResponse.prefix(4000))) + """ + guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil } + return cleanSummary(raw, limit: 1800) + } + + func generateBranchBriefing( + threadSummaries: [(title: String, summary: String)], + model: String? + ) async -> String? { + guard !threadSummaries.isEmpty else { return nil } + let prompt = OpenAISummarizationService.branchBriefingPrompt(threadSummaries: threadSummaries) + guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil } + return cleanSummary(raw, limit: 1800) + } + + private func generateCodexPlainSummary(prompt: String, model: String?) async -> String? { + guard let binary = await findCodexBinary() else { return nil } + let streamId = UUID() + var summary = "" + + do { + let cwd = FileManager.default.homeDirectoryForCurrentUser.path + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) + defer { finalize(streamId: streamId) } + + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + + var activeThreadId: String? + var turnStarted = false + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard let object = Self.decodeObject(line) else { continue } + + if let id = Self.idString(object["id"]), object["method"] == nil { + switch id { + case "1": + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 2, method: "thread/start", params: threadParams(threadId: nil, cwd: cwd)), to: handles.stdin) + case "2": + if let result = object["result"] { + activeThreadId = Self.threadId(from: result) ?? UUID().uuidString + } + if let activeThreadId, !turnStarted { + try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: .default, planMode: false)), to: handles.stdin) + turnStarted = true + } + default: + break + } + continue + } + + guard let method = object["method"]?.stringValue else { continue } + if let requestId = Self.idString(object["id"]) { + try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) + continue + } + + let params = object["params"]?.objectValue ?? [:] + switch method { + case "item/agentMessage/delta", "item/agent_message/delta": + summary += Self.firstString(in: params, keys: ["delta", "text", "content"]) ?? "" + case "turn/completed": + return summary + case "turn/failed", "error": + return nil + default: + break + } + } + } catch { + logger.warning("Codex summary generation failed: \(error.localizedDescription)") + } + return summary + } + private func fetchRateLimitsUncached() async -> RateLimitUsage? { guard let binary = await findCodexBinary() else { logger.warning("Skipping Codex rate-limit fetch because no codex binary was found") @@ -828,6 +992,22 @@ actor CodexAppServer { return String(cleaned.prefix(80)) } + private func cleanNotificationSummary(_ raw: String) -> String? { + cleanSummary(raw, limit: 180) + } + + private func cleanSummary(_ raw: String, limit: Int) -> String? { + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "codex error"] + guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } + return String(cleaned.prefix(limit)) + } + private func toolName(from item: [String: JSONValue]) -> String? { if let type = Self.firstString(in: item, keys: ["type", "kind"]) { let normalizedType = type.lowercased() diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index 040d23a4..7bb16bb4 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -86,6 +86,136 @@ actor OpenAISummarizationService { } } + func generateResponseNotificationSummary(responseText: String, endpoint: String, apiKey: String, model: String) async -> String? { + let trimmedResponse = String(responseText.prefix(4000)) + let prompt = """ + Summarize the following assistant response for a macOS notification. Reply with one concise sentence under 180 characters. Mention the outcome and the most important result. No markdown. + + \(trimmedResponse) + """ + + let body: JSONValue = .object([ + "model": .string(model), + "messages": .array([ + .object([ + "role": .string("system"), + "content": .string("You write concise notification summaries.") + ]), + .object([ + "role": .string("user"), + "content": .string(prompt) + ]) + ]), + "temperature": .number(0.2), + "max_tokens": .number(64) + ]) + + do { + var request = try makeRequest(endpoint: endpoint, path: "/chat/completions", apiKey: apiKey) + request.httpMethod = "POST" + request.httpBody = try JSONEncoder().encode(body) + + let value = try await send(request) + let content = value.objectValue?["choices"]?.arrayValue?.first? + .objectValue?["message"]?.objectValue?["content"]?.stringValue + ?? value.objectValue?["choices"]?.arrayValue?.first? + .objectValue?["text"]?.stringValue + return cleanNotificationSummary(content) + } catch { + logger.warning("OpenAI notification summary generation failed: \(error.localizedDescription)") + return nil + } + } + + func generateThreadSummary( + previousSummary: String?, + userMessage: String, + finalResponse: String, + endpoint: String, + apiKey: String, + model: String + ) async -> String? { + let previous = previousSummary?.trimmingCharacters(in: .whitespacesAndNewlines) + let prompt = """ + Update the stored summary for one project thread. Use the previous summary, latest user request, and final assistant response. + Keep it factual and concise, 3-6 bullet points max. Include completed work, important decisions, files or areas touched, and unresolved follow-ups. + Reply with only the updated summary. + + Previous summary: + \((previous?.isEmpty == false) ? previous! : "None") + + Latest user request: + \(String(userMessage.prefix(2000))) + + Final assistant response: + \(String(finalResponse.prefix(4000))) + """ + return await generateSummary(prompt: prompt, endpoint: endpoint, apiKey: apiKey, model: model, maxTokens: 256) + } + + func generateBranchBriefing( + threadSummaries: [(title: String, summary: String)], + endpoint: String, + apiKey: String, + model: String + ) async -> String? { + guard !threadSummaries.isEmpty else { return nil } + let prompt = Self.branchBriefingPrompt(threadSummaries: threadSummaries) + return await generateSummary(prompt: prompt, endpoint: endpoint, apiKey: apiKey, model: model, maxTokens: 384) + } + + static func branchBriefingPrompt(threadSummaries: [(title: String, summary: String)]) -> String { + let joined = threadSummaries.map { item -> String in + let title = item.title.trimmingCharacters(in: .whitespacesAndNewlines) + let summary = String(item.summary.prefix(1500)).trimmingCharacters(in: .whitespacesAndNewlines) + return "### \(title.isEmpty ? "Untitled thread" : title)\n\(summary)" + }.joined(separator: "\n\n") + + return """ + Write a concise overall briefing for one git branch by synthesizing the per-thread summaries below into a single coherent overview. + Cover the main themes, completed work, important decisions, files or areas touched, and unresolved follow-ups across the whole branch. + Do not list threads individually — produce a unified summary. Use 4-8 short bullet points. Reply with only the briefing. + + Thread summaries (newest first): + + \(joined) + """ + } + + private func generateSummary(prompt: String, endpoint: String, apiKey: String, model: String, maxTokens: Double) async -> String? { + let body: JSONValue = .object([ + "model": .string(model), + "messages": .array([ + .object([ + "role": .string("system"), + "content": .string("You maintain concise local project summaries.") + ]), + .object([ + "role": .string("user"), + "content": .string(prompt) + ]) + ]), + "temperature": .number(0.2), + "max_tokens": .number(maxTokens) + ]) + + do { + var request = try makeRequest(endpoint: endpoint, path: "/chat/completions", apiKey: apiKey) + request.httpMethod = "POST" + request.httpBody = try JSONEncoder().encode(body) + + let value = try await send(request) + let content = value.objectValue?["choices"]?.arrayValue?.first? + .objectValue?["message"]?.objectValue?["content"]?.stringValue + ?? value.objectValue?["choices"]?.arrayValue?.first? + .objectValue?["text"]?.stringValue + return cleanSummary(content, limit: 1800) + } catch { + logger.warning("OpenAI summary generation failed: \(error.localizedDescription)") + return nil + } + } + private func makeRequest(endpoint: String, path: String, apiKey: String) throws -> URLRequest { guard let url = endpointURL(endpoint: endpoint, path: path) else { throw OpenAIError.invalidEndpoint @@ -145,4 +275,21 @@ actor OpenAISummarizationService { guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } return String(cleaned.prefix(80)) } + + private func cleanNotificationSummary(_ raw: String?) -> String? { + cleanSummary(raw, limit: 180) + } + + private func cleanSummary(_ raw: String?, limit: Int) -> String? { + guard let raw else { return nil } + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "openai error"] + guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } + return String(cleaned.prefix(limit)) + } } diff --git a/RxCode/Services/RunProfile/RunProfileDetector.swift b/RxCode/Services/RunProfile/RunProfileDetector.swift new file mode 100644 index 00000000..0a50482f --- /dev/null +++ b/RxCode/Services/RunProfile/RunProfileDetector.swift @@ -0,0 +1,232 @@ +import Foundation + +/// Source of an auto-detected runnable shown in the "+" picker of the Run +/// Configurations dialog. +enum RunnableSource: String, Sendable, Hashable { + case xcode + case npm + case make +} + +/// A single auto-detected runnable. Pre-fills the bash command and display +/// name when the user picks it from the "+" menu. Not persisted on its own — +/// selecting one materializes a normal `RunProfile`. +struct DetectedRunnable: Identifiable, Hashable, Sendable { + let id: String + let source: RunnableSource + let displayName: String + let command: String +} + +struct DetectedRunnables: Sendable, Hashable { + var xcode: [DetectedRunnable] = [] + var npm: [DetectedRunnable] = [] + var make: [DetectedRunnable] = [] + + var isEmpty: Bool { xcode.isEmpty && npm.isEmpty && make.isEmpty } +} + +/// Read-only scanner that surfaces Xcode schemes, npm scripts, and Make +/// targets at a project root. Errors are swallowed — a broken `xcodebuild` +/// or unreadable file yields an empty list, not a thrown error. +@MainActor +final class RunProfileDetector { + func detect(in projectPath: String) async -> DetectedRunnables { + async let xcode = detectXcode(at: projectPath) + async let npm = detectNpm(at: projectPath) + async let make = detectMake(at: projectPath) + return await DetectedRunnables(xcode: xcode, npm: npm, make: make) + } + + // MARK: - Xcode + + private func detectXcode(at root: String) async -> [DetectedRunnable] { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(atPath: root) else { return [] } + + let workspaces = entries.filter { $0.hasSuffix(".xcworkspace") }.sorted() + let projects = entries.filter { $0.hasSuffix(".xcodeproj") }.sorted() + + let flag: String + let container: String + if let ws = workspaces.first { + flag = "-workspace" + container = ws + } else if let proj = projects.first { + flag = "-project" + container = proj + } else { + return [] + } + + let containerPath = (root as NSString).appendingPathComponent(container) + let (stdout, code) = await runProcess( + executable: "/usr/bin/env", + arguments: ["xcodebuild", "-list", "-json", flag, containerPath], + cwd: root, + timeoutSeconds: 8 + ) + guard code == 0, let data = stdout.data(using: .utf8) else { return [] } + + struct XcodeList: Decodable { + struct Workspace: Decodable { let schemes: [String]? } + struct Project: Decodable { let schemes: [String]? } + let workspace: Workspace? + let project: Project? + } + + guard let parsed = try? JSONDecoder().decode(XcodeList.self, from: data) else { return [] } + let schemes = parsed.workspace?.schemes ?? parsed.project?.schemes ?? [] + + return schemes.map { name in + let cmd = "xcodebuild \(flag) \"\(container)\" -scheme \"\(name)\" build" + return DetectedRunnable( + id: "xcode:\(name)", + source: .xcode, + displayName: name, + command: cmd + ) + } + } + + // MARK: - npm / pnpm / yarn + + private func detectNpm(at root: String) async -> [DetectedRunnable] { + let pkgPath = (root as NSString).appendingPathComponent("package.json") + guard let data = try? Data(contentsOf: URL(fileURLWithPath: pkgPath)) else { return [] } + + struct PackageJSON: Decodable { let scripts: [String: String]? } + guard let parsed = try? JSONDecoder().decode(PackageJSON.self, from: data), + let scripts = parsed.scripts, !scripts.isEmpty + else { return [] } + + let runner = npmRunner(at: root) + return scripts.keys.sorted().map { name in + DetectedRunnable( + id: "npm:\(name)", + source: .npm, + displayName: name, + command: "\(runner) \(name)" + ) + } + } + + private func npmRunner(at root: String) -> String { + let fm = FileManager.default + let path = root as NSString + if fm.fileExists(atPath: path.appendingPathComponent("pnpm-lock.yaml")) { return "pnpm run" } + if fm.fileExists(atPath: path.appendingPathComponent("yarn.lock")) { return "yarn" } + return "npm run" + } + + // MARK: - Makefile + + private func detectMake(at root: String) async -> [DetectedRunnable] { + let fm = FileManager.default + let candidates = ["Makefile", "makefile", "GNUmakefile"] + let path = root as NSString + guard let chosen = candidates.first(where: { fm.fileExists(atPath: path.appendingPathComponent($0)) }), + let content = try? String(contentsOfFile: path.appendingPathComponent(chosen), encoding: .utf8) + else { return [] } + + let targets = parseMakeTargets(content) + return targets.map { name in + DetectedRunnable( + id: "make:\(name)", + source: .make, + displayName: name, + command: "make \(name)" + ) + } + } + + private func parseMakeTargets(_ content: String) -> [String] { + // Target lines look like `name:` or `name: deps` at column 0 (no leading + // tab/space — those are recipe lines). Skip pattern rules (`%.o:`), + // special targets (`.PHONY:`), variable assignments (`X := y`). + var seen = Set() + var result: [String] = [] + let pattern = #"^([A-Za-z0-9_./+-]+)\s*:(?!=)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + for rawLine in content.components(separatedBy: "\n") { + if rawLine.hasPrefix("\t") || rawLine.hasPrefix(" ") { continue } + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix("#") { continue } + + let nsLine = rawLine as NSString + let range = NSRange(location: 0, length: nsLine.length) + guard let match = regex.firstMatch(in: rawLine, range: range), + match.numberOfRanges >= 2 + else { continue } + + let name = nsLine.substring(with: match.range(at: 1)) + if name.hasPrefix(".") { continue } // .PHONY, .DEFAULT, etc. + if name.contains("%") { continue } // pattern rule + if seen.insert(name).inserted { + result.append(name) + } + } + return result + } + + // MARK: - Process helper + + private func runProcess( + executable: String, + arguments: [String], + cwd: String, + timeoutSeconds: Int + ) async -> (stdout: String, exitCode: Int32) { + await withCheckedContinuation { continuation in + let process = Process() + let pipe = Pipe() + + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = pipe + process.standardError = Pipe() + process.environment = ProcessInfo.processInfo.environment + + let resumed = ManagedAtomicFlag() + process.terminationHandler = { _ in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let out = String(data: data, encoding: .utf8) ?? "" + if resumed.setIfUnset() { + continuation.resume(returning: (out, process.terminationStatus)) + } + } + + do { + try process.run() + } catch { + if resumed.setIfUnset() { + continuation.resume(returning: ("", -1)) + } + return + } + + Task.detached { [process] in + try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000) + if process.isRunning { + process.terminate() + } + } + } + } +} + +/// Tiny one-shot latch used to guard `continuation.resume` against double +/// firing when both the termination handler and the timeout race. +private final class ManagedAtomicFlag: @unchecked Sendable { + private let lock = NSLock() + private var flag = false + + func setIfUnset() -> Bool { + lock.lock(); defer { lock.unlock() } + if flag { return false } + flag = true + return true + } +} diff --git a/RxCode/Services/ThreadSearchService.swift b/RxCode/Services/ThreadSearchService.swift new file mode 100644 index 00000000..2379ba68 --- /dev/null +++ b/RxCode/Services/ThreadSearchService.swift @@ -0,0 +1,397 @@ +import Foundation +import NaturalLanguage +import RxCodeCore +import os + +/// On-device semantic search across past chat threads. Embeds thread content +/// when the thread finalizes and stores L2-normalised vectors in SwiftData via +/// `ThreadStore`. Search runs in-memory using cosine similarity (which reduces +/// to a dot product on normalised vectors), then collapses to one hit per +/// thread (max-scoring chunk wins) and groups by project. +actor ThreadSearchService { + + // MARK: - Public types + + struct Hit: Sendable, Equatable { + let threadId: String + let projectId: UUID + let score: Float + let snippet: String + let chunkIndex: Int + } + + struct Group: Sendable, Equatable { + let projectId: UUID + let hits: [Hit] + } + + /// Literal substring match inside the currently-open thread. Distinct from + /// `Hit` because we surface the matched message rather than a cosine-ranked + /// chunk, and we keep the surrounding text window so the overlay can + /// render a highlight. + struct InThreadHit: Sendable, Equatable, Identifiable { + let messageId: UUID + let role: Role + let snippet: String + /// Range of the matched substring within `snippet`. + let matchRange: Range + let timestamp: Date + var id: UUID { messageId } + } + + // MARK: - Stored types + + private struct Chunk { + let index: Int + let projectId: UUID + let text: String + let vector: [Float] + } + + // MARK: - State + + private let logger = Logger(subsystem: "com.claudework", category: "ThreadSearchService") + private var index: [String: [Chunk]] = [:] + private var embedder: NLEmbedding? + private var wordEmbedder: NLEmbedding? + private var threadStore: ThreadStore? + private var didStart = false + + /// Maximum characters per chunk. Sentence-embedding accepts longer input + /// but quality degrades for very long passages. + private let chunkSize = 480 + /// Backfill version sentinel. Bump when chunking or embedding model changes. + private let backfillVersion = 1 + private let backfillKey = "com.idealapp.RxCode.searchIndex.backfillVersion" + + init() {} + + // MARK: - Lifecycle + + /// Load any existing chunk rows into memory. Cheap: ~2KB per chunk. + func start(threadStore: ThreadStore) async { + guard !didStart else { return } + didStart = true + self.threadStore = threadStore + embedder = NLEmbedding.sentenceEmbedding(for: .english) + wordEmbedder = NLEmbedding.wordEmbedding(for: .english) + if embedder == nil && wordEmbedder == nil { + logger.error("Both sentence and word embeddings unavailable; search disabled") + return + } + let rows = await MainActor.run { threadStore.loadAllEmbeddingChunks() } + for row in rows { + let chunk = Chunk( + index: row.chunkIndex, + projectId: row.projectId, + text: row.text, + vector: row.floatVector() + ) + index[row.threadId, default: []].append(chunk) + } + for key in index.keys { + index[key]?.sort { $0.index < $1.index } + } + logger.info("Loaded \(rows.count) embedding chunks across \(self.index.count) threads") + } + + // MARK: - Indexing + + /// Index a thread's content. Replaces any prior chunks for that thread. + /// Safe to call multiple times — re-indexing is idempotent. + func indexThread(_ session: ChatSession) async { + guard let store = threadStore else { return } + // Skip threads with no real content (placeholder rows, empty new chats). + let texts = chunkTexts(for: session) + guard !texts.isEmpty else { + await MainActor.run { store.deleteEmbeddingChunks(threadId: session.id) } + index.removeValue(forKey: session.id) + return + } + + var newChunks: [Chunk] = [] + var rows: [ThreadEmbeddingChunk] = [] + for (i, text) in texts.enumerated() { + guard let vec = embed(text) else { continue } + newChunks.append(Chunk(index: i, projectId: session.projectId, text: text, vector: vec)) + rows.append(ThreadEmbeddingChunk( + id: ThreadEmbeddingChunk.makeId(threadId: session.id, index: i), + threadId: session.id, + projectId: session.projectId, + chunkIndex: i, + text: text, + vector: ThreadEmbeddingChunk.encode(vec), + dim: vec.count, + updatedAt: .now + )) + } + + index[session.id] = newChunks + let threadId = session.id + let snapshot = rows + await MainActor.run { + store.replaceEmbeddingChunks(threadId: threadId, chunks: snapshot) + } + logger.debug("Indexed thread \(session.id, privacy: .public): \(newChunks.count) chunks") + } + + func removeThread(id: String) async { + index.removeValue(forKey: id) + guard let store = threadStore else { return } + await MainActor.run { store.deleteEmbeddingChunks(threadId: id) } + } + + func removeProject(id: UUID) async { + for (threadId, chunks) in index where chunks.first?.projectId == id { + index.removeValue(forKey: threadId) + } + // ThreadStore.deleteAll(projectId:) already purges chunks at the same + // time it removes the threads, so the disk side is handled there. + } + + // MARK: - Search + + /// Return up to `limit` thread hits, grouped by project, sorted by descending score. + func search(_ query: String, limit: Int = 50) async -> [Group] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let qvec = embed(trimmed) else { return [] } + + struct Best { var score: Float; var chunk: Chunk } + var bestByThread: [String: Best] = [:] + + for (threadId, chunks) in index { + for chunk in chunks { + let score = dot(qvec, chunk.vector) + if let prior = bestByThread[threadId] { + if score > prior.score { + bestByThread[threadId] = Best(score: score, chunk: chunk) + } + } else { + bestByThread[threadId] = Best(score: score, chunk: chunk) + } + } + } + + let ranked = bestByThread + .map { (threadId, best) in + Hit( + threadId: threadId, + projectId: best.chunk.projectId, + score: best.score, + snippet: best.chunk.text, + chunkIndex: best.chunk.index + ) + } + .sorted { $0.score > $1.score } + .prefix(limit) + + // Group by project, preserving rank order within each group and + // ordering groups by their best score. + var groups: [UUID: [Hit]] = [:] + var groupOrder: [UUID] = [] + for hit in ranked { + if groups[hit.projectId] == nil { groupOrder.append(hit.projectId) } + groups[hit.projectId, default: []].append(hit) + } + return groupOrder.map { Group(projectId: $0, hits: groups[$0] ?? []) } + } + + // MARK: - Backfill + + /// Index every thread that does not have any cached chunks yet. Honoured + /// once per `backfillVersion` (bump the constant if the embedding/chunking + /// strategy changes and old vectors should be discarded). + func backfillIfNeeded( + loadAll: @MainActor @Sendable () -> [ChatSession.Summary], + loadFull: @MainActor @Sendable (ChatSession.Summary) async -> ChatSession? + ) async { + guard threadStore != nil else { return } + let stored = UserDefaults.standard.integer(forKey: backfillKey) + guard stored < backfillVersion else { return } + + let summaries = await MainActor.run { loadAll() } + var done = 0 + for summary in summaries { + if !index[summary.id, default: []].isEmpty { continue } + guard let full = await loadFull(summary) else { continue } + await indexThread(full) + done += 1 + // Yield occasionally so we don't starve the cooperative pool. + if done % 8 == 0 { await Task.yield() } + } + UserDefaults.standard.set(backfillVersion, forKey: backfillKey) + logger.info("Search backfill complete: indexed \(done) threads") + } + + // MARK: - Chunking + + /// Build the list of plain-text chunks to embed for a session. The first + /// chunk is always the title so a title-only match still scores well; the + /// rest are message text packed into ~`chunkSize`-character windows on + /// message boundaries. Tool-call blocks are skipped — they're noisy and + /// don't reflect the user's intent. + private func chunkTexts(for session: ChatSession) -> [String] { + var chunks: [String] = [] + let trimmedTitle = session.title.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedTitle.isEmpty, trimmedTitle != ChatSession.defaultTitle { + chunks.append(trimmedTitle) + } + + var buffer = "" + for message in session.messages { + let raw = message.blocks.compactMap(\.text).joined(separator: " ") + guard !raw.isEmpty else { continue } + let cleaned = ChatSession.stripAttachmentMarkers(from: raw) + guard !cleaned.isEmpty else { continue } + + let prefix = (message.role == .user) ? "User: " : "Assistant: " + let piece = prefix + cleaned + + if buffer.isEmpty { + buffer = piece + } else if buffer.count + piece.count + 1 <= chunkSize { + buffer += "\n" + piece + } else { + chunks.append(buffer) + buffer = piece + } + + // Hard-split overlong individual messages so we don't blow past the + // window by an entire long assistant reply. + while buffer.count > chunkSize { + let cutIndex = buffer.index(buffer.startIndex, offsetBy: chunkSize) + chunks.append(String(buffer[.. [Float]? { + if let embedder, let vec = embedder.vector(for: text) { + return normalise(vec.map { Float($0) }) + } + // Word-level fallback: average word vectors. Skip terms with no vector. + guard let wordEmbedder else { return nil } + let tokens = tokenize(text) + var sum: [Float] = Array(repeating: 0, count: wordEmbedder.dimension) + var count = 0 + for token in tokens { + guard let v = wordEmbedder.vector(for: token) else { continue } + for (i, x) in v.enumerated() where i < sum.count { + sum[i] += Float(x) + } + count += 1 + } + guard count > 0 else { return nil } + let inv = 1.0 / Float(count) + for i in 0.. [String] { + let lower = text.lowercased() + let tokenizer = NLTokenizer(unit: .word) + tokenizer.string = lower + var tokens: [String] = [] + tokenizer.enumerateTokens(in: lower.startIndex.. [Float] { + var sum: Float = 0 + for x in vec { sum += x * x } + let n = sum.squareRoot() + guard n > 0 else { return vec } + let inv = 1.0 / n + return vec.map { $0 * inv } + } + + private func dot(_ a: [Float], _ b: [Float]) -> Float { + let count = min(a.count, b.count) + var s: Float = 0 + for i in 0.. [InThreadHit] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + var hits: [InThreadHit] = [] + for message in messages { + let raw = message.blocks.compactMap(\.text).joined(separator: " ") + guard !raw.isEmpty else { continue } + let cleaned = ChatSession.stripAttachmentMarkers(from: raw) + guard !cleaned.isEmpty else { continue } + + guard let match = cleaned.range(of: trimmed, options: .caseInsensitive) else { continue } + + let (snippet, snippetRange) = makeSnippet(text: cleaned, match: match, radius: snippetRadius) + hits.append(InThreadHit( + messageId: message.id, + role: message.role, + snippet: snippet, + matchRange: snippetRange, + timestamp: message.timestamp + )) + if hits.count >= limit { break } + } + return hits + } + + /// Build a `±radius` character window around `match` and return the snippet + /// along with the matched range translated into the snippet's index space. + /// Adds an ellipsis when either edge of the source string was clipped. + private static func makeSnippet( + text: String, + match: Range, + radius: Int + ) -> (String, Range) { + let lowerOffset = text.distance(from: text.startIndex, to: match.lowerBound) + let upperOffset = text.distance(from: text.startIndex, to: match.upperBound) + let total = text.count + + let startOffset = max(0, lowerOffset - radius) + let endOffset = min(total, upperOffset + radius) + let startIndex = text.index(text.startIndex, offsetBy: startOffset) + let endIndex = text.index(text.startIndex, offsetBy: endOffset) + + var snippet = String(text[startIndex.. 0 { + let prefix = "… " + snippet = prefix + snippet + matchLowerInSnippet += prefix.count + matchUpperInSnippet += prefix.count + } + if endOffset < total { + snippet += " …" + } + + let lower = snippet.index(snippet.startIndex, offsetBy: matchLowerInSnippet) + let upper = snippet.index(snippet.startIndex, offsetBy: matchUpperInSnippet) + return (snippet, lower.. ThreadStore { - let schema = Schema([ChatThread.self, TodoSnapshot.self, ThreadFileEdit.self, QueuedMessageRecord.self, PlanDecisionRecord.self]) + let schema = Schema([ + ChatThread.self, + TodoSnapshot.self, + ThreadFileEdit.self, + QueuedMessageRecord.self, + PlanDecisionRecord.self, + ThreadSummaryRecord.self, + BranchBriefingRecord.self, + ThreadEmbeddingChunk.self + ]) let url = Self.storeURL() let config = ModelConfiguration(schema: schema, url: url) do { @@ -65,6 +74,37 @@ final class ThreadStore { fetch(id: id)?.cliSessionId } + func fetchThreadSummary(sessionId: String) -> ThreadSummaryRecord? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.sessionId == sessionId } + ) + descriptor.fetchLimit = 1 + return (try? context.fetch(descriptor))?.first + } + + func threadSummaryItem(sessionId: String) -> ThreadSummaryItem? { + fetchThreadSummary(sessionId: sessionId)?.toItem() + } + + func threadSummaryItems(projectId: UUID, branch: String) -> [ThreadSummaryItem] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.projectId == projectId && $0.branch == branch }, + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + ) + return ((try? context.fetch(descriptor)) ?? []).map { $0.toItem() } + } + + func branchBriefingItem(projectId: UUID, branch: String) -> BranchBriefingItem? { + fetchBranchBriefing(projectId: projectId, branch: branch)?.toItem() + } + + func allBranchBriefingItems() -> [BranchBriefingItem] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + ) + return ((try? context.fetch(descriptor)) ?? []).map { $0.toItem() } + } + // MARK: - Writes /// Insert or update a thread row from a summary. @@ -78,6 +118,69 @@ final class ThreadStore { save() } + func upsertThreadSummary( + sessionId: String, + projectId: UUID, + branch: String, + title: String, + summary: String, + updatedAt: Date = .now + ) { + if let existing = fetchThreadSummary(sessionId: sessionId) { + existing.apply(projectId: projectId, branch: branch, title: title, summary: summary, updatedAt: updatedAt) + } else { + context.insert(ThreadSummaryRecord( + sessionId: sessionId, + projectId: projectId, + branch: branch, + title: title, + summary: summary, + updatedAt: updatedAt + )) + } + save() + } + + func upsertBranchBriefing(projectId: UUID, branch: String, briefing: String, updatedAt: Date = .now) { + if let existing = fetchBranchBriefing(projectId: projectId, branch: branch) { + existing.apply(briefing: briefing, updatedAt: updatedAt) + } else { + context.insert(BranchBriefingRecord( + projectId: projectId, + branch: branch, + briefing: briefing, + updatedAt: updatedAt, + lastSeenAt: updatedAt + )) + } + save() + } + + /// Mark a branch's briefing as recently observed, resetting the TTL used by + /// `purgeStaleBranchBriefings`. No-op if no record exists. + func touchBranchBriefing(projectId: UUID, branch: String, at date: Date = .now) { + guard let row = fetchBranchBriefing(projectId: projectId, branch: branch) else { return } + row.touch(at: date) + save() + } + + /// Delete branch briefings whose `lastSeenAt` is older than `cutoff` — i.e. + /// branches that haven't been observed for the retention window and have + /// presumably been deleted both locally and remotely. Returns the deleted + /// record ids. + @discardableResult + func purgeStaleBranchBriefings(olderThan cutoff: Date) -> [String] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.lastSeenAt < cutoff } + ) + let rows = (try? context.fetch(descriptor)) ?? [] + guard !rows.isEmpty else { return [] } + let ids = rows.map(\.id) + for row in rows { context.delete(row) } + save() + return ids + } + /// Set `cliSessionId` on the thread row (used when the CLI assigns a session id mid-stream). func setCliSessionId(localId: String, cliSessionId: String) { guard let row = fetch(id: localId) else { return } @@ -106,6 +209,7 @@ final class ThreadStore { renameFileEdits(from: oldId, to: newId) renameQueueKey(from: oldId, to: newId) renamePlanDecisions(from: oldId, to: newId) + renameThreadSummary(from: oldId, to: newId) save() } @@ -145,6 +249,8 @@ final class ThreadStore { deleteFileEditRows(sessionId: id) deleteQueueRows(sessionKey: id) deletePlanDecisionRows(sessionId: id) + deleteThreadSummaryRow(sessionId: id) + deleteEmbeddingChunkRows(threadId: id) save() } @@ -164,6 +270,8 @@ final class ThreadStore { deleteFileEditRows(sessionId: id) deleteQueueRows(sessionKey: id) deletePlanDecisionRows(sessionId: id) + deleteThreadSummaryRow(sessionId: id) + deleteEmbeddingChunkRows(threadId: id) } if projectId == nil { let allTodos = (try? context.fetch(FetchDescriptor())) ?? [] @@ -174,6 +282,23 @@ final class ThreadStore { for row in allQueues { context.delete(row) } let allPlans = (try? context.fetch(FetchDescriptor())) ?? [] for row in allPlans { context.delete(row) } + let allSummaries = (try? context.fetch(FetchDescriptor())) ?? [] + for row in allSummaries { context.delete(row) } + let allBriefings = (try? context.fetch(FetchDescriptor())) ?? [] + for row in allBriefings { context.delete(row) } + let allChunks = (try? context.fetch(FetchDescriptor())) ?? [] + for row in allChunks { context.delete(row) } + } else if let projectId { + let briefingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.projectId == projectId } + ) + let briefings = (try? context.fetch(briefingDescriptor)) ?? [] + for row in briefings { context.delete(row) } + let chunkDescriptor = FetchDescriptor( + predicate: #Predicate { $0.projectId == projectId } + ) + let chunks = (try? context.fetch(chunkDescriptor)) ?? [] + for row in chunks { context.delete(row) } } save() } catch { @@ -181,6 +306,32 @@ final class ThreadStore { } } + // MARK: - Thread Summaries + + private func renameThreadSummary(from oldId: String, to newId: String) { + guard oldId != newId else { return } + guard let row = fetchThreadSummary(sessionId: oldId) else { return } + if fetchThreadSummary(sessionId: newId) != nil { + context.delete(row) + } else { + row.sessionId = newId + } + } + + private func deleteThreadSummaryRow(sessionId: String) { + guard let row = fetchThreadSummary(sessionId: sessionId) else { return } + context.delete(row) + } + + private func fetchBranchBriefing(projectId: UUID, branch: String) -> BranchBriefingRecord? { + let id = BranchBriefingRecord.makeId(projectId: projectId, branch: branch) + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + return (try? context.fetch(descriptor))?.first + } + // MARK: - Todo Snapshots func fetchTodoSnapshot(sessionId: String) -> TodoSnapshot? { @@ -426,6 +577,44 @@ final class ThreadStore { for row in rows { context.delete(row) } } + // MARK: - Thread Embedding Chunks + + func loadAllEmbeddingChunks() -> [ThreadEmbeddingChunk] { + let descriptor = FetchDescriptor() + return (try? context.fetch(descriptor)) ?? [] + } + + func loadEmbeddingChunks(threadId: String) -> [ThreadEmbeddingChunk] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.threadId == threadId }, + sortBy: [SortDescriptor(\.chunkIndex, order: .forward)] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + /// Replace all chunks for a thread atomically. Old rows are deleted first + /// so re-indexing cannot leave orphans behind. + func replaceEmbeddingChunks(threadId: String, chunks: [ThreadEmbeddingChunk]) { + deleteEmbeddingChunkRows(threadId: threadId) + for chunk in chunks { + context.insert(chunk) + } + save() + } + + func deleteEmbeddingChunks(threadId: String) { + deleteEmbeddingChunkRows(threadId: threadId) + save() + } + + private func deleteEmbeddingChunkRows(threadId: String) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.threadId == threadId } + ) + let rows = (try? context.fetch(descriptor)) ?? [] + for row in rows { context.delete(row) } + } + private func save() { guard context.hasChanges else { return } do { try context.save() } diff --git a/RxCode/Views/Chat/RecentChatsSuggestionList.swift b/RxCode/Views/Chat/RecentChatsSuggestionList.swift index 3d0e616a..4acccbcf 100644 --- a/RxCode/Views/Chat/RecentChatsSuggestionList.swift +++ b/RxCode/Views/Chat/RecentChatsSuggestionList.swift @@ -1,10 +1,12 @@ import SwiftUI import RxCodeCore +import RxCodeChatKit /// Shown below the input box on empty state so the user can quickly resume a recent chat. struct RecentChatsSuggestionList: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState + @Environment(ChatBridge.self) private var chatBridge @State private var renamingSession: ChatSession? @State private var renameText = "" @@ -134,19 +136,30 @@ struct RecentChatsSuggestionList: View { private var shouldShow: Bool { guard windowState.selectedProject != nil else { return false } guard windowState.currentSessionId == nil else { return false } + // Mirror ChatView.isEmptyState: avoid showing the suggestion list under + // an active conversation when messages briefly outlive the session id + // (e.g. just after a streaming session ends and currentSessionId resets). + guard chatBridge.messages.isEmpty, !chatBridge.isStreaming else { return false } return !suggestions.isEmpty } private var suggestions: [ChatSession.Summary] { guard let projectId = windowState.selectedProject?.id else { return [] } - return appState.allSessionSummaries + let scoped = appState.allSessionSummaries .filter { $0.projectId == projectId && !$0.isArchived } - .sorted { a, b in - if a.isPinned != b.isPinned { return a.isPinned } - return a.updatedAt > b.updatedAt - } - .prefix(5) - .map { $0 } + let pinned = scoped + .filter { $0.isPinned } + .sorted { $0.updatedAt > $1.updatedAt } + .prefix(6) + // Always surface at least 4 unpinned suggestions; when pinned fill + // fewer than 6 slots, expand the unpinned section to keep the total + // at 6. + let unpinnedLimit = max(4, 6 - pinned.count) + let unpinned = scoped + .filter { !$0.isPinned } + .sorted { $0.updatedAt > $1.updatedAt } + .prefix(unpinnedLimit) + return Array(pinned) + Array(unpinned) } private var isDeletingSessionBinding: Binding { diff --git a/RxCode/Views/EmptyProjectStateView.swift b/RxCode/Views/EmptyProjectStateView.swift new file mode 100644 index 00000000..2c5d1967 --- /dev/null +++ b/RxCode/Views/EmptyProjectStateView.swift @@ -0,0 +1,152 @@ +import RxCodeCore +import SwiftUI + +struct EmptyProjectStateView: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + private var briefings: [BranchBriefingItem] { + _ = appState.branchBriefingRevision + return appState.threadStore.allBranchBriefingItems() + } + + private var projectsById: [UUID: Project] { + Dictionary(uniqueKeysWithValues: appState.projects.map { ($0.id, $0) }) + } + + var body: some View { + ScrollView { + VStack(spacing: 24) { + VStack(spacing: 16) { + Image(systemName: "sparkle") + .font(.system(size: ClaudeTheme.size(48))) + .foregroundStyle(ClaudeTheme.accent) + + Text("Select a Project") + .font(.title2.weight(.semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + + Text("Select a project from the sidebar or add a new one.") + .font(.subheadline) + .foregroundStyle(ClaudeTheme.textSecondary) + } + .padding(.top, 60) + + let items = briefings.compactMap { item -> (BranchBriefingItem, Project)? in + guard let project = projectsById[item.projectId] else { return nil } + return (item, project) + } + + if !items.isEmpty { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + Text("Recent Briefings") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + .tracking(0.6) + } + .padding(.horizontal, 4) + + VStack(spacing: 10) { + ForEach(items, id: \.0.id) { item, project in + briefingBanner(item: item, project: project) + } + } + } + .frame(maxWidth: 720) + .padding(.horizontal, 28) + .padding(.bottom, 40) + } + } + .frame(maxWidth: .infinity) + } + .background(ClaudeTheme.background) + } + + private func briefingBanner(item: BranchBriefingItem, project: Project) -> some View { + Button { + appState.selectProject(project, in: windowState) + windowState.showingBriefing = true + } label: { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "folder.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + Text(project.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9, weight: .semibold)) + Text(item.branch) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + } + .foregroundStyle(ClaudeTheme.accent) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + Capsule(style: .continuous) + .fill(ClaudeTheme.accent.opacity(0.12)) + ) + + Spacer(minLength: 8) + + Text(Self.compactDate(item.updatedAt)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + + Text(Self.previewText(item.briefing)) + .font(.system(size: 12.5)) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineSpacing(3) + .lineLimit(3) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium, style: .continuous) + .fill(ClaudeTheme.surfacePrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium, style: .continuous) + .strokeBorder(ClaudeTheme.border.opacity(0.55), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .pointerCursorOnHover() + } + + private static func compactDate(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: .now) + } + + private static func previewText(_ markdown: String) -> String { + markdown + .components(separatedBy: "\n") + .map { line -> String in + var s = line.trimmingCharacters(in: .whitespaces) + while s.hasPrefix("#") { s.removeFirst() } + if s.hasPrefix("- ") || s.hasPrefix("* ") || s.hasPrefix("+ ") { + s.removeFirst(2) + } + return s.trimmingCharacters(in: .whitespaces) + } + .filter { !$0.isEmpty } + .joined(separator: " · ") + } +} diff --git a/RxCode/Views/Inspector/InspectorContentView.swift b/RxCode/Views/Inspector/InspectorContentView.swift index c7a503c8..9a21b4e6 100644 --- a/RxCode/Views/Inspector/InspectorContentView.swift +++ b/RxCode/Views/Inspector/InspectorContentView.swift @@ -10,24 +10,71 @@ import RxCodeCore struct InspectorContentView: View { @Environment(WindowState.self) private var windowState - @Binding var inspectorProcess: TerminalProcess - let terminalResetID: UUID + let terminalsBySession: [String: [InspectorTerminal]] + let currentSessionKey: String + let activeTerminalId: UUID? let memoClearID: UUID? let terminalFocusID: UUID? let memoFocusID: UUID? + let onSelectTerminal: (UUID) -> Void + let onCloseTerminal: (UUID) -> Void + let onAddTerminal: () -> Void + let onRenameTerminal: (UUID, String) -> Void + + private struct FlatEntry: Identifiable { + let sessionKey: String + let terminalId: UUID + let process: TerminalProcess + let resetID: UUID + var id: String { "\(sessionKey)|\(terminalId.uuidString)" } + } + + /// Flattened list of every terminal across every session so they all stay + /// mounted (and their shells keep running) regardless of the active thread. + private var allEntries: [FlatEntry] { + terminalsBySession + .sorted { $0.key < $1.key } + .flatMap { sessionKey, terminals in + terminals.map { t in + FlatEntry( + sessionKey: sessionKey, + terminalId: t.id, + process: t.process, + resetID: t.resetID + ) + } + } + } + + private var currentTerminals: [InspectorTerminal] { + terminalsBySession[currentSessionKey] ?? [] + } var body: some View { VStack(spacing: 0) { - EmbeddedTerminalView( - executable: "/bin/zsh", - arguments: ["-il"], - currentDirectory: windowState.selectedProject?.path, - process: inspectorProcess, - focusTrigger: terminalFocusID - ) - .id(terminalResetID) - .padding(8) - .background(ClaudeTheme.codeBackground) + VStack(spacing: 0) { + if currentTerminals.count > 1 || !currentTerminals.isEmpty { + terminalTabBar + } + ZStack { + ForEach(allEntries) { entry in + let isActive = entry.sessionKey == currentSessionKey + && entry.terminalId == activeTerminalId + EmbeddedTerminalView( + executable: "/bin/zsh", + arguments: ["-il"], + currentDirectory: windowState.selectedProject?.path, + process: entry.process, + focusTrigger: isActive ? terminalFocusID : nil + ) + .id(entry.resetID) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + } + } + .padding(8) + .background(ClaudeTheme.codeBackground) + } .frame(maxHeight: windowState.inspectorTab == .terminal ? .infinity : 0) .clipped() @@ -42,6 +89,129 @@ struct InspectorContentView: View { .clipped() } } + + @ViewBuilder + private var terminalTabBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(Array(currentTerminals.enumerated()), id: \.element.id) { index, terminal in + TerminalTabChip( + title: terminal.customTitle ?? "Terminal \(index + 1)", + isActive: terminal.id == activeTerminalId, + canClose: currentTerminals.count > 1, + onSelect: { onSelectTerminal(terminal.id) }, + onClose: { onCloseTerminal(terminal.id) }, + onRename: { newTitle in onRenameTerminal(terminal.id, newTitle) } + ) + } + Button(action: onAddTerminal) { + Image(systemName: "plus") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + .frame(width: 22, height: 22) + .background( + ClaudeTheme.surfaceSecondary.opacity(0.5), + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + .help("New Terminal (⌘T)") + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .background(ClaudeTheme.surfaceElevated) + .overlay(alignment: .bottom) { + Rectangle() + .fill(ClaudeTheme.borderSubtle.opacity(0.5)) + .frame(height: 0.5) + } + } +} + +// MARK: - TerminalTabChip + +private struct TerminalTabChip: View { + let title: String + let isActive: Bool + let canClose: Bool + let onSelect: () -> Void + let onClose: () -> Void + let onRename: (String) -> Void + + @State private var isHovering = false + @State private var isEditing = false + @State private var draft: String = "" + @FocusState private var fieldFocused: Bool + + private func commit() { + onRename(draft) + isEditing = false + } + + private func cancel() { + isEditing = false + } + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "apple.terminal") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(isActive ? ClaudeTheme.textOnAccent : ClaudeTheme.textTertiary) + if isEditing { + TextField("", text: $draft) + .textFieldStyle(.plain) + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(isActive ? ClaudeTheme.textOnAccent : ClaudeTheme.textPrimary) + .frame(minWidth: 60, idealWidth: 90) + .focused($fieldFocused) + .onSubmit(commit) + .onExitCommand(perform: cancel) + .onChange(of: fieldFocused) { _, focused in + if !focused { commit() } + } + } else { + Text(title) + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(isActive ? ClaudeTheme.textOnAccent : ClaudeTheme.textSecondary) + .lineLimit(1) + } + if canClose, !isEditing, isHovering || isActive { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: ClaudeTheme.size(9), weight: .bold)) + .foregroundStyle(isActive ? ClaudeTheme.textOnAccent.opacity(0.8) : ClaudeTheme.textTertiary) + .frame(width: 14, height: 14) + } + .buttonStyle(.plain) + .help("Close Terminal") + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isActive ? ClaudeTheme.accent : (isHovering ? ClaudeTheme.surfaceSecondary : Color.clear)) + ) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + draft = title + isEditing = true + DispatchQueue.main.async { fieldFocused = true } + } + .onTapGesture { if !isEditing { onSelect() } } + .contextMenu { + Button("Rename…") { + draft = title + isEditing = true + DispatchQueue.main.async { fieldFocused = true } + } + if canClose { + Button("Close Terminal", role: .destructive, action: onClose) + } + } + .onHover { isHovering = $0 } + } } // MARK: - InspectorIconButton diff --git a/RxCode/Views/Inspector/RightInspectorPanel.swift b/RxCode/Views/Inspector/RightInspectorPanel.swift index 63f35e50..a99807fd 100644 --- a/RxCode/Views/Inspector/RightInspectorPanel.swift +++ b/RxCode/Views/Inspector/RightInspectorPanel.swift @@ -6,17 +6,43 @@ import RxCodeCore /// Right-side panel with two modes: /// - Review: This thread / Unstaged / Staged / Branch tabs. /// - Inspector: Memo / Terminal tabs. +/// Per-terminal instance state. A thread can have multiple of these. +struct InspectorTerminal: Identifiable { + let id: UUID + let process: TerminalProcess + var resetID: UUID + /// User-set title. When nil, the UI falls back to "Terminal N". + var customTitle: String? +} + struct RightInspectorPanel: View { @Environment(WindowState.self) private var windowState - // Inspector-mode state lives here so reset/clear buttons in the header - // can drive the embedded views. - @State private var inspectorProcess = TerminalProcess() - @State private var terminalResetID = UUID() + // Per-thread terminal storage. Each session/thread can have multiple + // terminals; all stay alive across thread switches. + @State private var terminalsBySession: [String: [InspectorTerminal]] = [:] + @State private var activeTerminalIdBySession: [String: UUID] = [:] @State private var memoClearID: UUID? = nil @State private var terminalFocusID: UUID? = nil @State private var memoFocusID: UUID? = nil + private var currentSessionKey: String { + windowState.currentSessionId ?? windowState.newSessionKey + } + + private var currentTerminals: [InspectorTerminal] { + terminalsBySession[currentSessionKey] ?? [] + } + + private var activeTerminalId: UUID? { + activeTerminalIdBySession[currentSessionKey] + } + + private var activeTerminal: InspectorTerminal? { + guard let id = activeTerminalId else { return nil } + return currentTerminals.first { $0.id == id } + } + private func bumpFocus(for tab: InspectorTab) { switch tab { case .terminal: terminalFocusID = UUID() @@ -25,6 +51,75 @@ struct RightInspectorPanel: View { } } + private func ensureTerminal(for key: String) { + if (terminalsBySession[key]?.isEmpty ?? true) { + let t = InspectorTerminal(id: UUID(), process: TerminalProcess(), resetID: UUID()) + terminalsBySession[key] = [t] + activeTerminalIdBySession[key] = t.id + } else if activeTerminalIdBySession[key] == nil, + let first = terminalsBySession[key]?.first { + activeTerminalIdBySession[key] = first.id + } + } + + private func addTerminalToCurrent() { + let key = currentSessionKey + let t = InspectorTerminal(id: UUID(), process: TerminalProcess(), resetID: UUID()) + terminalsBySession[key, default: []].append(t) + activeTerminalIdBySession[key] = t.id + terminalFocusID = UUID() + } + + private func selectTerminal(_ id: UUID) { + activeTerminalIdBySession[currentSessionKey] = id + terminalFocusID = UUID() + } + + private func closeTerminal(_ id: UUID) { + let key = currentSessionKey + guard var list = terminalsBySession[key], + let idx = list.firstIndex(where: { $0.id == id }) else { return } + list[idx].process.terminate() + list.remove(at: idx) + if list.isEmpty { + let t = InspectorTerminal(id: UUID(), process: TerminalProcess(), resetID: UUID()) + list.append(t) + activeTerminalIdBySession[key] = t.id + } else if activeTerminalIdBySession[key] == id { + let newActive = list[max(0, idx - 1)] + activeTerminalIdBySession[key] = newActive.id + } + terminalsBySession[key] = list + } + + private func resetActiveTerminal() { + let key = currentSessionKey + guard var list = terminalsBySession[key], + let id = activeTerminalIdBySession[key], + let idx = list.firstIndex(where: { $0.id == id }) else { return } + list[idx].process.terminate() + list[idx] = InspectorTerminal( + id: list[idx].id, + process: TerminalProcess(), + resetID: UUID(), + customTitle: list[idx].customTitle + ) + terminalsBySession[key] = list + } + + private func clearActiveTerminal() { + activeTerminal?.process.clear() + } + + private func renameTerminal(_ id: UUID, title: String) { + let key = currentSessionKey + guard var list = terminalsBySession[key], + let idx = list.firstIndex(where: { $0.id == id }) else { return } + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + list[idx].customTitle = trimmed.isEmpty ? nil : trimmed + terminalsBySession[key] = list + } + var body: some View { VStack(spacing: 0) { header @@ -35,11 +130,16 @@ struct RightInspectorPanel: View { reviewContent case .inspector: InspectorContentView( - inspectorProcess: $inspectorProcess, - terminalResetID: terminalResetID, + terminalsBySession: terminalsBySession, + currentSessionKey: currentSessionKey, + activeTerminalId: activeTerminalId, memoClearID: memoClearID, terminalFocusID: terminalFocusID, - memoFocusID: memoFocusID + memoFocusID: memoFocusID, + onSelectTerminal: selectTerminal, + onCloseTerminal: closeTerminal, + onAddTerminal: addTerminalToCurrent, + onRenameTerminal: renameTerminal ) } } @@ -57,6 +157,14 @@ struct RightInspectorPanel: View { ) .opacity(windowState.showInspector ? 1 : 0) .clipped() + .background(terminalShortcuts) + .task(id: currentSessionKey) { + ensureTerminal(for: currentSessionKey) + // Always open the terminal for the current thread. + windowState.inspectorMode = .inspector + windowState.inspectorTab = .terminal + windowState.showInspector = true + } .onChange(of: windowState.inspectorTab) { _, newTab in if windowState.inspectorMode == .inspector { bumpFocus(for: newTab) } } @@ -72,6 +180,25 @@ struct RightInspectorPanel: View { } } + /// Hidden buttons that register keyboard shortcuts (Cmd+K clear, Cmd+T new) + /// scoped to when the inspector is showing the terminal tab. + @ViewBuilder + private var terminalShortcuts: some View { + if windowState.showInspector, + windowState.inspectorMode == .inspector, + windowState.inspectorTab == .terminal { + ZStack { + Button("Clear Terminal", action: clearActiveTerminal) + .keyboardShortcut("k", modifiers: .command) + Button("New Terminal", action: addTerminalToCurrent) + .keyboardShortcut("t", modifiers: .command) + } + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + } + // MARK: - Header @ViewBuilder @@ -93,10 +220,14 @@ struct RightInspectorPanel: View { if windowState.inspectorMode == .inspector { if windowState.inspectorTab == .terminal { + HeaderIconButton(systemImage: "plus", help: "New Terminal (⌘T)") { + addTerminalToCurrent() + } + HeaderIconButton(systemImage: "eraser", help: "Clear Terminal (⌘K)") { + clearActiveTerminal() + } HeaderIconButton(systemImage: "arrow.counterclockwise", help: "Reset Terminal") { - inspectorProcess.terminate() - inspectorProcess = TerminalProcess() - terminalResetID = UUID() + resetActiveTerminal() } } else if windowState.inspectorTab == .memo { HeaderIconButton(systemImage: "trash", help: "Clear Memo") { diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index b7ebe43f..9eaa0ee6 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -33,6 +33,9 @@ struct MainView: View { } private var navigationTitleText: String { + if windowState.showingBriefing { + return "Briefing" + } if let id = windowState.currentSessionId, let title = appState.allSessionSummaries.first(where: { $0.id == id })?.title, !title.isEmpty @@ -78,6 +81,32 @@ struct MainView: View { } } } + .overlay { + if windowState.showGlobalSearch { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { + windowState.showGlobalSearch = false + } + } + GlobalSearchOverlay() + .transition(.asymmetric( + insertion: .scale(scale: 0.97).combined(with: .opacity), + removal: .opacity + )) + } + .animation(.spring(response: 0.25, dampingFraction: 0.9), value: windowState.showGlobalSearch) + } + } + .background { + Button("") { + windowState.showGlobalSearch.toggle() + } + .keyboardShortcut("k", modifiers: .command) + .hidden() + } .id(appState.themeRevision) .onChange(of: windowState.showInspector) { _, isShowing in if isShowing, !inspectorStarted { inspectorStarted = true } @@ -124,6 +153,15 @@ struct MainView: View { } } + ToolbarItem(placement: .navigation) { + Button { + windowState.showGlobalSearch = true + } label: { + Image(systemName: "magnifyingglass") + } + .help("Search Threads (⌘K)") + } + ToolbarItem(placement: .navigation) { Text(navigationTitleText) .padding(.trailing) @@ -157,7 +195,9 @@ struct MainView: View { private var detailContent: some View { Group { - if windowState.selectedProject != nil { + if windowState.showingBriefing { + BriefingView() + } else if windowState.selectedProject != nil { VStack(spacing: 0) { ChatView(inputAccessory: { HStack(spacing: 8) { @@ -177,21 +217,7 @@ struct MainView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(ClaudeTheme.background) } else { - VStack(spacing: 16) { - Image(systemName: "sparkle") - .font(.system(size: ClaudeTheme.size(48))) - .foregroundStyle(ClaudeTheme.accent) - - Text("Select a Project") - .font(.title2.weight(.semibold)) - .foregroundStyle(ClaudeTheme.textPrimary) - - Text("Select a project from the sidebar or add a new one.") - .font(.subheadline) - .foregroundStyle(ClaudeTheme.textSecondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(ClaudeTheme.background) + EmptyProjectStateView() } } .sheet(item: Bindable(windowState).inspectorFile) { file in diff --git a/RxCode/Views/ProjectWindowView.swift b/RxCode/Views/ProjectWindowView.swift index dbe6138f..9e08960a 100644 --- a/RxCode/Views/ProjectWindowView.swift +++ b/RxCode/Views/ProjectWindowView.swift @@ -10,6 +10,9 @@ struct ProjectWindowView: View { @State private var columnVisibility: NavigationSplitViewVisibility = .all private var navigationTitleText: String { + if windowState.showingBriefing { + return "Briefing" + } if let id = windowState.currentSessionId, let title = appState.allSessionSummaries.first(where: { $0.id == id })?.title, !title.isEmpty { @@ -121,7 +124,9 @@ struct ProjectWindowView: View { private var detailContent: some View { Group { - if windowState.selectedProject != nil { + if windowState.showingBriefing { + BriefingView() + } else if windowState.selectedProject != nil { VStack(spacing: 0) { chatToolbarArea ClaudeThemeDivider() diff --git a/RxCode/Views/RunProfile/RunConfigurationsView.swift b/RxCode/Views/RunProfile/RunConfigurationsView.swift index dfbe4b17..17b56cdb 100644 --- a/RxCode/Views/RunProfile/RunConfigurationsView.swift +++ b/RxCode/Views/RunProfile/RunConfigurationsView.swift @@ -14,6 +14,7 @@ struct RunConfigurationsView: View { @State private var draft: [RunProfile] = [] @State private var selectedId: UUID? + @State private var detected: DetectedRunnables = .init() private var selectedIndex: Int? { guard let id = selectedId else { return nil } @@ -38,6 +39,9 @@ struct RunConfigurationsView: View { draft = appState.runProfiles(for: project.id) selectedId = windowState.selectedRunProfileId ?? draft.first?.id } + .task { + detected = await RunProfileDetector().detect(in: project.path) + } } // MARK: - Sections @@ -55,12 +59,7 @@ struct RunConfigurationsView: View { private var profileList: some View { VStack(spacing: 0) { HStack(spacing: 4) { - Button { - addProfile() - } label: { - Image(systemName: "plus") - } - .help("Add Profile") + addMenu Button { deleteSelected() } label: { @@ -146,14 +145,67 @@ struct RunConfigurationsView: View { .padding(.vertical, 10) } + // MARK: - Add menu (detected runnables) + + private var addMenu: some View { + Menu { + Button("Empty Bash Configuration") { addProfile() } + + if !detected.xcode.isEmpty { + Divider() + Section("Xcode Schemes") { + ForEach(detected.xcode) { runnable in + Button { + addProfile(from: runnable) + } label: { + Label(runnable.displayName, systemImage: "hammer.fill") + } + } + } + } + + if !detected.npm.isEmpty { + Divider() + Section("npm Scripts") { + ForEach(detected.npm) { runnable in + Button { + addProfile(from: runnable) + } label: { + Label(runnable.displayName, systemImage: "shippingbox.fill") + } + } + } + } + + if !detected.make.isEmpty { + Divider() + Section("Makefile Targets") { + ForEach(detected.make) { runnable in + Button { + addProfile(from: runnable) + } label: { + Label(runnable.displayName, systemImage: "wrench.and.screwdriver.fill") + } + } + } + } + } label: { + Image(systemName: "plus") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Add Profile") + } + // MARK: - Actions - private func addProfile() { + private func addProfile(from runnable: DetectedRunnable? = nil) { let now = Date() let new = RunProfile( projectId: project.id, - name: "New Bash Configuration", - bash: BashRunConfig(), + name: runnable?.displayName ?? "New Bash Configuration", + bash: BashRunConfig(command: runnable?.command ?? ""), createdAt: now, updatedAt: now ) diff --git a/RxCode/Views/Search/GlobalSearchOverlay.swift b/RxCode/Views/Search/GlobalSearchOverlay.swift new file mode 100644 index 00000000..90db1a3a --- /dev/null +++ b/RxCode/Views/Search/GlobalSearchOverlay.swift @@ -0,0 +1,410 @@ +import SwiftUI +import RxCodeCore + +/// Cmd+K Spotlight-style search across every indexed thread on the device. +/// Results are grouped by project; tapping one switches the current window +/// to that project and selects the session. +struct GlobalSearchOverlay: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + @State private var query: String = "" + @State private var groups: [ThreadSearchService.Group] = [] + @State private var inThreadHits: [ThreadSearchService.InThreadHit] = [] + @State private var isSearching: Bool = false + @State private var hasSearched: Bool = false + @FocusState private var inputFocused: Bool + + private var hasResults: Bool { + !groups.isEmpty || !inThreadHits.isEmpty + } + + private var currentThreadTitle: String? { + guard let id = windowState.currentSessionId else { return nil } + return appState.allSessionSummaries.first(where: { $0.id == id })?.title + } + + private let cardWidth: CGFloat = 720 + private let cardHeight: CGFloat = 520 + + var body: some View { + VStack(spacing: 0) { + searchField + Divider().background(ClaudeTheme.borderSubtle) + resultsScroll + footer + } + .frame(width: cardWidth, height: cardHeight) + .background(ClaudeTheme.background) + .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge)) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge) + .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) + ) + .shadow(color: ClaudeTheme.shadowColor, radius: 24, y: 6) + .onAppear { inputFocused = true } + .onKeyPress(.escape) { + close() + return .handled + } + .task(id: query) { + await runSearch() + } + } + + // MARK: - Search field + + private var searchField: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(ClaudeTheme.textSecondary) + .font(.system(size: ClaudeTheme.size(14), weight: .medium)) + + TextField("Search threads by topic, keyword, or feel…", text: $query) + .textFieldStyle(.plain) + .font(.system(size: ClaudeTheme.size(15))) + .foregroundStyle(ClaudeTheme.textPrimary) + .focused($inputFocused) + .onSubmit { Task { await runSearch() } } + + if isSearching { + ProgressView().controlSize(.small) + } + + Text("esc") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ClaudeTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: 4)) + .onTapGesture { close() } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + + // MARK: - Results + + @ViewBuilder + private var resultsScroll: some View { + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + placeholder( + icon: "sparkles", + title: "Search past threads", + subtitle: "Describe what you remember and we'll find it across every project. Indexing runs locally; nothing leaves your Mac." + ) + } else if !hasResults && hasSearched && !isSearching { + placeholder( + icon: "magnifyingglass", + title: "No matches", + subtitle: "Try a different phrasing, or wait — the backfill may still be indexing older threads." + ) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + if !inThreadHits.isEmpty { + currentThreadSection + } + ForEach(groups, id: \.projectId) { group in + projectSection(group) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + } + } + } + + // MARK: - Current-thread section + + private var currentThreadSection: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "text.magnifyingglass") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.accent) + Text(sectionHeader) + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + .textCase(.uppercase) + Text("· \(inThreadHits.count)") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .padding(.horizontal, 8) + .padding(.bottom, 2) + + VStack(spacing: 2) { + ForEach(inThreadHits) { hit in + inThreadRow(hit: hit) + } + } + } + } + + private var sectionHeader: String { + if let title = currentThreadTitle, !title.isEmpty { + return "In this thread · \(title)" + } + return "In this thread" + } + + private func inThreadRow(hit: ThreadSearchService.InThreadHit) -> some View { + Button { + close() + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: hit.role == .user ? "person.crop.circle" : "sparkle") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 3) { + Text(hit.role == .user ? "You" : "Assistant") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + + Text(highlightedSnippet(hit: hit)) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineLimit(3) + } + Spacer(minLength: 8) + Text(hit.timestamp.formatted(date: .omitted, time: .shortened)) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(ClaudeTheme.surfacePrimary) + ) + .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + } + .buttonStyle(.plain) + } + + private func highlightedSnippet(hit: ThreadSearchService.InThreadHit) -> AttributedString { + // Render inline markdown so backticks, **bold**, etc. don't show raw. + // We then re-find the literal match inside the rendered string so the + // highlight stays aligned even when the parser shrinks or drops + // markdown syntax characters. + var attr = Self.inlineMarkdown(hit.snippet) + let literal = String(hit.snippet[hit.matchRange]) + if !literal.isEmpty, + let range = attr.range(of: literal, options: .caseInsensitive) { + attr[range].inlinePresentationIntent = .stronglyEmphasized + attr[range].foregroundColor = ClaudeTheme.accent + } + return attr + } + + private func placeholder(icon: String, title: String, subtitle: String) -> some View { + VStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 28, weight: .light)) + .foregroundStyle(ClaudeTheme.textTertiary) + Text(title) + .font(.system(size: ClaudeTheme.size(14), weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + Text(subtitle) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 60) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func projectSection(_ group: ThreadSearchService.Group) -> some View { + let project = appState.projects.first(where: { $0.id == group.projectId }) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "folder.fill") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + Text(project?.name ?? "Unknown project") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + } + .padding(.horizontal, 8) + .padding(.bottom, 2) + + VStack(spacing: 2) { + ForEach(group.hits, id: \.threadId) { hit in + resultRow(hit: hit) + } + } + } + } + + private func resultRow(hit: ThreadSearchService.Hit) -> some View { + let summary = appState.allSessionSummaries.first(where: { $0.id == hit.threadId }) + let title = summary?.title ?? "Untitled thread" + let snippet = displaySnippet(hit: hit, title: title) + let threadSummary = appState.threadStore.threadSummaryItem(sessionId: hit.threadId)?.summary + .trimmingCharacters(in: .whitespacesAndNewlines) + return Button { + select(hit: hit) + } label: { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(title) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + scoreBadge(hit.score) + if summary?.isArchived == true { + Text("archived") + .font(.system(size: ClaudeTheme.size(10), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(ClaudeTheme.surfaceSecondary, in: Capsule()) + } + } + if let threadSummary, !threadSummary.isEmpty { + Text(Self.inlineMarkdown(threadSummary)) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineLimit(3) + } else if let snippet { + Text(snippet) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineLimit(2) + } else { + Text("Matched on title") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + } + } + Spacer(minLength: 8) + if let updated = summary?.updatedAt { + Text(updated.formatted(date: .abbreviated, time: .omitted)) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(ClaudeTheme.surfacePrimary) + ) + .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + } + .buttonStyle(.plain) + } + + /// Show the cosine similarity as a 0–100 chip. Negative scores clamp to 0; + /// they're rare but possible since NLEmbedding outputs aren't strictly + /// positive-only. + private func scoreBadge(_ score: Float) -> some View { + let clamped = max(0, min(1, score)) + let pct = Int((clamped * 100).rounded()) + return Text("\(pct)%") + .font(.system(size: ClaudeTheme.size(10), weight: .semibold).monospacedDigit()) + .foregroundStyle(ClaudeTheme.accent) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(ClaudeTheme.accent.opacity(0.12), in: Capsule()) + } + + /// Parse a hit snippet for display. Returns `nil` when the snippet would + /// just repeat the thread title (the indexer stores the title as its own + /// chunk, so a title-only match would otherwise show the title twice). + private func displaySnippet(hit: ThreadSearchService.Hit, title: String) -> AttributedString? { + let cleaned = hit.snippet + .trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedTitle.isEmpty, cleaned.caseInsensitiveCompare(normalizedTitle) == .orderedSame { + return nil + } + return Self.inlineMarkdown(cleaned) + } + + private static func inlineMarkdown(_ text: String) -> AttributedString { + if let attr = try? AttributedString( + markdown: text, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + return attr + } + return AttributedString(text) + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 8) { + Image(systemName: "lock.shield") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(ClaudeTheme.textTertiary) + Text("Local on-device search · archived threads included") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(ClaudeTheme.surfaceSecondary.opacity(0.5)) + } + + // MARK: - Actions + + private func close() { + windowState.showGlobalSearch = false + } + + private func select(hit: ThreadSearchService.Hit) { + let id = hit.threadId + close() + // Small delay so the overlay's dismiss animation has a frame to start + // before we trigger the navigation, which can otherwise visibly stall. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + appState.selectSession(id: id, in: windowState) + } + } + + private func runSearch() async { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !q.isEmpty else { + groups = [] + inThreadHits = [] + hasSearched = false + return + } + // Full-text scan of the current thread runs immediately so typing feels + // responsive even before the semantic search debounces. + let liveMessages = appState.messages(in: windowState) + inThreadHits = ThreadSearchService.searchInThread(q, in: liveMessages) + + // Debounce: wait briefly so we don't re-embed on every keystroke. + try? await Task.sleep(for: .milliseconds(180)) + if Task.isCancelled { return } + if q != query.trimmingCharacters(in: .whitespacesAndNewlines) { return } + + isSearching = true + let results = await appState.searchService.search(q, limit: 50) + if Task.isCancelled { return } + if q == query.trimmingCharacters(in: .whitespacesAndNewlines) { + // Drop the current thread from the semantic groups when we already + // have in-thread literal matches — same thread shouldn't appear twice. + let currentId = inThreadHits.isEmpty ? nil : windowState.currentSessionId + groups = results.compactMap { group in + let filtered = group.hits.filter { $0.threadId != currentId } + guard !filtered.isEmpty else { return nil } + return ThreadSearchService.Group(projectId: group.projectId, hits: filtered) + } + hasSearched = true + } + isSearching = false + } +} diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift new file mode 100644 index 00000000..b7b1937d --- /dev/null +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -0,0 +1,498 @@ +import SwiftUI +import Foundation +import RxCodeCore + +struct BriefingView: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + @State private var currentBranch: String? + + private var project: Project? { + windowState.selectedProject + } + + private var briefing: BranchBriefingItem? { + _ = appState.branchBriefingRevision + guard let project, let currentBranch else { return nil } + return appState.threadStore.branchBriefingItem(projectId: project.id, branch: currentBranch) + } + + private var threadSummaries: [ThreadSummaryItem] { + _ = appState.threadSummaryRevision + guard let project, let currentBranch else { return [] } + return appState.threadStore.threadSummaryItems(projectId: project.id, branch: currentBranch) + } + + var body: some View { + Group { + if let project { + projectBriefing(project) + } else { + emptyState( + icon: "folder", + title: "Select a Project", + message: "Choose a project to view its branch briefing." + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(ClaudeTheme.background) + .task(id: project?.path) { + while !Task.isCancelled { + await refreshBranch() + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + } + } + + private func projectBriefing(_ project: Project) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + hero(project) + + if currentBranch == nil { + emptyState( + icon: "arrow.triangle.branch", + title: "No Git Branch", + message: "This project does not have a detectable current branch." + ) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else if briefing == nil, threadSummaries.isEmpty { + emptyState( + icon: "text.page", + title: "No Briefing Yet", + message: "Briefing updates after a thread finishes on this branch." + ) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else { + if let briefing { + briefingCard(briefing) + } + + if !threadSummaries.isEmpty { + threadSection(threadSummaries) + } + } + } + .padding(.horizontal, 28) + .padding(.top, 24) + .padding(.bottom, 40) + .frame(maxWidth: 1200, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + // MARK: - Hero + + private func hero(_ project: Project) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .center, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(ClaudeTheme.accent.opacity(0.14)) + Image(systemName: "text.page") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + } + .frame(width: 38, height: 38) + + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .center, spacing: 10) { + Text(project.name) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + + startNewChatButton(for: project) + } + Text("A running summary of work on this branch.") + .font(.system(size: 12)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + + Spacer(minLength: 0) + } + + HStack(spacing: 8) { + chip(icon: "folder.fill", text: project.name) + if let currentBranch { + chip(icon: "arrow.triangle.branch", text: currentBranch, accented: true) + } else { + chip(icon: "arrow.triangle.branch", text: "No branch") + } + } + } + } + + private func startNewChatButton(for project: Project) -> some View { + Button { + if windowState.selectedProject?.id != project.id { + appState.selectProject(project, in: windowState) + } + appState.startNewChat(in: windowState) + } label: { + HStack(spacing: 5) { + Image(systemName: "plus.bubble.fill") + .font(.system(size: 11, weight: .semibold)) + Text("Start New Chat") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(ClaudeTheme.textOnAccent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule(style: .continuous).fill(ClaudeTheme.accent) + ) + } + .buttonStyle(.plain) + .help("Start a new chat in \(project.name)") + } + + private func chip(icon: String, text: String, accented: Bool = false) -> some View { + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 10, weight: .semibold)) + Text(text) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + } + .foregroundStyle(accented ? ClaudeTheme.accent : ClaudeTheme.textSecondary) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background( + Capsule(style: .continuous) + .fill(accented ? ClaudeTheme.accent.opacity(0.12) : ClaudeTheme.surfaceSecondary) + ) + .overlay( + Capsule(style: .continuous) + .strokeBorder(accented ? ClaudeTheme.accent.opacity(0.25) : ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + ) + } + + // MARK: - Briefing Card + + private func briefingCard(_ item: BranchBriefingItem) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "sparkles") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + Text("General") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + .tracking(0.6) + + Spacer() + + Text("Updated \(Self.compactDate(item.updatedAt))") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + + BriefingMarkdownView(text: item.briefing, fontSize: 13.5) + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) + .fill(ClaudeTheme.surfacePrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) + .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + ) + } + + // MARK: - Thread Summaries + + private func threadSection(_ items: [ThreadSummaryItem]) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Thread Summaries") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + .tracking(0.6) + + Text("\(items.count)") + .font(.system(size: 11, weight: .semibold).monospacedDigit()) + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background( + Capsule().fill(ClaudeTheme.surfaceSecondary) + ) + + Spacer() + } + .padding(.horizontal, 2) + + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 320, maximum: 480), spacing: 12, alignment: .top)], + alignment: .leading, + spacing: 12 + ) { + ForEach(items) { item in + threadCard(item) + } + } + } + } + + private func threadCard(_ item: ThreadSummaryItem) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(ClaudeTheme.accent.opacity(0.12)) + Image(systemName: "bubble.left.and.text.bubble.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + } + .frame(width: 22, height: 22) + + Text(item.title) + .font(.system(size: 13.5, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Divider() + .opacity(0.4) + + BriefingMarkdownView(text: item.summary, fontSize: 12.5) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer(minLength: 0) + + HStack(spacing: 6) { + Image(systemName: "clock") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + Text(Self.compactDate(item.updatedAt)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + Spacer() + } + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium, style: .continuous) + .fill(ClaudeTheme.surfacePrimary) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium, style: .continuous) + .strokeBorder(ClaudeTheme.border.opacity(0.55), lineWidth: 0.5) + ) + .shadow(color: Color.black.opacity(0.03), radius: 2, x: 0, y: 1) + } + + // MARK: - Empty State + + private func emptyState(icon: String, title: String, message: String) -> some View { + VStack(spacing: 12) { + ZStack { + Circle() + .fill(ClaudeTheme.surfaceSecondary) + .frame(width: 56, height: 56) + Image(systemName: icon) + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + Text(message) + .font(.system(size: 13)) + .foregroundStyle(ClaudeTheme.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 320) + } + .padding(.vertical, 60) + } + + private func refreshBranch() async { + guard let project else { + await MainActor.run { currentBranch = nil } + return + } + let branch = await GitHelper.currentBranch(at: project.path) + await MainActor.run { + currentBranch = branch + if let branch { + appState.touchBranchBriefing(projectId: project.id, branch: branch) + } + } + } + + private static func compactDate(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: .now) + } +} + +// MARK: - Lightweight Markdown Renderer + +/// Renders simple markdown content (bullets, ordered lists, paragraphs, headings, inline +/// bold/italic/code). Designed for compact briefings/summaries — not a full markdown engine. +private struct BriefingMarkdownView: View { + let text: String + var fontSize: CGFloat = 13.5 + + private var blocks: [Block] { + Self.parse(text) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + switch block { + case .heading(let level, let content): + Text(Self.inline(content)) + .font(.system(size: headingSize(level), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + .padding(.top, 4) + .padding(.bottom, 2) + case .paragraph(let content): + Text(Self.inline(content)) + .font(.system(size: fontSize)) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + case .bullet(let content): + bulletRow(marker: "•", content: content) + case .ordered(let number, let content): + bulletRow(marker: "\(number).", content: content, monospaced: true) + } + } + } + } + + private func bulletRow(marker: String, content: String, monospaced: Bool = false) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(marker) + .font(monospaced + ? .system(size: fontSize, weight: .semibold).monospacedDigit() + : .system(size: fontSize, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + .frame(minWidth: 14, alignment: .leading) + Text(Self.inline(content)) + .font(.system(size: fontSize)) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func headingSize(_ level: Int) -> CGFloat { + switch level { + case 1: return fontSize + 5 + case 2: return fontSize + 3 + case 3: return fontSize + 2 + default: return fontSize + 1 + } + } + + // MARK: Parsing + + enum Block { + case heading(level: Int, content: String) + case paragraph(String) + case bullet(String) + case ordered(number: Int, content: String) + } + + private static func parse(_ text: String) -> [Block] { + var blocks: [Block] = [] + var paragraphBuffer: [String] = [] + + func flushParagraph() { + guard !paragraphBuffer.isEmpty else { return } + let joined = paragraphBuffer.joined(separator: " ") + .trimmingCharacters(in: .whitespaces) + if !joined.isEmpty { + blocks.append(.paragraph(joined)) + } + paragraphBuffer.removeAll() + } + + for rawLine in text.components(separatedBy: "\n") { + let line = rawLine.trimmingCharacters(in: .whitespaces) + + if line.isEmpty { + flushParagraph() + continue + } + + // Heading: # to ###### + if line.hasPrefix("#") { + var level = 0 + for ch in line { + if ch == "#" { level += 1 } else { break } + } + if level >= 1, level <= 6, line.count > level, + line[line.index(line.startIndex, offsetBy: level)] == " " { + flushParagraph() + let content = String(line.dropFirst(level + 1)).trimmingCharacters(in: .whitespaces) + blocks.append(.heading(level: level, content: content)) + continue + } + } + + // Unordered bullet + if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") { + flushParagraph() + blocks.append(.bullet(String(line.dropFirst(2)))) + continue + } + + // Ordered list "1. content" + if let dotIdx = line.firstIndex(of: "."), + let number = Int(line[line.startIndex.. AttributedString { + if var attr = try? AttributedString( + markdown: content, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + // Style inline code spans + for run in attr.runs { + guard let intent = run.inlinePresentationIntent else { continue } + if intent.contains(.code) { + attr[run.range].font = .system(size: 12.5, design: .monospaced) + attr[run.range].foregroundColor = ClaudeTheme.textPrimary + attr[run.range].backgroundColor = ClaudeTheme.surfaceTertiary + } + } + return attr + } + return AttributedString(content) + } +} diff --git a/RxCode/Views/Sidebar/ProjectChatRow.swift b/RxCode/Views/Sidebar/ProjectChatRow.swift index 85d72875..79300412 100644 --- a/RxCode/Views/Sidebar/ProjectChatRow.swift +++ b/RxCode/Views/Sidebar/ProjectChatRow.swift @@ -123,10 +123,13 @@ struct ProjectChatRow: View { if case .streaming = status { CompactSessionProgressView(progress: todoProgress) + .frame(width: 28, alignment: .trailing) } else { Text(Self.compactElapsedTime(since: summary.updatedAt)) .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(ClaudeTheme.textTertiary) + .monospacedDigit() + .frame(width: 28, alignment: .trailing) } } .padding(.leading, 18) diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index ba4ea00d..ea2f9c5e 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -24,6 +24,12 @@ struct ProjectTreeView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { + SummarySidebarSection() + .padding(.top, 8) + + ClaudeThemeDivider() + .padding(.vertical, 4) + header .padding(.horizontal, 12) .padding(.vertical, 8) @@ -129,10 +135,54 @@ struct ProjectTreeView: View { } .buttonStyle(.borderless) .help("Show all chats") + } +} + +// MARK: - SummarySidebarSection + +private struct SummarySidebarSection: View { + @Environment(WindowState.self) private var windowState + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("General") + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + .padding(.horizontal, 12) + .padding(.bottom, 2) + + Button { + windowState.showingBriefing = true + } label: { + HStack(spacing: 8) { + Image(systemName: "text.page") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + .frame(width: 18, height: 18) + + Text("Briefing") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .lineLimit(1) + + Spacer(minLength: 4) + } + .foregroundStyle(windowState.showingBriefing ? ClaudeTheme.accent : ClaudeTheme.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(windowState.showingBriefing ? ClaudeTheme.accent.opacity(0.10) : Color.clear) + ) + .padding(.horizontal, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Open project branch briefing") } } +} - // MARK: - Empty State +// MARK: - Empty State private var emptyState: some View { VStack(spacing: 8) { @@ -162,8 +212,10 @@ struct ProjectTreeView: View { isExpanded: Binding( get: { expandedProjectIds.contains(project.id) }, set: { newValue in - if newValue { expandedProjectIds.insert(project.id) } - else { expandedProjectIds.remove(project.id) } + withAnimation(.easeInOut(duration: 0.18)) { + if newValue { expandedProjectIds.insert(project.id) } + else { expandedProjectIds.remove(project.id) } + } } ), isSelected: windowState.selectedProject?.id == project.id, @@ -197,10 +249,15 @@ struct ProjectTreeView: View { deleteSession = session } ) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + )) } } } .padding(.bottom, 8) + .animation(.easeInOut(duration: 0.18), value: expandedProjectIds) } } } @@ -383,13 +440,24 @@ private struct ProjectChatsList: View { ) } + private var collapsedVisibleCount: Int { + let pinnedCount = sessions.prefix(while: { $0.isPinned }).count + // Cap pinned slots at 6, then guarantee at least 4 unpinned rows so + // recent unpinned threads remain visible even when pinned threads + // would otherwise fill the collapsed list. + let pinnedShown = min(pinnedCount, 6) + let unpinnedShown = max(4, 6 - pinnedShown) + let dynamic = pinnedShown + unpinnedShown + return max(Self.defaultVisibleThreadCount, dynamic) + } + private var visibleSessions: [ChatSession.Summary] { guard !showsAllThreads else { return sessions } - return Array(sessions.prefix(Self.defaultVisibleThreadCount)) + return Array(sessions.prefix(collapsedVisibleCount)) } private var hiddenThreadCount: Int { - max(0, sessions.count - Self.defaultVisibleThreadCount) + max(0, sessions.count - collapsedVisibleCount) } var body: some View { @@ -403,13 +471,20 @@ private struct ProjectChatsList: View { } else { ForEach(visibleSessions) { summary in chatRow(for: summary) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + )) } if hiddenThreadCount > 0 { threadLimitToggle + .transition(.opacity) } } } + .clipped() + .animation(.easeInOut(duration: 0.18), value: showsAllThreads) } private var threadLimitToggle: some View { @@ -451,7 +526,7 @@ private struct ProjectChatsList: View { return ProjectChatRow( summary: summary, - isCurrent: windowState.currentSessionId == sessionId, + isCurrent: !windowState.showingBriefing && windowState.currentSessionId == sessionId, status: status, todoProgress: progress, onSelect: { onSelectSession(sessionId) }, diff --git a/RxCode/Views/Terminal/TerminalView.swift b/RxCode/Views/Terminal/TerminalView.swift index 5aa3a6e8..d34d723f 100644 --- a/RxCode/Views/Terminal/TerminalView.swift +++ b/RxCode/Views/Terminal/TerminalView.swift @@ -16,6 +16,13 @@ final class TerminalProcess { terminalView = nil } + /// Clears the visible screen and scrollback without killing the running shell. + func clear() { + guard let tv = terminalView else { return } + tv.getTerminal().resetToInitialState() + tv.needsDisplay = true + } + deinit { terminalView?.terminate() } diff --git a/RxCodeTests/AppStateProjectSwitchTests.swift b/RxCodeTests/AppStateProjectSwitchTests.swift index 3e030c1e..1378da44 100644 --- a/RxCodeTests/AppStateProjectSwitchTests.swift +++ b/RxCodeTests/AppStateProjectSwitchTests.swift @@ -111,9 +111,72 @@ final class AppStateProjectSwitchTests: XCTestCase { XCTAssertNotNil(appState.sessionStates["live"]) } + // MARK: - selectProject: queued message ownership + + func testSelectProject_preservesQueuedMessagesForOutgoingSession() { + let projectA = makeProject("A") + let projectB = makeProject("B") + appState.projects = [projectA, projectB] + window.selectedProject = projectA + window.currentSessionId = "session-a" + window.messageQueue = [ + QueuedMessage(text: "queued for session A", attachments: []) + ] + + appState.selectProject(projectB, in: window) + + XCTAssertEqual( + window.draftQueues["session-a"]?.map(\.text), + ["queued for session A"], + "Switching projects should keep queued messages attached to the outgoing thread." + ) + XCTAssertEqual( + window.messageQueue.map(\.text), + [], + "The destination project's new chat should not show the outgoing thread's queue." + ) + XCTAssertNil(window.draftQueues[newChatKey(for: projectB)]) + } + + func testSelectProject_keepsNewChatQueuedMessagesProjectScoped() { + let projectA = makeProject("A") + let projectB = makeProject("B") + appState.projects = [projectA, projectB] + window.selectedProject = projectA + window.currentSessionId = nil + window.messageQueue = [ + QueuedMessage(text: "queued before first send in A", attachments: []) + ] + + appState.selectProject(projectB, in: window) + + XCTAssertEqual( + window.draftQueues[newChatKey(for: projectA)]?.map(\.text), + ["queued before first send in A"], + "A not-yet-started chat queue should stay with its source project." + ) + XCTAssertEqual( + window.messageQueue.map(\.text), + [], + "Project B should start with its own empty new-chat queue." + ) + + appState.selectProject(projectA, in: window) + + XCTAssertEqual( + window.messageQueue.map(\.text), + ["queued before first send in A"], + "Returning to project A should restore project A's new-chat queue." + ) + } + // MARK: - Helpers private func makeProject(_ name: String) -> Project { Project(name: name, path: "/tmp/\(name.lowercased())", gitHubRepo: nil) } + + private func newChatKey(for project: Project) -> String { + "new:\(project.id.uuidString)" + } } From da5687e7867b2b4cb9328d73d98434c5275d7ba5 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 18 May 2026 19:57:19 +0800 Subject: [PATCH 2/2] fix: run profile and ux --- .../RxCodeChatKit/WebPreviewButton.swift | 2 +- .../RxCodeCore/Models/MakeRunConfig.swift | 20 + .../RxCodeCore/Models/RunProfile.swift | 12 + .../RxCodeCore/Models/XcodeRunConfig.swift | 36 ++ .../RunProfile/RunTaskExecutor.swift | 91 +++- Packages/Sources/RxCodeCore/WindowState.swift | 3 + .../RunTaskExecutorTests.swift | 40 ++ RxCode/App/AppState.swift | 37 +- .../RunProfile/RunProfileDetector.swift | 48 +- RxCode/Services/ThreadSearchService.swift | 94 +++- RxCode/Services/ThreadStore.swift | 15 + .../Views/Chat/ThreadTitlePopoverButton.swift | 98 ++++ .../Views/Inspector/RightInspectorPanel.swift | 36 +- RxCode/Views/MainView.swift | 242 +++++---- RxCode/Views/ProjectWindowView.swift | 4 + .../RunProfile/RunConfigurationsView.swift | 373 ++++++++++--- RxCode/Views/Search/GlobalSearchOverlay.swift | 123 ++++- RxCode/Views/SettingsView.swift | 40 ++ RxCode/Views/Sidebar/BriefingView.swift | 511 +++++++++++------- RxCode/Views/Sidebar/HistoryListView.swift | 2 +- 20 files changed, 1405 insertions(+), 422 deletions(-) create mode 100644 Packages/Sources/RxCodeCore/Models/MakeRunConfig.swift create mode 100644 Packages/Sources/RxCodeCore/Models/XcodeRunConfig.swift create mode 100644 RxCode/Views/Chat/ThreadTitlePopoverButton.swift diff --git a/Packages/Sources/RxCodeChatKit/WebPreviewButton.swift b/Packages/Sources/RxCodeChatKit/WebPreviewButton.swift index 735b8755..92d6672d 100644 --- a/Packages/Sources/RxCodeChatKit/WebPreviewButton.swift +++ b/Packages/Sources/RxCodeChatKit/WebPreviewButton.swift @@ -20,7 +20,7 @@ struct WebPreviewButton: View { Text("View Result", bundle: .module) Text(url.absoluteString) .font(.caption) - .foregroundStyle(ClaudeTheme.textSecondary) + .foregroundStyle(.white.opacity(0.85)) } .font(.subheadline) } diff --git a/Packages/Sources/RxCodeCore/Models/MakeRunConfig.swift b/Packages/Sources/RxCodeCore/Models/MakeRunConfig.swift new file mode 100644 index 00000000..e76ab63b --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/MakeRunConfig.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct MakeRunConfig: Codable, Sendable, Hashable { + /// File name (relative to project root) of the Makefile to drive `make`. + /// Empty defaults to `make`'s normal lookup (Makefile / makefile / GNUmakefile). + public var makefile: String + public var target: String + /// Extra arguments appended after the target (e.g. `VAR=value`). + public var arguments: String + + public init( + makefile: String = "", + target: String = "", + arguments: String = "" + ) { + self.makefile = makefile + self.target = target + self.arguments = arguments + } +} diff --git a/Packages/Sources/RxCodeCore/Models/RunProfile.swift b/Packages/Sources/RxCodeCore/Models/RunProfile.swift index 710c1f1b..bbea1eb1 100644 --- a/Packages/Sources/RxCodeCore/Models/RunProfile.swift +++ b/Packages/Sources/RxCodeCore/Models/RunProfile.swift @@ -2,6 +2,8 @@ import Foundation public enum RunProfileType: String, Codable, Sendable, CaseIterable, Hashable { case bash + case xcode + case make } public struct RunProfile: Identifiable, Codable, Sendable, Hashable { @@ -10,6 +12,12 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable { public var name: String public var type: RunProfileType public var bash: BashRunConfig + /// Populated when `type == .xcode`. Optional so existing on-disk profiles + /// (written before the Xcode type existed) decode cleanly. + public var xcode: XcodeRunConfig? + /// Populated when `type == .make`. Optional so existing on-disk profiles + /// (written before the Make type existed) decode cleanly. + public var make: MakeRunConfig? public var beforeSteps: [RunStep] public var afterSteps: [RunStep] public var createdAt: Date @@ -21,6 +29,8 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable { name: String, type: RunProfileType = .bash, bash: BashRunConfig = BashRunConfig(), + xcode: XcodeRunConfig? = nil, + make: MakeRunConfig? = nil, beforeSteps: [RunStep] = [], afterSteps: [RunStep] = [], createdAt: Date = Date(), @@ -31,6 +41,8 @@ public struct RunProfile: Identifiable, Codable, Sendable, Hashable { self.name = name self.type = type self.bash = bash + self.xcode = xcode + self.make = make self.beforeSteps = beforeSteps self.afterSteps = afterSteps self.createdAt = createdAt diff --git a/Packages/Sources/RxCodeCore/Models/XcodeRunConfig.swift b/Packages/Sources/RxCodeCore/Models/XcodeRunConfig.swift new file mode 100644 index 00000000..07f2156a --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/XcodeRunConfig.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum XcodeAction: String, Codable, Sendable, CaseIterable, Hashable { + case build + case run + case test + case clean +} + +public struct XcodeRunConfig: Codable, Sendable, Hashable { + /// File name (relative to project root) of the `.xcodeproj` or + /// `.xcworkspace` to drive `xcodebuild`. + public var container: String + public var isWorkspace: Bool + public var scheme: String + public var configuration: String + public var action: XcodeAction + /// Optional `-destination` argument. Empty string omits the flag. + public var destination: String + + public init( + container: String = "", + isWorkspace: Bool = false, + scheme: String = "", + configuration: String = "Debug", + action: XcodeAction = .run, + destination: String = "" + ) { + self.container = container + self.isWorkspace = isWorkspace + self.scheme = scheme + self.configuration = configuration + self.action = action + self.destination = destination + } +} diff --git a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift index 67e19423..d0924736 100644 --- a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift +++ b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift @@ -114,15 +114,100 @@ public enum RunTaskExecutor { lines.append("") } - let main = profile.bash.command.trimmingCharacters(in: .whitespaces) - if !main.isEmpty { + let mainLines = mainCommandLines(for: profile) + if !mainLines.isEmpty { lines.append("# --- main ---") - lines.append(main) + lines.append(contentsOf: mainLines) } return lines.joined(separator: "\n") + "\n" } + /// Produce the script lines for the profile's main command. For `.bash` + /// this is just the user-typed string; for `.xcode` we synthesize the + /// appropriate `xcodebuild` invocation (and, for `.run`, locate the built + /// `.app` from build settings and `open` it); for `.make` we synthesize + /// a `make` invocation against the configured Makefile and target. + static func mainCommandLines(for profile: RunProfile) -> [String] { + switch profile.type { + case .bash: + let cmd = profile.bash.command.trimmingCharacters(in: .whitespaces) + return cmd.isEmpty ? [] : [cmd] + case .xcode: + guard let xcode = profile.xcode, + !xcode.container.isEmpty, + !xcode.scheme.isEmpty + else { return [] } + return xcodeScriptLines(xcode) + case .make: + guard let make = profile.make else { return [] } + return makeScriptLines(make) + } + } + + private static func xcodeScriptLines(_ cfg: XcodeRunConfig) -> [String] { + let containerFlag = cfg.isWorkspace ? "-workspace" : "-project" + let container = shellEscape(cfg.container) + let scheme = shellEscape(cfg.scheme) + let configuration = shellEscape(cfg.configuration) + let destination = cfg.destination.trimmingCharacters(in: .whitespaces) + + var base = "xcodebuild \(containerFlag) \(container) -scheme \(scheme) -configuration \(configuration)" + if !destination.isEmpty { + base += " -destination \(shellEscape(destination))" + } + + switch cfg.action { + case .build: + return ["\(base) build"] + case .clean: + return ["\(base) clean"] + case .test: + return ["\(base) test"] + case .run: + // Build, then resolve BUILT_PRODUCTS_DIR / FULL_PRODUCT_NAME from + // `-showBuildSettings` and `open` the resulting bundle. `-n` + // forces a fresh instance — without it, `open` just activates an + // already-running copy (common when iterating on an app you + // launched a moment ago), making Run feel like a no-op. + return [ + "\(base) build", + "__rxcode_settings=$(\(base) -showBuildSettings 2>/dev/null)", + "__rxcode_build_dir=$(printf '%s\\n' \"$__rxcode_settings\" | awk -F' = ' '/^[[:space:]]*BUILT_PRODUCTS_DIR =/ {print $2; exit}')", + "__rxcode_product=$(printf '%s\\n' \"$__rxcode_settings\" | awk -F' = ' '/^[[:space:]]*FULL_PRODUCT_NAME =/ {print $2; exit}')", + "if [ -n \"$__rxcode_build_dir\" ] && [ -n \"$__rxcode_product\" ]; then", + " printf '[rxcode] launching %s\\n' \"$__rxcode_build_dir/$__rxcode_product\"", + " open -n \"$__rxcode_build_dir/$__rxcode_product\"", + "else", + " printf '[rxcode] could not resolve built product to launch\\n' 1>&2", + " exit 1", + "fi", + ] + } + } + + private static func makeScriptLines(_ cfg: MakeRunConfig) -> [String] { + let makefile = cfg.makefile.trimmingCharacters(in: .whitespaces) + let target = cfg.target.trimmingCharacters(in: .whitespaces) + let args = cfg.arguments.trimmingCharacters(in: .whitespaces) + // An untouched profile has nothing to run; skip the main command so + // we don't shell out to a default `make` and surprise the user. + if makefile.isEmpty && target.isEmpty && args.isEmpty { return [] } + + var parts: [String] = ["make"] + if !makefile.isEmpty { + parts.append("-f") + parts.append(shellEscape(makefile)) + } + if !target.isEmpty { + parts.append(shellEscape(target)) + } + if !args.isEmpty { + parts.append(args) + } + return [parts.joined(separator: " ")] + } + /// Escapes a path for safe inclusion as a single-quoted bash literal. /// `'` inside the path is encoded as `'\''`. public static func shellEscape(_ s: String) -> String { diff --git a/Packages/Sources/RxCodeCore/WindowState.swift b/Packages/Sources/RxCodeCore/WindowState.swift index 25409296..fdfdd500 100644 --- a/Packages/Sources/RxCodeCore/WindowState.swift +++ b/Packages/Sources/RxCodeCore/WindowState.swift @@ -136,6 +136,9 @@ public final class WindowState { public var showRunConfigurations: Bool = false /// Which active run task the Run inspector tab is currently displaying. public var selectedRunTaskId: UUID? + /// Bumped to request the active inspector terminal to clear its buffer. + /// Observed by `RightInspectorPanel`; the value itself is meaningless — only the change matters. + public var clearTerminalRequest: UUID? public var inspectorReviewTab: InspectorReviewTab = .thisThread public var inspectorFile: PreviewFile? public var diffFile: PreviewFile? diff --git a/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift b/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift index 920feed9..66b557a7 100644 --- a/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift +++ b/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift @@ -231,6 +231,46 @@ struct RunTaskExecutorTests { #expect(script.contains("cd '/tmp/it'\\''s a path'")) } + // MARK: - Make profile + + @Test("Make profile with target only emits `make `") + func makeProfileTargetOnly() { + var profile = RunProfile( + projectId: UUID(), + name: "build", + type: .make, + make: MakeRunConfig(target: "build") + ) + profile.bash.workingDirectory = "" + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + #expect(script.contains("make 'build'")) + #expect(!script.contains("-f")) + } + + @Test("Make profile with non-default Makefile passes -f") + func makeProfileCustomMakefile() { + let profile = RunProfile( + projectId: UUID(), + name: "build", + type: .make, + make: MakeRunConfig(makefile: "BuildScripts/release.mk", target: "all", arguments: "VAR=1 -j4") + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + #expect(script.contains("make -f 'BuildScripts/release.mk' 'all' VAR=1 -j4")) + } + + @Test("Make profile with empty config emits no main command") + func makeProfileEmpty() { + let profile = RunProfile( + projectId: UUID(), + name: "empty", + type: .make, + make: MakeRunConfig() + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + #expect(!script.contains("# --- main ---")) + } + // MARK: - Model round-trip @Test("RunProfile JSON round-trips") diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 2cf50847..cbf62584 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -896,6 +896,8 @@ final class AppState { let mcp: MCPService let threadStore: ThreadStore let searchService = ThreadSearchService() + /// Live progress for a user-triggered full reindex. `nil` when idle. + var reindexProgress: (done: Int, total: Int)? = nil let runService = RunService() let ideMCPServer = IDEMCPServer() @@ -993,6 +995,28 @@ final class AppState { } } + /// User-triggered full reindex of every thread. Wipes cached embeddings, + /// then re-embeds every thread. Updates `reindexProgress` so the UI can + /// render a counter. + func reindexAllThreads() async { + guard reindexProgress == nil else { return } + reindexProgress = (0, 0) + let searchService = self.searchService + let threadStore = self.threadStore + let persistence = self.persistence + await searchService.reindexAll( + loadAll: { @MainActor in threadStore.loadAllSummaries() }, + loadFull: { @MainActor [weak self] summary -> ChatSession? in + let cwd = self?.projects.first(where: { $0.id == summary.projectId })?.path ?? "" + return await persistence.loadFullSession(summary: summary, cwd: cwd) + }, + progress: { [weak self] done, total in + Task { @MainActor in self?.reindexProgress = (done, total) } + } + ) + reindexProgress = nil + } + // MARK: - Agent Backends /// Looks up the `AgentBackend` for the given provider. Used by @@ -1676,6 +1700,11 @@ final class AppState { selectProject(first, in: window) } + // Show the briefing as the landing view on launch, even after restoring a project. + // `selectProject` clears `showingBriefing` for normal switches; re-enable here so the + // user lands on the briefing dashboard rather than a fresh chat. + window.showingBriefing = true + window.isInitialized = true } @@ -4335,7 +4364,13 @@ final class AppState { func selectSession(id: String, in window: WindowState) { logger.info("[SelectSession] click sid=\(id, privacy: .public) currentSid=\(window.currentSessionId ?? "", privacy: .public) selectedProject=\(window.selectedProject?.id.uuidString ?? "", privacy: .public) summariesCount=\(self.allSessionSummaries.count)") guard window.currentSessionId != id else { - logger.info("[SelectSession] no-op: already current sid=\(id, privacy: .public)") + if window.showingBriefing { + window.showingBriefing = false + window.requestInputFocus = true + logger.info("[SelectSession] same sid, leaving briefing sid=\(id, privacy: .public)") + } else { + logger.info("[SelectSession] no-op: already current sid=\(id, privacy: .public)") + } return } diff --git a/RxCode/Services/RunProfile/RunProfileDetector.swift b/RxCode/Services/RunProfile/RunProfileDetector.swift index 0a50482f..3bd88ff2 100644 --- a/RxCode/Services/RunProfile/RunProfileDetector.swift +++ b/RxCode/Services/RunProfile/RunProfileDetector.swift @@ -1,4 +1,5 @@ import Foundation +import RxCodeCore /// Source of an auto-detected runnable shown in the "+" picker of the Run /// Configurations dialog. @@ -8,14 +9,37 @@ enum RunnableSource: String, Sendable, Hashable { case make } -/// A single auto-detected runnable. Pre-fills the bash command and display -/// name when the user picks it from the "+" menu. Not persisted on its own — -/// selecting one materializes a normal `RunProfile`. +/// A single auto-detected runnable. Pre-fills either a bash command (npm, +/// make) or a structured Xcode config (when `xcode` is non-nil) when the user +/// picks it from the "+" menu. Not persisted on its own — selecting one +/// materializes a normal `RunProfile`. struct DetectedRunnable: Identifiable, Hashable, Sendable { let id: String let source: RunnableSource let displayName: String let command: String + /// Present when `source == .xcode`. The dialog materializes these as + /// `.xcode`-typed profiles instead of bash configurations. + let xcode: XcodeRunConfig? + /// Present when `source == .make`. The dialog materializes these as + /// `.make`-typed profiles instead of bash configurations. + let make: MakeRunConfig? + + init( + id: String, + source: RunnableSource, + displayName: String, + command: String, + xcode: XcodeRunConfig? = nil, + make: MakeRunConfig? = nil + ) { + self.id = id + self.source = source + self.displayName = displayName + self.command = command + self.xcode = xcode + self.make = make + } } struct DetectedRunnables: Sendable, Hashable { @@ -78,13 +102,22 @@ final class RunProfileDetector { guard let parsed = try? JSONDecoder().decode(XcodeList.self, from: data) else { return [] } let schemes = parsed.workspace?.schemes ?? parsed.project?.schemes ?? [] + let isWorkspace = (flag == "-workspace") return schemes.map { name in let cmd = "xcodebuild \(flag) \"\(container)\" -scheme \"\(name)\" build" + let xcode = XcodeRunConfig( + container: container, + isWorkspace: isWorkspace, + scheme: name, + configuration: "Debug", + action: .run + ) return DetectedRunnable( id: "xcode:\(name)", source: .xcode, displayName: name, - command: cmd + command: cmd, + xcode: xcode ) } } @@ -130,12 +163,17 @@ final class RunProfileDetector { else { return [] } let targets = parseMakeTargets(content) + // Default Makefile lookup (`make`) finds the same file we picked here, + // so leave `makefile` empty unless the user picked a non-default name. + let isDefaultName = (chosen == "Makefile" || chosen == "makefile" || chosen == "GNUmakefile") + let makefileName = isDefaultName ? "" : chosen return targets.map { name in DetectedRunnable( id: "make:\(name)", source: .make, displayName: name, - command: "make \(name)" + command: "make \(name)", + make: MakeRunConfig(makefile: makefileName, target: name) ) } } diff --git a/RxCode/Services/ThreadSearchService.swift b/RxCode/Services/ThreadSearchService.swift index 2379ba68..2362dbc6 100644 --- a/RxCode/Services/ThreadSearchService.swift +++ b/RxCode/Services/ThreadSearchService.swift @@ -61,7 +61,7 @@ actor ThreadSearchService { /// but quality degrades for very long passages. private let chunkSize = 480 /// Backfill version sentinel. Bump when chunking or embedding model changes. - private let backfillVersion = 1 + private let backfillVersion = 2 private let backfillKey = "com.idealapp.RxCode.searchIndex.backfillVersion" init() {} @@ -70,11 +70,15 @@ actor ThreadSearchService { /// Load any existing chunk rows into memory. Cheap: ~2KB per chunk. func start(threadStore: ThreadStore) async { - guard !didStart else { return } + guard !didStart else { + logger.info("start() called again — already initialised, ignoring") + return + } didStart = true self.threadStore = threadStore embedder = NLEmbedding.sentenceEmbedding(for: .english) wordEmbedder = NLEmbedding.wordEmbedding(for: .english) + logger.info("start(): sentenceEmbedder=\(self.embedder != nil), wordEmbedder=\(self.wordEmbedder != nil)") if embedder == nil && wordEmbedder == nil { logger.error("Both sentence and word embeddings unavailable; search disabled") return @@ -100,10 +104,18 @@ actor ThreadSearchService { /// Index a thread's content. Replaces any prior chunks for that thread. /// Safe to call multiple times — re-indexing is idempotent. func indexThread(_ session: ChatSession) async { - guard let store = threadStore else { return } + guard let store = threadStore else { + logger.info("Skip index \(session.id, privacy: .public): threadStore not set (start() not called?)") + return + } + guard embedder != nil || wordEmbedder != nil else { + logger.info("Skip index \(session.id, privacy: .public): no embedder available") + return + } // Skip threads with no real content (placeholder rows, empty new chats). let texts = chunkTexts(for: session) guard !texts.isEmpty else { + logger.info("Skip index \(session.id, privacy: .public): no chunkable content (messages=\(session.messages.count), title=\"\(session.title, privacy: .public)\")") await MainActor.run { store.deleteEmbeddingChunks(threadId: session.id) } index.removeValue(forKey: session.id) return @@ -111,8 +123,12 @@ actor ThreadSearchService { var newChunks: [Chunk] = [] var rows: [ThreadEmbeddingChunk] = [] + var embedFailures = 0 for (i, text) in texts.enumerated() { - guard let vec = embed(text) else { continue } + guard let vec = embed(text) else { + embedFailures += 1 + continue + } newChunks.append(Chunk(index: i, projectId: session.projectId, text: text, vector: vec)) rows.append(ThreadEmbeddingChunk( id: ThreadEmbeddingChunk.makeId(threadId: session.id, index: i), @@ -126,13 +142,55 @@ actor ThreadSearchService { )) } + if newChunks.isEmpty { + logger.error("Failed to index \(session.id, privacy: .public): all \(texts.count) chunk(s) failed to embed") + return + } + index[session.id] = newChunks let threadId = session.id let snapshot = rows await MainActor.run { store.replaceEmbeddingChunks(threadId: threadId, chunks: snapshot) } - logger.debug("Indexed thread \(session.id, privacy: .public): \(newChunks.count) chunks") + logger.info("Indexed thread \(session.id, privacy: .public): \(newChunks.count) chunks (\(embedFailures) embed failures, \(session.messages.count) messages)") + } + + /// Force a full reindex of every thread. Clears the in-memory index, wipes + /// persisted chunks, resets the backfill sentinel, and re-embeds everything. + /// Reports progress via the optional callback so the UI can show a spinner. + func reindexAll( + loadAll: @MainActor @Sendable () -> [ChatSession.Summary], + loadFull: @MainActor @Sendable (ChatSession.Summary) async -> ChatSession?, + progress: (@Sendable (_ done: Int, _ total: Int) -> Void)? = nil + ) async { + guard let store = threadStore else { + logger.error("Reindex aborted: threadStore not set") + return + } + logger.info("Reindex starting: clearing index and embedding chunk store") + index.removeAll() + await MainActor.run { store.deleteAllEmbeddingChunks() } + UserDefaults.standard.removeObject(forKey: backfillKey) + + let summaries = await MainActor.run { loadAll() } + let total = summaries.count + logger.info("Reindex: \(total) thread summaries to embed") + var done = 0 + var loadFailed = 0 + for summary in summaries { + if let full = await loadFull(summary) { + await indexThread(full) + } else { + loadFailed += 1 + logger.info("Reindex: failed to load full session \(summary.id, privacy: .public)") + } + done += 1 + progress?(done, total) + if done % 8 == 0 { await Task.yield() } + } + UserDefaults.standard.set(backfillVersion, forKey: backfillKey) + logger.info("Reindex complete: processed=\(done), loadFailed=\(loadFailed), total=\(total)") } func removeThread(id: String) async { @@ -205,22 +263,38 @@ actor ThreadSearchService { loadAll: @MainActor @Sendable () -> [ChatSession.Summary], loadFull: @MainActor @Sendable (ChatSession.Summary) async -> ChatSession? ) async { - guard threadStore != nil else { return } + guard threadStore != nil else { + logger.info("Backfill skipped: threadStore not set") + return + } let stored = UserDefaults.standard.integer(forKey: backfillKey) - guard stored < backfillVersion else { return } + guard stored < backfillVersion else { + logger.info("Backfill skipped: already ran at version \(stored) (current=\(self.backfillVersion))") + return + } let summaries = await MainActor.run { loadAll() } + logger.info("Backfill starting: \(summaries.count) thread summaries to evaluate") var done = 0 + var alreadyIndexed = 0 + var loadFailed = 0 for summary in summaries { - if !index[summary.id, default: []].isEmpty { continue } - guard let full = await loadFull(summary) else { continue } + if !index[summary.id, default: []].isEmpty { + alreadyIndexed += 1 + continue + } + guard let full = await loadFull(summary) else { + loadFailed += 1 + logger.info("Backfill: failed to load full session \(summary.id, privacy: .public)") + continue + } await indexThread(full) done += 1 // Yield occasionally so we don't starve the cooperative pool. if done % 8 == 0 { await Task.yield() } } UserDefaults.standard.set(backfillVersion, forKey: backfillKey) - logger.info("Search backfill complete: indexed \(done) threads") + logger.info("Search backfill complete: indexed=\(done), alreadyIndexed=\(alreadyIndexed), loadFailed=\(loadFailed), total=\(summaries.count)") } // MARK: - Chunking diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index 675c642c..e1e3f6ca 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -94,6 +94,13 @@ final class ThreadStore { return ((try? context.fetch(descriptor)) ?? []).map { $0.toItem() } } + func allThreadSummaryItems() -> [ThreadSummaryItem] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + ) + return ((try? context.fetch(descriptor)) ?? []).map { $0.toItem() } + } + func branchBriefingItem(projectId: UUID, branch: String) -> BranchBriefingItem? { fetchBranchBriefing(projectId: projectId, branch: branch)?.toItem() } @@ -607,6 +614,14 @@ final class ThreadStore { save() } + /// Wipe every persisted embedding chunk across all threads. + func deleteAllEmbeddingChunks() { + let descriptor = FetchDescriptor() + let rows = (try? context.fetch(descriptor)) ?? [] + for row in rows { context.delete(row) } + save() + } + private func deleteEmbeddingChunkRows(threadId: String) { let descriptor = FetchDescriptor( predicate: #Predicate { $0.threadId == threadId } diff --git a/RxCode/Views/Chat/ThreadTitlePopoverButton.swift b/RxCode/Views/Chat/ThreadTitlePopoverButton.swift new file mode 100644 index 00000000..adf3c236 --- /dev/null +++ b/RxCode/Views/Chat/ThreadTitlePopoverButton.swift @@ -0,0 +1,98 @@ +import RxCodeCore +import SwiftUI + +/// Renders the current thread's title as a button that reveals the +/// generated thread summary in a popover when tapped. Falls back to +/// plain title text when no summary is available. +struct ThreadTitlePopoverButton: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + let title: String + + @State private var showingPopover = false + + private var summaryItem: ThreadSummaryItem? { + guard !windowState.showingBriefing, + let sessionId = windowState.currentSessionId + else { return nil } + _ = appState.threadSummaryRevision + return appState.threadStore.threadSummaryItem(sessionId: sessionId) + } + + private var trimmedSummary: String { + summaryItem?.summary.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + var body: some View { + let hasSummary = !trimmedSummary.isEmpty + Button { + if hasSummary { showingPopover.toggle() } + } label: { + HStack(spacing: 4) { + Text(title) + .foregroundStyle(ClaudeTheme.textPrimary) + if hasSummary { + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + .padding(.trailing) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!hasSummary) + .help(hasSummary ? "Show thread summary" : "") + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + ThreadSummaryPopoverContent(item: summaryItem) + } + } +} + +private struct ThreadSummaryPopoverContent: View { + let item: ThreadSummaryItem? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if let item { + Text(item.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(3) + + Text(item.updatedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.system(size: 11)) + .foregroundStyle(ClaudeTheme.textTertiary) + + Divider() + + ScrollView { + Text(Self.attributedSummary(item.summary)) + .font(.system(size: 12.5)) + .foregroundStyle(ClaudeTheme.textSecondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 320) + } else { + Text("No summary yet") + .font(.system(size: 12.5)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + .padding(14) + .frame(minWidth: 360, idealWidth: 420, maxWidth: 480) + } + + private static func attributedSummary(_ text: String) -> AttributedString { + if let attr = try? AttributedString( + markdown: text, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + return attr + } + return AttributedString(text) + } +} diff --git a/RxCode/Views/Inspector/RightInspectorPanel.swift b/RxCode/Views/Inspector/RightInspectorPanel.swift index a99807fd..19eb3711 100644 --- a/RxCode/Views/Inspector/RightInspectorPanel.swift +++ b/RxCode/Views/Inspector/RightInspectorPanel.swift @@ -160,9 +160,15 @@ struct RightInspectorPanel: View { .background(terminalShortcuts) .task(id: currentSessionKey) { ensureTerminal(for: currentSessionKey) - // Always open the terminal for the current thread. + // Default the inspector to the terminal tab on a thread switch, + // but don't clobber a user-driven focus on the Run tab — pressing + // the Run button sets `inspectorTab = .run` and we want that to + // stick even if the .task body runs after (first mount race) or + // the session key happens to change in the same tick. windowState.inspectorMode = .inspector - windowState.inspectorTab = .terminal + if windowState.inspectorTab != .run { + windowState.inspectorTab = .terminal + } windowState.showInspector = true } .onChange(of: windowState.inspectorTab) { _, newTab in @@ -178,24 +184,28 @@ struct RightInspectorPanel: View { bumpFocus(for: windowState.inspectorTab) } } + .onChange(of: windowState.clearTerminalRequest) { _, _ in + // Routed from the global Cmd+K handler in MainView when the terminal + // tab is the active inspector tab. Avoids a duplicate keyboardShortcut + // collision with the global-search shortcut. + clearActiveTerminal() + } } - /// Hidden buttons that register keyboard shortcuts (Cmd+K clear, Cmd+T new) - /// scoped to when the inspector is showing the terminal tab. + /// Hidden button that registers the Cmd+T new-terminal shortcut, scoped to + /// when the inspector is showing the terminal tab. Cmd+K is intentionally + /// not registered here — it's routed via `WindowState.clearTerminalRequest` + /// from the single global Cmd+K handler in MainView. @ViewBuilder private var terminalShortcuts: some View { if windowState.showInspector, windowState.inspectorMode == .inspector, windowState.inspectorTab == .terminal { - ZStack { - Button("Clear Terminal", action: clearActiveTerminal) - .keyboardShortcut("k", modifiers: .command) - Button("New Terminal", action: addTerminalToCurrent) - .keyboardShortcut("t", modifiers: .command) - } - .frame(width: 0, height: 0) - .opacity(0) - .accessibilityHidden(true) + Button("New Terminal", action: addTerminalToCurrent) + .keyboardShortcut("t", modifiers: .command) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) } } diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 9eaa0ee6..6dcb0de1 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -49,64 +49,13 @@ struct MainView: View { if !appState.onboardingCompleted { OnboardingView() } else { - HSplitView { - NavigationSplitView(columnVisibility: $columnVisibility) { - sidebarContent - } detail: { - detailContent - } - .background { - Button("") { - columnVisibility = (columnVisibility == .all) ? .detailOnly : .all - } - .keyboardShortcut("3", modifiers: .command) - .hidden() - } - .overlay { - if windowState.showMarketplace { - ZStack { - Color.black.opacity(0.3) - .ignoresSafeArea() - .onTapGesture { - withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { - windowState.showMarketplace = false - } - } - SkillMarketView() - .focusable(false) - .transition(.asymmetric( - insertion: .move(edge: .bottom).combined(with: .opacity), - removal: .opacity - )) - } - } - } - .overlay { - if windowState.showGlobalSearch { - ZStack { - Color.black.opacity(0.3) - .ignoresSafeArea() - .onTapGesture { - withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { - windowState.showGlobalSearch = false - } - } - GlobalSearchOverlay() - .transition(.asymmetric( - insertion: .scale(scale: 0.97).combined(with: .opacity), - removal: .opacity - )) - } - .animation(.spring(response: 0.25, dampingFraction: 0.9), value: windowState.showGlobalSearch) - } - } - .background { - Button("") { - windowState.showGlobalSearch.toggle() - } - .keyboardShortcut("k", modifiers: .command) - .hidden() - } + mainContent + } + } + + private var mainContent: some View { + HSplitView { + navigationContent .id(appState.themeRevision) .onChange(of: windowState.showInspector) { _, isShowing in if isShowing, !inspectorStarted { inspectorStarted = true } @@ -123,55 +72,136 @@ struct MainView: View { .accessibilityElement(children: .contain) .accessibilityIdentifier("rxcode-main-view") .toolbar { - if columnVisibility != .detailOnly { - ToolbarItem(placement: .navigation) { - Button { - showGitHubSheet = true - } label: { - Image("GitHubMark") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - .help(appState.isLoggedIn ? "Manage GitHub Repos" : "Connect GitHub") - } + toolbarContent + } - ToolbarItem(placement: .navigation) { - Button { - showFilePicker = true - } label: { - Image(systemName: "plus") - } - .help("Add Project") - .fileImporter( - isPresented: $showFilePicker, - allowedContentTypes: [.folder], - allowsMultipleSelection: false - ) { result in - handleFolderSelection(result) - } - } + if inspectorStarted { + RightInspectorPanel() + } + } + } - ToolbarItem(placement: .navigation) { - Button { - windowState.showGlobalSearch = true - } label: { - Image(systemName: "magnifyingglass") - } - .help("Search Threads (⌘K)") + private var navigationContent: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + sidebarContent + } detail: { + detailContent + } + .background { + Button("") { + columnVisibility = (columnVisibility == .all) ? .detailOnly : .all + } + .keyboardShortcut("3", modifiers: .command) + .hidden() + } + .overlay { + marketplaceOverlay + } + .overlay { + globalSearchOverlay + } + .background { + Button("") { + // Cmd+K is context-sensitive: when the inspector terminal tab + // is active, clear the terminal; otherwise open global search. + if windowState.showInspector, + windowState.inspectorMode == .inspector, + windowState.inspectorTab == .terminal { + windowState.clearTerminalRequest = UUID() + } else { + windowState.showGlobalSearch.toggle() + } + } + .keyboardShortcut("k", modifiers: .command) + .hidden() + } + } + + @ViewBuilder + private var marketplaceOverlay: some View { + if windowState.showMarketplace { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { + windowState.showMarketplace = false } + } + SkillMarketView() + .focusable(false) + .transition(.asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .opacity + )) + } + } + } - ToolbarItem(placement: .navigation) { - Text(navigationTitleText) - .padding(.trailing) + @ViewBuilder + private var globalSearchOverlay: some View { + if windowState.showGlobalSearch { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { + windowState.showGlobalSearch = false } } + GlobalSearchOverlay() + .transition(.asymmetric( + insertion: .scale(scale: 0.97).combined(with: .opacity), + removal: .opacity + )) + } + .animation(.spring(response: 0.25, dampingFraction: 0.9), value: windowState.showGlobalSearch) + } + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + if columnVisibility != .detailOnly { + ToolbarItem(placement: .navigation) { + Button { + showGitHubSheet = true + } label: { + Image("GitHubMark") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) } + .help(appState.isLoggedIn ? "Manage GitHub Repos" : "Connect GitHub") + } - if inspectorStarted { - RightInspectorPanel() + ToolbarItem(placement: .navigation) { + Button { + showFilePicker = true + } label: { + Image(systemName: "plus") + } + .help("Add Project") + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [.folder], + allowsMultipleSelection: false + ) { result in + handleFolderSelection(result) + } + } + + ToolbarItem(placement: .navigation) { + Button { + windowState.showGlobalSearch = true + } label: { + Image(systemName: "magnifyingglass") } + .help("Search Threads (⌘K)") + } + + ToolbarItem(placement: .navigation) { + ThreadTitlePopoverButton(title: navigationTitleText) } } } @@ -183,7 +213,7 @@ struct MainView: View { ProjectTreeView() } .background(ClaudeTheme.sidebarBackground.ignoresSafeArea()) - .navigationSplitViewColumnWidth(min: 220, ideal: 280, max: 360) + .navigationSplitViewColumnWidth(min: 300, ideal: 320, max: 400) .sheet(isPresented: $showGitHubSheet) { GitHubSheet() } @@ -235,6 +265,13 @@ struct MainView: View { .frame(minWidth: 1000, idealWidth: 1400, maxWidth: 1920, minHeight: 600, idealHeight: 1000, maxHeight: 1200) } + .sheet(isPresented: Bindable(windowState).showRunConfigurations) { + if let project = windowState.selectedProject { + RunConfigurationsView(project: project) + .environment(appState) + .environment(windowState) + } + } .alert("Error", isPresented: Bindable(windowState).showError) { Button("OK", role: .cancel) {} } message: { @@ -800,13 +837,6 @@ struct ChatDetailModifiers: ViewModifier { .sheet(item: Bindable(windowState).interactiveTerminal) { terminal in InteractiveTerminalPopup(state: terminal) } - .sheet(isPresented: Bindable(windowState).showRunConfigurations) { - if let project = windowState.selectedProject { - RunConfigurationsView(project: project) - .environment(appState) - .environment(windowState) - } - } } } diff --git a/RxCode/Views/ProjectWindowView.swift b/RxCode/Views/ProjectWindowView.swift index 9e08960a..eb4b773e 100644 --- a/RxCode/Views/ProjectWindowView.swift +++ b/RxCode/Views/ProjectWindowView.swift @@ -150,7 +150,11 @@ struct ProjectWindowView: View { } } .toolbarBackground(.hidden, for: .windowToolbar) + .toolbar(removing: .title) .toolbar { + ToolbarItem(placement: .navigation) { + ThreadTitlePopoverButton(title: navigationTitleText) + } RxCodeToolbarContent() } .background(UnifiedTitleBarAccessor()) diff --git a/RxCode/Views/RunProfile/RunConfigurationsView.swift b/RxCode/Views/RunProfile/RunConfigurationsView.swift index 17b56cdb..6c9da425 100644 --- a/RxCode/Views/RunProfile/RunConfigurationsView.swift +++ b/RxCode/Views/RunProfile/RunConfigurationsView.swift @@ -95,14 +95,29 @@ struct RunConfigurationsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(selection: $selectedId) { - Section("Bash") { - ForEach(draft) { profile in - HStack { - Image(systemName: "terminal") - .foregroundStyle(ClaudeTheme.accent) - Text(profile.name.isEmpty ? "Untitled" : profile.name) + let xcodeProfiles = draft.filter { $0.type == .xcode } + let makeProfiles = draft.filter { $0.type == .make } + let bashProfiles = draft.filter { $0.type == .bash } + + if !xcodeProfiles.isEmpty { + Section("Xcode") { + ForEach(xcodeProfiles) { profile in + profileRow(profile) + } + } + } + if !makeProfiles.isEmpty { + Section("Make") { + ForEach(makeProfiles) { profile in + profileRow(profile) + } + } + } + if !bashProfiles.isEmpty { + Section("Bash") { + ForEach(bashProfiles) { profile in + profileRow(profile) } - .tag(profile.id) } } } @@ -145,47 +160,69 @@ struct RunConfigurationsView: View { .padding(.vertical, 10) } + @ViewBuilder + private func profileRow(_ profile: RunProfile) -> some View { + HStack { + Image(systemName: iconName(for: profile.type)) + .foregroundStyle(ClaudeTheme.accent) + Text(profile.name.isEmpty ? "Untitled" : profile.name) + } + .tag(profile.id) + } + + private func iconName(for type: RunProfileType) -> String { + switch type { + case .xcode: return "hammer.fill" + case .make: return "wrench.and.screwdriver.fill" + case .bash: return "terminal" + } + } + // MARK: - Add menu (detected runnables) private var addMenu: some View { Menu { - Button("Empty Bash Configuration") { addProfile() } - - if !detected.xcode.isEmpty { - Divider() - Section("Xcode Schemes") { - ForEach(detected.xcode) { runnable in - Button { - addProfile(from: runnable) - } label: { - Label(runnable.displayName, systemImage: "hammer.fill") - } + Section("Xcode") { + Button { + addXcodeProfile() + } label: { + Label("Empty Xcode Configuration", systemImage: "hammer.fill") + } + ForEach(detected.xcode) { runnable in + Button { + addProfile(from: runnable) + } label: { + Label(runnable.displayName, systemImage: "hammer.fill") } } } - if !detected.npm.isEmpty { - Divider() - Section("npm Scripts") { - ForEach(detected.npm) { runnable in - Button { - addProfile(from: runnable) - } label: { - Label(runnable.displayName, systemImage: "shippingbox.fill") - } + Section("Make") { + Button { + addMakeProfile() + } label: { + Label("Empty Make Configuration", systemImage: "wrench.and.screwdriver.fill") + } + ForEach(detected.make) { runnable in + Button { + addProfile(from: runnable) + } label: { + Label(runnable.displayName, systemImage: "wrench.and.screwdriver.fill") } } } - if !detected.make.isEmpty { - Divider() - Section("Makefile Targets") { - ForEach(detected.make) { runnable in - Button { - addProfile(from: runnable) - } label: { - Label(runnable.displayName, systemImage: "wrench.and.screwdriver.fill") - } + Section("Bash") { + Button { + addProfile() + } label: { + Label("Empty Bash Configuration", systemImage: "terminal") + } + ForEach(detected.npm) { runnable in + Button { + addProfile(from: runnable) + } label: { + Label(runnable.displayName, systemImage: "shippingbox.fill") } } } @@ -202,6 +239,32 @@ struct RunConfigurationsView: View { private func addProfile(from runnable: DetectedRunnable? = nil) { let now = Date() + if let xcode = runnable?.xcode { + let new = RunProfile( + projectId: project.id, + name: runnable?.displayName ?? "New Xcode Configuration", + type: .xcode, + xcode: xcode, + createdAt: now, + updatedAt: now + ) + draft.append(new) + selectedId = new.id + return + } + if let make = runnable?.make { + let new = RunProfile( + projectId: project.id, + name: runnable?.displayName ?? "New Make Configuration", + type: .make, + make: make, + createdAt: now, + updatedAt: now + ) + draft.append(new) + selectedId = new.id + return + } let new = RunProfile( projectId: project.id, name: runnable?.displayName ?? "New Bash Configuration", @@ -213,6 +276,36 @@ struct RunConfigurationsView: View { selectedId = new.id } + private func addXcodeProfile() { + let now = Date() + let firstDetected = detected.xcode.first?.xcode + let new = RunProfile( + projectId: project.id, + name: "New Xcode Configuration", + type: .xcode, + xcode: firstDetected ?? XcodeRunConfig(), + createdAt: now, + updatedAt: now + ) + draft.append(new) + selectedId = new.id + } + + private func addMakeProfile() { + let now = Date() + let firstDetected = detected.make.first?.make + let new = RunProfile( + projectId: project.id, + name: "New Make Configuration", + type: .make, + make: firstDetected ?? MakeRunConfig(), + createdAt: now, + updatedAt: now + ) + draft.append(new) + selectedId = new.id + } + private func deleteSelected() { guard let idx = selectedIndex else { return } let removed = draft.remove(at: idx) @@ -260,43 +353,33 @@ private struct RunProfileDetailForm: View { Form { Section { TextField("Name", text: $profile.name) - Picker("Type", selection: $profile.type) { + Picker("Type", selection: Binding( + get: { profile.type }, + set: { newValue in + profile.type = newValue + if newValue == .xcode, profile.xcode == nil { + profile.xcode = XcodeRunConfig() + } + if newValue == .make, profile.make == nil { + profile.make = MakeRunConfig() + } + } + )) { ForEach(RunProfileType.allCases, id: \.self) { type in Text(type.rawValue.capitalized).tag(type) } } } header: { Text("Configuration") - } footer: { - Text("Only Bash is supported for now.") } - Section { - TextEditor(text: $profile.bash.command) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 100) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) - ) - HStack { - TextField("Working Directory", text: $profile.bash.workingDirectory, prompt: Text(project.path)) - Button("Browse…") { - pickDirectory { picked in - profile.bash.workingDirectory = picked - } - } - Button { - profile.bash.workingDirectory = "" - } label: { - Image(systemName: "arrow.uturn.backward") - } - .help("Reset to project root") - } - } header: { - Text("Command") - } footer: { - Text("Absolute or project-relative path. Leave empty to use the project root.") + switch profile.type { + case .bash: + bashCommandSection + case .xcode: + xcodeCommandSection + case .make: + makeCommandSection } environmentsSection @@ -314,6 +397,166 @@ private struct RunProfileDetailForm: View { .formStyle(.grouped) } + // MARK: - Command sections + + @ViewBuilder + private var bashCommandSection: some View { + Section { + TextEditor(text: $profile.bash.command) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 100) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) + ) + HStack { + TextField("Working Directory", text: $profile.bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + profile.bash.workingDirectory = picked + } + } + Button { + profile.bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Command") + } footer: { + Text("Absolute or project-relative path. Leave empty to use the project root.") + } + } + + @ViewBuilder + private var xcodeCommandSection: some View { + Section { + let xcode = Binding( + get: { profile.xcode ?? XcodeRunConfig() }, + set: { profile.xcode = $0 } + ) + HStack { + TextField( + "Project / Workspace", + text: xcode.container, + prompt: Text("RxCode.xcodeproj") + ) + .font(.system(.body, design: .monospaced)) + Button("Browse…") { + pickXcodeContainer { name, isWorkspace in + var c = xcode.wrappedValue + c.container = name + c.isWorkspace = isWorkspace + xcode.wrappedValue = c + } + } + } + Toggle("Use Workspace (.xcworkspace)", isOn: xcode.isWorkspace) + TextField("Scheme", text: xcode.scheme, prompt: Text("RxCode")) + .font(.system(.body, design: .monospaced)) + TextField("Configuration", text: xcode.configuration, prompt: Text("Debug")) + .font(.system(.body, design: .monospaced)) + Picker("Action", selection: xcode.action) { + ForEach(XcodeAction.allCases, id: \.self) { action in + Text(action.rawValue.capitalized).tag(action) + } + } + TextField( + "Destination (optional)", + text: xcode.destination, + prompt: Text("platform=macOS") + ) + .font(.system(.body, design: .monospaced)) + } header: { + Text("Xcode") + } footer: { + Text("Build runs `xcodebuild build`. Run builds, then launches the produced .app. Working directory is always the project root.") + } + } + + @ViewBuilder + private var makeCommandSection: some View { + Section { + let make = Binding( + get: { profile.make ?? MakeRunConfig() }, + set: { profile.make = $0 } + ) + HStack { + TextField( + "Makefile (optional)", + text: make.makefile, + prompt: Text("Makefile") + ) + .font(.system(.body, design: .monospaced)) + Button("Browse…") { + pickFile { picked in + var c = make.wrappedValue + c.makefile = picked + make.wrappedValue = c + } + } + Button { + var c = make.wrappedValue + c.makefile = "" + make.wrappedValue = c + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Use default Makefile lookup") + } + TextField("Target", text: make.target, prompt: Text("build")) + .font(.system(.body, design: .monospaced)) + TextField( + "Arguments (optional)", + text: make.arguments, + prompt: Text("VAR=value -j8") + ) + .font(.system(.body, design: .monospaced)) + HStack { + TextField("Working Directory", text: $profile.bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + profile.bash.workingDirectory = picked + } + } + Button { + profile.bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Make") + } footer: { + Text("Runs `make [-f ] [arguments]`. Leave Makefile empty to use the default lookup (Makefile / makefile / GNUmakefile).") + } + } + + private func pickXcodeContainer(onPick: @escaping (String, Bool) -> Void) { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: project.path) + panel.allowedContentTypes = [ + UTType(filenameExtension: "xcodeproj") ?? .package, + UTType(filenameExtension: "xcworkspace") ?? .package, + ] + guard panel.runModal() == .OK, let url = panel.url else { return } + let name: String + let root = (project.path as NSString).standardizingPath + let std = (url.path as NSString).standardizingPath + if std.hasPrefix(root + "/") { + name = String(std.dropFirst(root.count + 1)) + } else { + name = std + } + onPick(name, url.pathExtension == "xcworkspace") + } + // MARK: - Environments @State private var newPresetName: String = "" diff --git a/RxCode/Views/Search/GlobalSearchOverlay.swift b/RxCode/Views/Search/GlobalSearchOverlay.swift index 90db1a3a..c31f75a7 100644 --- a/RxCode/Views/Search/GlobalSearchOverlay.swift +++ b/RxCode/Views/Search/GlobalSearchOverlay.swift @@ -13,8 +13,26 @@ struct GlobalSearchOverlay: View { @State private var inThreadHits: [ThreadSearchService.InThreadHit] = [] @State private var isSearching: Bool = false @State private var hasSearched: Bool = false + @State private var selectedIndex: Int = 0 @FocusState private var inputFocused: Bool + /// Flattened selectable rows in display order. Drives arrow-key navigation + /// and Enter activation; recomputed on every render so it stays in sync + /// with the current results. + private enum SelectableRow: Hashable { + case inThread(UUID) + case thread(String) + } + + private var flatRows: [SelectableRow] { + var rows: [SelectableRow] = [] + for hit in inThreadHits { rows.append(.inThread(hit.id)) } + for group in groups { + for hit in group.hits { rows.append(.thread(hit.threadId)) } + } + return rows + } + private var hasResults: Bool { !groups.isEmpty || !inThreadHits.isEmpty } @@ -47,9 +65,24 @@ struct GlobalSearchOverlay: View { close() return .handled } + .onKeyPress(.downArrow) { + moveSelection(by: 1) + return .handled + } + .onKeyPress(.upArrow) { + moveSelection(by: -1) + return .handled + } + .onKeyPress(.return) { + activateSelection() + return .handled + } .task(id: query) { await runSearch() } + .onChange(of: query) { _, _ in selectedIndex = 0 } + .onChange(of: inThreadHits) { _, _ in clampSelection() } + .onChange(of: groups) { _, _ in clampSelection() } } // MARK: - Search field @@ -100,17 +133,25 @@ struct GlobalSearchOverlay: View { subtitle: "Try a different phrasing, or wait — the backfill may still be indexing older threads." ) } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 12) { - if !inThreadHits.isEmpty { - currentThreadSection + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + if !inThreadHits.isEmpty { + currentThreadSection + } + ForEach(groups, id: \.projectId) { group in + projectSection(group) + } } - ForEach(groups, id: \.projectId) { group in - projectSection(group) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } + .onChange(of: selectedIndex) { _, _ in + guard let row = currentSelection() else { return } + withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { + proxy.scrollTo(row, anchor: .center) } } - .padding(.horizontal, 12) - .padding(.vertical, 12) } } } @@ -150,7 +191,9 @@ struct GlobalSearchOverlay: View { } private func inThreadRow(hit: ThreadSearchService.InThreadHit) -> some View { - Button { + let row: SelectableRow = .inThread(hit.id) + let isSelected = currentSelection() == row + return Button { close() } label: { HStack(alignment: .top, spacing: 10) { @@ -177,13 +220,11 @@ struct GlobalSearchOverlay: View { } .padding(.horizontal, 10) .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .fill(ClaudeTheme.surfacePrimary) - ) + .background(rowBackground(isSelected: isSelected)) .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) } .buttonStyle(.plain) + .id(row) } private func highlightedSnippet(hit: ThreadSearchService.InThreadHit) -> AttributedString { @@ -248,6 +289,8 @@ struct GlobalSearchOverlay: View { let snippet = displaySnippet(hit: hit, title: title) let threadSummary = appState.threadStore.threadSummaryItem(sessionId: hit.threadId)?.summary .trimmingCharacters(in: .whitespacesAndNewlines) + let row: SelectableRow = .thread(hit.threadId) + let isSelected = currentSelection() == row return Button { select(hit: hit) } label: { @@ -294,13 +337,20 @@ struct GlobalSearchOverlay: View { } .padding(.horizontal, 10) .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .fill(ClaudeTheme.surfacePrimary) - ) + .background(rowBackground(isSelected: isSelected)) .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) } .buttonStyle(.plain) + .id(row) + } + + private func rowBackground(isSelected: Bool) -> some View { + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(isSelected ? ClaudeTheme.accent.opacity(0.14) : ClaudeTheme.surfacePrimary) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder(isSelected ? ClaudeTheme.accent.opacity(0.55) : .clear, lineWidth: 1) + ) } /// Show the cosine similarity as a 0–100 chip. Negative scores clamp to 0; @@ -363,6 +413,45 @@ struct GlobalSearchOverlay: View { windowState.showGlobalSearch = false } + private func currentSelection() -> SelectableRow? { + let rows = flatRows + guard !rows.isEmpty else { return nil } + let clamped = max(0, min(selectedIndex, rows.count - 1)) + return rows[clamped] + } + + private func moveSelection(by delta: Int) { + let rows = flatRows + guard !rows.isEmpty else { return } + let next = selectedIndex + delta + selectedIndex = max(0, min(next, rows.count - 1)) + } + + private func clampSelection() { + let rows = flatRows + if rows.isEmpty { + selectedIndex = 0 + } else if selectedIndex >= rows.count { + selectedIndex = rows.count - 1 + } else if selectedIndex < 0 { + selectedIndex = 0 + } + } + + private func activateSelection() { + guard let row = currentSelection() else { return } + switch row { + case .inThread: + close() + case .thread(let threadId): + let id = threadId + close() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + appState.selectSession(id: id, in: windowState) + } + } + } + private func select(hit: ThreadSearchService.Hit) { let id = hit.threadId close() diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 4338797b..4e7ed174 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -94,6 +94,8 @@ struct GeneralSettingsTab: View { Divider() menuBarSection Divider() + searchIndexSection + Divider() VStack(alignment: .leading, spacing: 8) { skillMarketSection onboardingSection @@ -106,6 +108,44 @@ struct GeneralSettingsTab: View { } } + // MARK: - Search Index Section + + private var searchIndexSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Search Index") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Reindex all threads") + .font(.system(size: ClaudeTheme.size(13))) + Text("Wipe cached embeddings and re-embed every thread for semantic search. Use this if global search results look stale or empty.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + Button { + Task { await appState.reindexAllThreads() } + } label: { + if let progress = appState.reindexProgress { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + if progress.total > 0 { + Text(verbatim: "\(progress.done)/\(progress.total)") + .font(.system(size: ClaudeTheme.size(12))) + .monospacedDigit() + } + } + } else { + Text("Reindex Now") + } + } + .disabled(appState.reindexProgress != nil) + } + } + } + // MARK: - Menu Bar Section private var menuBarSection: some View { diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index b7b1937d..1e4d7391 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -6,88 +6,125 @@ struct BriefingView: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState - @State private var currentBranch: String? + /// Selected project ids for filtering. Empty = show every project. + @State private var selectedProjectIds: Set = [] + + private struct BriefingGroup: Identifiable { + let projectId: UUID + let branch: String + let briefing: BranchBriefingItem? + let threadSummaries: [ThreadSummaryItem] + let updatedAt: Date + var id: String { "\(projectId.uuidString)::\(branch)" } + } - private var project: Project? { - windowState.selectedProject + private var projectsById: [UUID: Project] { + Dictionary(uniqueKeysWithValues: appState.projects.map { ($0.id, $0) }) } - private var briefing: BranchBriefingItem? { + private var groups: [BriefingGroup] { _ = appState.branchBriefingRevision - guard let project, let currentBranch else { return nil } - return appState.threadStore.branchBriefingItem(projectId: project.id, branch: currentBranch) + _ = appState.threadSummaryRevision + + let briefings = appState.threadStore.allBranchBriefingItems() + let summaries = appState.threadStore.allThreadSummaryItems() + + struct Bucket { + var projectId: UUID + var branch: String + var briefing: BranchBriefingItem? + var threads: [ThreadSummaryItem] + var updated: Date + } + + var bucket: [String: Bucket] = [:] + for b in briefings { + let key = "\(b.projectId.uuidString)::\(b.branch)" + var entry = bucket[key] ?? Bucket(projectId: b.projectId, branch: b.branch, briefing: nil, threads: [], updated: .distantPast) + entry.briefing = b + if b.updatedAt > entry.updated { entry.updated = b.updatedAt } + bucket[key] = entry + } + for s in summaries { + let key = "\(s.projectId.uuidString)::\(s.branch)" + var entry = bucket[key] ?? Bucket(projectId: s.projectId, branch: s.branch, briefing: nil, threads: [], updated: .distantPast) + entry.threads.append(s) + if s.updatedAt > entry.updated { entry.updated = s.updatedAt } + bucket[key] = entry + } + + let all = bucket.values + .map { + BriefingGroup( + projectId: $0.projectId, + branch: $0.branch, + briefing: $0.briefing, + threadSummaries: $0.threads.sorted { $0.updatedAt > $1.updatedAt }, + updatedAt: $0.updated + ) + } + .sorted { $0.updatedAt > $1.updatedAt } + + if selectedProjectIds.isEmpty { + return all + } + return all.filter { selectedProjectIds.contains($0.projectId) } } - private var threadSummaries: [ThreadSummaryItem] { + /// Projects that actually have at least one briefing or summary recorded. + private var projectsWithData: [Project] { + _ = appState.branchBriefingRevision _ = appState.threadSummaryRevision - guard let project, let currentBranch else { return [] } - return appState.threadStore.threadSummaryItems(projectId: project.id, branch: currentBranch) + let ids = Set( + appState.threadStore.allBranchBriefingItems().map(\.projectId) + + appState.threadStore.allThreadSummaryItems().map(\.projectId) + ) + return appState.projects.filter { ids.contains($0.id) } } var body: some View { Group { - if let project { - projectBriefing(project) - } else { + if groups.isEmpty { emptyState( - icon: "folder", - title: "Select a Project", - message: "Choose a project to view its branch briefing." + icon: "text.page", + title: "No Briefings Yet", + message: "Briefings appear after a thread finishes on a project branch." ) .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + content } } .background(ClaudeTheme.background) - .task(id: project?.path) { - while !Task.isCancelled { - await refreshBranch() - try? await Task.sleep(nanoseconds: 5_000_000_000) - } - } } - private func projectBriefing(_ project: Project) -> some View { + private var content: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - hero(project) - - if currentBranch == nil { - emptyState( - icon: "arrow.triangle.branch", - title: "No Git Branch", - message: "This project does not have a detectable current branch." - ) - .frame(maxWidth: .infinity) - .padding(.top, 24) - } else if briefing == nil, threadSummaries.isEmpty { - emptyState( - icon: "text.page", - title: "No Briefing Yet", - message: "Briefing updates after a thread finishes on this branch." - ) - .frame(maxWidth: .infinity) - .padding(.top, 24) - } else { - if let briefing { - briefingCard(briefing) - } - - if !threadSummaries.isEmpty { - threadSection(threadSummaries) + hero + filterBar + + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 380, maximum: 560), spacing: 16, alignment: .top)], + alignment: .leading, + spacing: 16 + ) { + ForEach(groups) { group in + groupCard(group) } } } .padding(.horizontal, 28) .padding(.top, 24) .padding(.bottom, 40) - .frame(maxWidth: 1200, alignment: .leading) + .frame(maxWidth: 1400, alignment: .leading) .frame(maxWidth: .infinity, alignment: .topLeading) } } // MARK: - Hero - private func hero(_ project: Project) -> some View { + private var hero: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .center, spacing: 12) { ZStack { @@ -100,200 +137,288 @@ struct BriefingView: View { .frame(width: 38, height: 38) VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .center, spacing: 10) { - Text(project.name) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(ClaudeTheme.textPrimary) - .lineLimit(1) - .truncationMode(.middle) - - startNewChatButton(for: project) - } - Text("A running summary of work on this branch.") + Text("Briefings") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + Text(heroSubtitle) .font(.system(size: 12)) .foregroundStyle(ClaudeTheme.textTertiary) } Spacer(minLength: 0) } + } + } + + private var heroSubtitle: String { + let count = groups.count + let branches = count == 1 ? "branch" : "branches" + let projectCount = Set(groups.map(\.projectId)).count + let projects = projectCount == 1 ? "project" : "projects" + if selectedProjectIds.isEmpty { + return "\(count) \(branches) across \(projectCount) \(projects)." + } + return "\(count) \(branches) across \(projectCount) selected \(projects)." + } - HStack(spacing: 8) { - chip(icon: "folder.fill", text: project.name) - if let currentBranch { - chip(icon: "arrow.triangle.branch", text: currentBranch, accented: true) - } else { - chip(icon: "arrow.triangle.branch", text: "No branch") + // MARK: - Filter bar + + private var filterBar: some View { + let projects = projectsWithData + return Group { + if projects.count > 1 { + Menu { + Button { + selectedProjectIds.removeAll() + } label: { + Label("All projects", systemImage: selectedProjectIds.isEmpty ? "checkmark" : "") + } + Divider() + ForEach(projects) { project in + Button { + toggleProject(project.id) + } label: { + Label( + project.name, + systemImage: selectedProjectIds.contains(project.id) ? "checkmark" : "" + ) + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 11, weight: .semibold)) + Text(filterMenuLabel(projects: projects)) + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(selectedProjectIds.isEmpty ? ClaudeTheme.textSecondary : ClaudeTheme.textOnAccent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule(style: .continuous) + .fill(selectedProjectIds.isEmpty ? ClaudeTheme.surfaceSecondary : ClaudeTheme.accent) + ) + .overlay( + Capsule(style: .continuous) + .strokeBorder( + selectedProjectIds.isEmpty + ? ClaudeTheme.border.opacity(0.6) + : ClaudeTheme.accent.opacity(0.4), + lineWidth: 0.5 + ) + ) } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() } } } - private func startNewChatButton(for project: Project) -> some View { - Button { - if windowState.selectedProject?.id != project.id { - appState.selectProject(project, in: windowState) - } - appState.startNewChat(in: windowState) - } label: { - HStack(spacing: 5) { - Image(systemName: "plus.bubble.fill") - .font(.system(size: 11, weight: .semibold)) - Text("Start New Chat") - .font(.system(size: 12, weight: .semibold)) - } - .foregroundStyle(ClaudeTheme.textOnAccent) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background( - Capsule(style: .continuous).fill(ClaudeTheme.accent) - ) + private func filterMenuLabel(projects: [Project]) -> String { + if selectedProjectIds.isEmpty { + return "All projects" } - .buttonStyle(.plain) - .help("Start a new chat in \(project.name)") + if selectedProjectIds.count == 1, let id = selectedProjectIds.first { + return projectsById[id]?.name ?? "1 project" + } + return "\(selectedProjectIds.count) projects" } - private func chip(icon: String, text: String, accented: Bool = false) -> some View { - HStack(spacing: 5) { - Image(systemName: icon) - .font(.system(size: 10, weight: .semibold)) - Text(text) - .font(.system(size: 11, weight: .medium)) - .lineLimit(1) - .truncationMode(.middle) + private func toggleProject(_ id: UUID) { + if selectedProjectIds.contains(id) { + selectedProjectIds.remove(id) + } else { + selectedProjectIds.insert(id) } - .foregroundStyle(accented ? ClaudeTheme.accent : ClaudeTheme.textSecondary) - .padding(.horizontal, 9) - .padding(.vertical, 4) + } + + // MARK: - Group card + + private func groupCard(_ group: BriefingGroup) -> some View { + let project = projectsById[group.projectId] + return VStack(alignment: .leading, spacing: 12) { + groupCardHeader(group, project: project) + + if let briefing = group.briefing { + Divider().opacity(0.4) + BriefingMarkdownView(text: briefing.briefing, fontSize: 12.5) + } + + if !group.threadSummaries.isEmpty { + Divider().opacity(0.4) + threadList(group.threadSummaries) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) .background( - Capsule(style: .continuous) - .fill(accented ? ClaudeTheme.accent.opacity(0.12) : ClaudeTheme.surfaceSecondary) + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) + .fill(ClaudeTheme.surfacePrimary) ) .overlay( - Capsule(style: .continuous) - .strokeBorder(accented ? ClaudeTheme.accent.opacity(0.25) : ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) + .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) ) + .shadow(color: Color.black.opacity(0.03), radius: 2, x: 0, y: 1) } - // MARK: - Briefing Card - - private func briefingCard(_ item: BranchBriefingItem) -> some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .center, spacing: 8) { - Image(systemName: "sparkles") - .font(.system(size: 11, weight: .semibold)) + private func groupCardHeader(_ group: BriefingGroup, project: Project?) -> some View { + HStack(alignment: .center, spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(ClaudeTheme.accent.opacity(0.12)) + Image(systemName: "folder.fill") + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(ClaudeTheme.accent) - Text("General") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(ClaudeTheme.textTertiary) - .textCase(.uppercase) - .tracking(0.6) + } + .frame(width: 28, height: 28) - Spacer() + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(project?.name ?? "Unknown project") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) - Text("Updated \(Self.compactDate(item.updatedAt))") - .font(.system(size: 11, weight: .medium)) + chip(icon: "arrow.triangle.branch", text: group.branch, accented: true) + } + Text("Updated \(Self.compactDate(group.updatedAt))") + .font(.system(size: 10.5, weight: .medium)) .foregroundStyle(ClaudeTheme.textTertiary) } - BriefingMarkdownView(text: item.briefing, fontSize: 13.5) + Spacer(minLength: 0) + + if let project { + cardMenu(for: project) + } + } + } + + private func cardMenu(for project: Project) -> some View { + Menu { + Button { + if windowState.selectedProject?.id != project.id { + appState.selectProject(project, in: windowState) + } + appState.startNewChat(in: windowState) + } label: { + Label("Start New Chat", systemImage: "plus.bubble.fill") + } + + Button { + if windowState.selectedProject?.id != project.id { + appState.selectProject(project, in: windowState) + } + } label: { + Label("Open Project", systemImage: "folder") + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + .frame(width: 24, height: 22) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(ClaudeTheme.surfaceSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + ) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Actions for \(project.name)") + } + + private func chip(icon: String, text: String, accented: Bool = false) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 9, weight: .semibold)) + Text(text) + .font(.system(size: 10.5, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(accented ? ClaudeTheme.accent : ClaudeTheme.textSecondary) + .padding(.horizontal, 7) + .padding(.vertical, 2) .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) - .fill(ClaudeTheme.surfacePrimary) + Capsule(style: .continuous) + .fill(accented ? ClaudeTheme.accent.opacity(0.12) : ClaudeTheme.surfaceSecondary) ) .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge, style: .continuous) - .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + Capsule(style: .continuous) + .strokeBorder(accented ? ClaudeTheme.accent.opacity(0.25) : ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) ) } - // MARK: - Thread Summaries + // MARK: - Thread list (compact rows) - private func threadSection(_ items: [ThreadSummaryItem]) -> some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("Thread Summaries") - .font(.system(size: 11, weight: .semibold)) + private func threadList(_ items: [ThreadSummaryItem]) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("Threads") + .font(.system(size: 10.5, weight: .semibold)) .foregroundStyle(ClaudeTheme.textTertiary) .textCase(.uppercase) .tracking(0.6) - Text("\(items.count)") - .font(.system(size: 11, weight: .semibold).monospacedDigit()) + .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) .foregroundStyle(ClaudeTheme.textTertiary) - .padding(.horizontal, 6) + .padding(.horizontal, 5) .padding(.vertical, 1) - .background( - Capsule().fill(ClaudeTheme.surfaceSecondary) - ) - + .background(Capsule().fill(ClaudeTheme.surfaceSecondary)) Spacer() } - .padding(.horizontal, 2) - - LazyVGrid( - columns: [GridItem(.adaptive(minimum: 320, maximum: 480), spacing: 12, alignment: .top)], - alignment: .leading, - spacing: 12 - ) { - ForEach(items) { item in - threadCard(item) - } + .padding(.bottom, 2) + + ForEach(items) { item in + threadRow(item) } } } - private func threadCard(_ item: ThreadSummaryItem) -> some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 8) { - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(ClaudeTheme.accent.opacity(0.12)) - Image(systemName: "bubble.left.and.text.bubble.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(ClaudeTheme.accent) - } - .frame(width: 22, height: 22) + private func threadRow(_ item: ThreadSummaryItem) -> some View { + Button { + appState.selectSession(id: item.sessionId, in: windowState) + } label: { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "bubble.left.and.text.bubble.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ClaudeTheme.accent) + .frame(width: 14) Text(item.title) - .font(.system(size: 13.5, weight: .semibold)) + .font(.system(size: 12.5, weight: .medium)) .foregroundStyle(ClaudeTheme.textPrimary) - .lineLimit(2) - .multilineTextAlignment(.leading) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) - } - - Divider() - .opacity(0.4) - - BriefingMarkdownView(text: item.summary, fontSize: 12.5) - .frame(maxWidth: .infinity, alignment: .leading) - Spacer(minLength: 0) - - HStack(spacing: 6) { - Image(systemName: "clock") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(ClaudeTheme.textTertiary) Text(Self.compactDate(item.updatedAt)) - .font(.system(size: 11, weight: .medium)) + .font(.system(size: 10.5, weight: .medium)) .foregroundStyle(ClaudeTheme.textTertiary) - Spacer() + .lineLimit(1) } + .padding(.horizontal, 6) + .padding(.vertical, 5) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(ClaudeTheme.surfaceSecondary.opacity(0.4)) + ) } - .padding(14) - .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium, style: .continuous) - .fill(ClaudeTheme.surfacePrimary) - ) - .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium, style: .continuous) - .strokeBorder(ClaudeTheme.border.opacity(0.55), lineWidth: 0.5) - ) - .shadow(color: Color.black.opacity(0.03), radius: 2, x: 0, y: 1) + .buttonStyle(.plain) + .help("Open thread") } // MARK: - Empty State @@ -320,20 +445,6 @@ struct BriefingView: View { .padding(.vertical, 60) } - private func refreshBranch() async { - guard let project else { - await MainActor.run { currentBranch = nil } - return - } - let branch = await GitHelper.currentBranch(at: project.path) - await MainActor.run { - currentBranch = branch - if let branch { - appState.touchBranchBriefing(projectId: project.id, branch: branch) - } - } - } - private static func compactDate(_ date: Date) -> String { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .short diff --git a/RxCode/Views/Sidebar/HistoryListView.swift b/RxCode/Views/Sidebar/HistoryListView.swift index 002a066a..52692d30 100644 --- a/RxCode/Views/Sidebar/HistoryListView.swift +++ b/RxCode/Views/Sidebar/HistoryListView.swift @@ -1,5 +1,5 @@ -import SwiftUI import RxCodeCore +import SwiftUI struct HistoryListView: View { @Environment(AppState.self) private var appState