fix(chat): resume session on coordinator.selectedSessionId, not just pendingProjectChat

Clicking a session in the Projects Sessions tab routed to the
Chat section (correct — we want interactive resume, not the
read-only Sessions browser), but the session didn't actually
load and the project chip didn't appear. Root cause: ChatView
only observed `coordinator.pendingProjectChat` (for new chats),
not `selectedSessionId` (for resumes). Setting the id had no
effect because no consumer existed on the Chat side.

Every other session-click site in Scarf routes to `.sessions`,
and SessionsView consumes selectedSessionId at its `.task` +
clears it. Projects is the exception — the whole point of the
per-project Sessions tab is to resume chats interactively rather
than browse them, so we route to `.chat`. That routing was right;
the Chat side just needed to grow the symmetrical consumer.

This commit adds two handoff paths in ChatView (mirrors the
existing `pendingProjectChat` pattern):

- `.task` picks up a selectedSessionId that was set before
  ChatView mounted (cold-launch handoff from Projects).
- `.onChange(of: coord.selectedSessionId)` picks up mid-session
  navigation (user clicks a session while already in Chat).

Both call `viewModel.resumeSession(id)` then clear the coordinator
field. The project chip rendering + navTitle update then happen
automatically inside ChatViewModel.resumeSession ->
startACPSession, which already looks up attribution via
SessionAttributionService.projectPath(for: resolvedSessionId) —
that plumbing was in from Part B. The bug was entirely in the
trigger, not the side-effect.

`else if` between pendingProjectChat and selectedSessionId makes
precedence explicit — new-chat wins over resume if both are
somehow set. In practice only one is ever populated per
navigation, but the explicit ordering avoids surprise.

No race with SessionsView's own consumer: `coordinator.selectedSection`
ensures only one view is rendering at a time, and both consumers
clear the field on consume.

93/93 Swift tests still pass. No test change — this is a view-
wiring integration fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 01:34:19 +02:00
parent fb833d4a0a
commit 5ae8db25c3
@@ -40,6 +40,19 @@ struct ChatView: View {
coordinator.pendingProjectChat = nil
viewModel.startNewSession(projectPath: pending)
}
// Same story for resume-session handoff: the user clicked
// a session in the Projects Sessions tab (routes to `.chat`
// rather than `.sessions` so the chat actually reopens).
// SessionsView consumes `selectedSessionId` for its own
// routing; Chat now consumes it too. Mutually exclusive at
// any given render because only one section is active per
// `coordinator.selectedSection`. `else if` makes precedence
// explicit pendingProjectChat (new) outranks
// selectedSessionId (resume) when both are somehow set.
else if let pendingId = coordinator.selectedSessionId {
coordinator.selectedSessionId = nil
viewModel.resumeSession(pendingId)
}
}
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.loadRecentSessions() }
@@ -56,6 +69,17 @@ struct ChatView: View {
viewModel.startNewSession(projectPath: projectPath)
}
}
// Live handoff for resume: user clicked an existing session in
// the Projects Sessions tab while already in the Chat section
// (or switched back to Chat after). Project-chip rendering
// happens automatically inside ChatViewModel.resumeSession ->
// startACPSession via the attribution.projectPath(for:) lookup.
.onChange(of: coord.selectedSessionId) { _, new in
if let sessionId = new {
coordinator.selectedSessionId = nil
viewModel.resumeSession(sessionId)
}
}
}
/// Banner rendered between the toolbar and the chat area when either