M7 #17 (pass-2): empty-transcript UX + defensive project chip

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 14:52:42 +02:00
parent d2633fb92d
commit 9a4473333b
+54 -5
View File
@@ -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(