mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user