From e4920538d21cab0a38fa29270554fd2fae5717d3 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 24 Apr 2026 01:00:07 +0200 Subject: [PATCH] feat(chat): show active-project indicator in SessionInfoBar + nav title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a visible cue telling the user when their chat is scoped to a Scarf project. Two surfaces: - **SessionInfoBar** gets a folder-fill icon + project name chip at the start of the bar (before the working dot + title). Rendered with `.tint` foregroundStyle so it's visually anchored as the first piece of context. Hidden for non-project chats — the bar looks identical to v2.2.1 when projectName is nil. - **Navigation title** becomes `Chat · ` when scoped, stays as plain `Chat` otherwise. Matches macOS conventions for "subject — detail" titles. ChatViewModel gains two `@Observable` properties: - `currentProjectPath: String?` — absolute path, source of truth for attribution lookups - `currentProjectName: String?` — resolved via the projects registry at session-start; stored to avoid disk reads on every render. Falls back to the raw path (rather than nil) when a session's attribution points at a project no longer in the registry — the user still sees *something* rather than silently losing the indicator. Both are populated in `startACPSession(resume:projectPath:)` from two sources: 1. If the caller passed `projectPath` — fresh project-chat case 2. Otherwise, SessionAttributionService.projectPath(for: resolvedSessionId) — resumed-session case. Means clicking an old project-attributed session from ANY surface (the project's Sessions tab, the global Resume menu) re-surfaces the indicator. When the user starts a non-project session, both fields reset to nil explicitly so the indicator doesn't leak between chats. Files: - ChatViewModel.swift — new properties + resolve logic - SessionInfoBar.swift — new `projectName: String?` parameter + chip rendering - RichChatView.swift — passes chatViewModel.currentProjectName through to SessionInfoBar - ChatView.swift — navTitle reflects the active project 80/80 Swift tests still pass. Visual change only; no test change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Chat/ViewModels/ChatViewModel.swift | 45 +++++++++++++++++++ .../scarf/Features/Chat/Views/ChatView.swift | 8 +++- .../Features/Chat/Views/RichChatView.swift | 8 +++- .../Features/Chat/Views/SessionInfoBar.swift | 18 ++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 84a071d..539a915 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -41,6 +41,20 @@ final class ChatViewModel { let richChatViewModel: RichChatViewModel private var coordinator: Coordinator? + /// Absolute project path for the current session, when the chat is + /// project-scoped (either started via a project's "New Chat" button + /// or resumed from a session that was previously attributed via the + /// v2.3 sidecar). Nil for plain global chats. Drives the project + /// indicator in SessionInfoBar + the `Chat · ` nav title. + private(set) var currentProjectPath: String? + + /// Human-readable name of the active project, resolved from the + /// projects registry at session-start time. Stored alongside the + /// path so the view renders without hitting disk on every update. + /// Nil when `currentProjectPath` is nil OR the path isn't in the + /// registry (project was removed after the session was attributed). + private(set) var currentProjectName: String? + // ACP state private var acpClient: ACPClient? private var acpEventTask: Task? @@ -363,6 +377,37 @@ final class ChatViewModel { ) } + // Resolve which project (if any) this session belongs + // to, so SessionInfoBar + nav title can surface it. + // Two inputs — use whichever is non-nil: + // * `projectPath` — the caller asked for a project + // scope (fresh project chat). Just-attributed; + // definitely in the sidecar. + // * `attribution.projectPath(for: resolvedSessionId)` + // — the resumed session was previously attributed. + // Covers "click an old project-attributed session + // from the global Sessions sidebar / Resume menu" + // where projectPath isn't known at the call site. + let attributedPath = projectPath + ?? attribution.projectPath(for: resolvedSessionId) + if let path = attributedPath { + // Look up a human-readable name from the projects + // registry. Missing project (path in the sidecar, + // project since removed) → show the path as a + // fallback label so the chip still renders and the + // user sees *something* rather than silently losing + // the indicator. + let registry = ProjectDashboardService(context: context).loadRegistry() + let name = registry.projects.first(where: { $0.path == path })?.name + self.currentProjectPath = path + self.currentProjectName = name ?? path + } else { + // Explicit clear on non-project sessions so the + // indicator doesn't leak from a previous chat. + self.currentProjectPath = nil + self.currentProjectName = nil + } + // Refresh session list so the new ACP session appears in the Resume menu await loadRecentSessions() diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index d1e42c4..4bc10d3 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -22,7 +22,13 @@ struct ChatView: View { // push the whole window past the screen. Same pattern as // the Sessions tab fix in the v2.3 branch. .frame(maxWidth: .infinity, maxHeight: .infinity) - .navigationTitle("Chat") + // v2.3: reflect the active Scarf project in the nav title + // so the user can see at a glance that the chat is scoped + // (complements the folder chip in SessionInfoBar). Falls + // back to the plain "Chat" label for global chats. + .navigationTitle( + viewModel.currentProjectName.map { "Chat · \($0)" } ?? "Chat" + ) .task { await viewModel.loadRecentSessions() viewModel.refreshCredentialPreflight() diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index 8df5e64..b6b676d 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -17,7 +17,13 @@ struct RichChatView: View { isWorking: richChat.isAgentWorking, acpInputTokens: richChat.acpInputTokens, acpOutputTokens: richChat.acpOutputTokens, - acpThoughtTokens: richChat.acpThoughtTokens + acpThoughtTokens: richChat.acpThoughtTokens, + // v2.3: surface the active Scarf project (if any) as + // a folder chip at the start of the bar. Driven by + // ChatViewModel.currentProjectName which is set in + // startACPSession on both new project chats and + // resumed project-attributed sessions. + projectName: chatViewModel.currentProjectName ) Divider() diff --git a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift index e4c7a3a..b14a169 100644 --- a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift +++ b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift @@ -7,10 +7,28 @@ struct SessionInfoBar: View { var acpInputTokens: Int = 0 var acpOutputTokens: Int = 0 var acpThoughtTokens: Int = 0 + /// Name of the Scarf project this session is attributed to, when + /// applicable. Nil for plain global chats. Drives the folder-chip + /// indicator rendered before the session title. Resolved by + /// `ChatViewModel.currentProjectName` — the view just passes it + /// through. + var projectName: String? = nil var body: some View { HStack(spacing: 16) { if let session { + // Project indicator first — visually anchors the session + // as "scoped to project X" before the working dot and + // title. Hidden for non-project chats so the bar looks + // identical to v2.2.1 behavior. + if let projectName { + Label(projectName, systemImage: "folder.fill") + .font(.caption) + .foregroundStyle(.tint) + .lineLimit(1) + .help("Chat is scoped to Scarf project \"\(projectName)\"") + } + HStack(spacing: 4) { Circle() .fill(isWorking ? .green : .secondary)