From 9a4473333b858dd95a198862ff7f9cb5ada42d60 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 24 Apr 2026 14:52:42 +0200 Subject: [PATCH] M7 #17 (pass-2): empty-transcript UX + defensive project chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass-2 observations: 1. Resumed sessions from Dashboard loaded into chat but showed no message history. 2. On sessions WITH a project badge, the chat nav-bar chip rendered the folder icon but no project name. **Root cause for (1)** — not actually an iOS bug. ACP-native sessions (the kind ScarfGo starts) don't persist their transcript to the client-visible `state.db` — only CLI/terminal sessions leave history there. Confirmed by direct SQLite inspection: the session IDs in Dashboard's Recent Sessions show `message_count = 0`; the sessions with lots of messages are all older CLI sessions. The Mac has this same limitation — just less visible because Mac's Sessions list surfaces CLI sessions preferentially. What we fix on the UX side: a friendlier empty state when a resumed session has no persisted transcript. Replaces the blank canvas with an icon + "Session resumed" + explanatory caption ("Hermes has the context for this session, but the transcript isn't cached locally. Send a message to continue.") Nudges the user toward the right mental model instead of leaving them wondering why their history vanished. Gated on `sessionId != nil` so fresh-chat empty state stays the same. **Root cause for (2)** — `ProjectEntry.name` shouldn't be empty, but a defensive treatment avoids ever surfacing a folder-only chip on edge cases (registry race, partial JSON decode). startResuming now: - Clears `currentProjectName` eagerly at the start of the resume flow so a lingering name from a prior session doesn't flash onto the new header. - Treats empty strings as nil when the lookup returns one. And the toolbar renderer adds a `!projectName.isEmpty` guard so an unexpected empty string never produces an icon-only chip. Co-Authored-By: Claude Opus 4.7 (1M context) --- scarf/Scarf iOS/Chat/ChatView.swift | 59 ++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 09f1e99..96ba5d6 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -56,7 +56,7 @@ struct ChatView: View { VStack(spacing: 1) { Text("Chat") .font(.headline) - if let projectName = controller.currentProjectName { + if let projectName = controller.currentProjectName, !projectName.isEmpty { Label(projectName, systemImage: "folder.fill") .font(.caption2) .foregroundStyle(.tint) @@ -157,7 +157,18 @@ struct ChatView: View { ScrollView { LazyVStack(spacing: 12) { if controller.vm.messages.isEmpty, controller.state == .ready { - emptyState + if controller.vm.sessionId != nil { + // Resumed-session path: session ID is set but + // no messages loaded. ACP-native sessions don't + // persist their transcript to state.db (only + // CLI/terminal sessions do), so resuming one + // reconnects to the agent but can't surface + // the history client-side. Explain to the user + // rather than showing a blank canvas. + resumedEmptyState + } else { + emptyState + } } ForEach(controller.vm.messages) { msg in MessageBubble(message: msg) @@ -213,6 +224,32 @@ struct ChatView: View { .padding(.top, 60) } + /// Friendlier-than-blank state for a session resumed from the + /// Dashboard that had no transcript persisted to `state.db`. + /// Hermes doesn't write ACP-native session messages to the + /// client DB — only CLI/terminal sessions leave a history there — + /// so resuming a "recent session" started via Chat means the + /// agent has the context but the client can't replay it. The + /// user can keep chatting and the agent will have full memory. + @ViewBuilder + private var resumedEmptyState: some View { + VStack(spacing: 8) { + Image(systemName: "arrow.clockwise.circle") + .font(.system(size: 40)) + .foregroundStyle(.tertiary) + Text("Session resumed") + .font(.headline) + .foregroundStyle(.secondary) + Text("Hermes has the context for this session, but the transcript isn't cached locally. Send a message to continue.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + @ViewBuilder private var composer: some View { HStack(alignment: .bottom, spacing: 8) { @@ -619,18 +656,30 @@ final class ChatController { func startResuming(sessionID: String) async { await stop() vm.reset() + // Clear eagerly so a lingering project name from a prior + // session doesn't flash onto the new header while the + // attribution lookup runs. + currentProjectName = nil // Resolve the project name for this session (if any) via the // attribution sidecar + project registry. Set BEFORE the ACP // handshake so the nav-bar subtitle is visible the moment the // "Connecting…" overlay disappears. Run off-thread so we - // don't block while the SFTP reads happen. + // don't block while the SFTP reads happen. Empty-string names + // are treated as nil — registry entries should never have + // empty names in practice, but guard against a surprise + // JSON-decode edge case that would render just a folder icon + // with no text (pass-2 bug: user saw exactly that). let ctx = context - currentProjectName = await Task.detached { + let resolved: String? = await Task.detached { let attribution = SessionAttributionService(context: ctx) guard let path = attribution.projectPath(for: sessionID) else { return nil } let registry = ProjectDashboardService(context: ctx).loadRegistry() - return registry.projects.first(where: { $0.path == path })?.name + guard let name = registry.projects.first(where: { $0.path == path })?.name, + !name.isEmpty + else { return nil } + return name }.value + currentProjectName = resolved state = .connecting let client = ACPClient.forIOSApp(