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)