feat(chat): port the 3-pane chat layout + ScarfDesign telemetry

Sessions list (264 px) | transcript (flex) | inspector (320 px) per
design/static-site/ui-kit/Chat.jsx and the ScarfChatView reference.
Built over the real ChatViewModel + RichChatViewModel — live ACP
streaming pipeline untouched.

HermesToolCall gains optional duration / exitCode / startedAt fields
(backwards-compatible, nil defaults; not Codable). RichChatViewModel
populates them on ACP toolCallStart / toolCallUpdate; mutates the
streaming entry before finalize so the persisted call carries
telemetry. Sessions loaded from state.db gracefully render "—" when
nil.

ChatViewModel gains focusedToolCallId + a focusedToolCall computed
helper. ToolCallCard takes onFocus / isFocused — single click both
focuses the inspector and toggles inline expansion (two paths to the
same data per locked decision). Border weight + tint bump signal the
focused card.

Sessions pane: project filter (matches Sessions feature semantics),
search field, project chips per row, right-click rename + delete via
hermes sessions rename / delete --yes. Recent-sessions limit bumped
10 -> 50 so the project filter has data. loadRecentSessions commits
all four observables in a single MainActor batch to eliminate the
brief flash on session switch. ChatView toolbar's redundant Session
menu trimmed (left pane is canonical).

ChatTranscriptPane wraps existing SessionInfoBar + RichChatMessageList
+ RichChatInputBar without owning new state. RichChatView body becomes
a fixed 3-pane HStack — ViewThatFits was downgrading to transcript-only
when transcript content widened mid-load.

Inspector: STATUS / ARGUMENTS / TELEMETRY / PERMISSIONS in the Details
tab; STDOUT in dark mono panel under Output; full JSON envelope under
Raw. Footer Re-run is stubbed (TODO: /retry path); Copy puts the raw
JSON envelope on the clipboard.

ProjectSlashCommandsView: empty-state ContentUnavailableView now
centers in the full pane via .frame(maxWidth/maxHeight: .infinity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 14:17:06 +02:00
parent 8a2d89654b
commit 41769e289c
12 changed files with 1203 additions and 125 deletions
@@ -72,6 +72,20 @@ public struct HermesToolCall: Identifiable, Sendable, Codable {
public let functionName: String
public let arguments: String
/// Wall-clock duration of the tool call. Set on ACP `toolCallComplete`
/// (or equivalent) by `RichChatViewModel`. Nil for sessions loaded
/// from `state.db` (no live timing) and for in-flight calls.
public var duration: TimeInterval?
/// Process exit code, when the tool kind is `.execute` and the
/// tool-result message exposes one. Best-effort parse of the result
/// content; nil when not applicable / not parseable.
public var exitCode: Int?
/// Wall-clock timestamp the call was emitted by Hermes. Set on ACP
/// `toolCallStart`. Nil for sessions loaded from `state.db`.
public var startedAt: Date?
public enum CodingKeys: String, CodingKey {
case callId = "id"
case type
@@ -83,10 +97,20 @@ public struct HermesToolCall: Identifiable, Sendable, Codable {
case arguments
}
public init(callId: String, functionName: String, arguments: String) {
public init(
callId: String,
functionName: String,
arguments: String,
duration: TimeInterval? = nil,
exitCode: Int? = nil,
startedAt: Date? = nil
) {
self.callId = callId
self.functionName = functionName
self.arguments = arguments
self.duration = duration
self.exitCode = exitCode
self.startedAt = startedAt
}
public init(from decoder: Decoder) throws {
@@ -95,6 +119,11 @@ public struct HermesToolCall: Identifiable, Sendable, Codable {
let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
functionName = try funcContainer.decode(String.self, forKey: .name)
arguments = try funcContainer.decode(String.self, forKey: .arguments)
// Telemetry fields are populated locally from ACP events, never
// persisted via Codable, so they decode as nil.
duration = nil
exitCode = nil
startedAt = nil
}
public func encode(to encoder: Encoder) throws {
@@ -37,7 +37,11 @@ public final class RichChatViewModel {
public init(context: ServerContext = .local) {
self.context = context
self.dataService = HermesDataService(context: context)
loadQuickCommands()
// Quick-commands load happens in `reset()`, which every chat-start
// path calls before the user can interact (iOS: ChatController.start;
// Mac: ChatViewModel.startNewSession/resumeSession/continueLastSession).
// Calling it here too caused two parallel SFTP reads of config.yaml
// on iOS chat startup.
}
@@ -591,13 +595,29 @@ public final class RichChatViewModel {
let toolCall = HermesToolCall(
callId: call.toolCallId,
functionName: call.functionName,
arguments: call.argumentsJSON
arguments: call.argumentsJSON,
startedAt: Date()
)
streamingToolCalls.append(toolCall)
upsertStreamingMessage()
}
private func handleToolCallComplete(_ update: ACPToolCallUpdateEvent) {
// Populate live telemetry on the matching streaming call BEFORE
// finalizing once finalize runs, streamingToolCalls is cleared
// and the call is locked into the parent HermesMessage's `let
// toolCalls`. Mutating here lets `finalizeStreamingMessage()`
// promote a HermesToolCall that already carries duration +
// exitCode for the inspector to render. No-op for sessions
// loaded from `state.db` (no live event ever fires).
if let idx = streamingToolCalls.firstIndex(where: { $0.callId == update.toolCallId }) {
let started = streamingToolCalls[idx].startedAt
if let started {
streamingToolCalls[idx].duration = Date().timeIntervalSince(started)
}
streamingToolCalls[idx].exitCode = Self.exitCode(forStatus: update.status)
}
// Finalize the streaming assistant message (with its tool calls) as a permanent message
finalizeStreamingMessage()
@@ -620,6 +640,19 @@ public final class RichChatViewModel {
buildMessageGroups()
}
/// Derive a synthetic exit code from the ACP update event's status
/// string. Hermes reports `completed`/`error`/`failed`/`canceled`;
/// we collapse to 0 for success, 1 for known-failure variants, nil
/// for anything else (so the inspector renders "" rather than
/// fabricating a value).
private static func exitCode(forStatus status: String) -> Int? {
switch status.lowercased() {
case "completed", "success", "ok": return 0
case "error", "failed", "canceled", "cancelled": return 1
default: return nil
}
}
private func handlePromptComplete(response: ACPPromptResult) {
// Detect a failed prompt that produced no assistant output e.g.
// Hermes returning `stopReason: "refusal"` when the session was