feat(chat): show active-project indicator in SessionInfoBar + nav title

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 · <ProjectName>` 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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 01:00:07 +02:00
parent 5340e70dd3
commit e4920538d2
4 changed files with 77 additions and 2 deletions
@@ -41,6 +41,20 @@ final class ChatViewModel {
let richChatViewModel: RichChatViewModel let richChatViewModel: RichChatViewModel
private var coordinator: Coordinator? 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 · <Name>` 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 // ACP state
private var acpClient: ACPClient? private var acpClient: ACPClient?
private var acpEventTask: Task<Void, Never>? private var acpEventTask: Task<Void, Never>?
@@ -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 // Refresh session list so the new ACP session appears in the Resume menu
await loadRecentSessions() await loadRecentSessions()
@@ -22,7 +22,13 @@ struct ChatView: View {
// push the whole window past the screen. Same pattern as // push the whole window past the screen. Same pattern as
// the Sessions tab fix in the v2.3 branch. // the Sessions tab fix in the v2.3 branch.
.frame(maxWidth: .infinity, maxHeight: .infinity) .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 { .task {
await viewModel.loadRecentSessions() await viewModel.loadRecentSessions()
viewModel.refreshCredentialPreflight() viewModel.refreshCredentialPreflight()
@@ -17,7 +17,13 @@ struct RichChatView: View {
isWorking: richChat.isAgentWorking, isWorking: richChat.isAgentWorking,
acpInputTokens: richChat.acpInputTokens, acpInputTokens: richChat.acpInputTokens,
acpOutputTokens: richChat.acpOutputTokens, 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() Divider()
@@ -7,10 +7,28 @@ struct SessionInfoBar: View {
var acpInputTokens: Int = 0 var acpInputTokens: Int = 0
var acpOutputTokens: Int = 0 var acpOutputTokens: Int = 0
var acpThoughtTokens: 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 { var body: some View {
HStack(spacing: 16) { HStack(spacing: 16) {
if let session { 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) { HStack(spacing: 4) {
Circle() Circle()
.fill(isWorking ? .green : .secondary) .fill(isWorking ? .green : .secondary)