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 functionName: String
public let arguments: 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 { public enum CodingKeys: String, CodingKey {
case callId = "id" case callId = "id"
case type case type
@@ -83,10 +97,20 @@ public struct HermesToolCall: Identifiable, Sendable, Codable {
case arguments 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.callId = callId
self.functionName = functionName self.functionName = functionName
self.arguments = arguments self.arguments = arguments
self.duration = duration
self.exitCode = exitCode
self.startedAt = startedAt
} }
public init(from decoder: Decoder) throws { 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) let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
functionName = try funcContainer.decode(String.self, forKey: .name) functionName = try funcContainer.decode(String.self, forKey: .name)
arguments = try funcContainer.decode(String.self, forKey: .arguments) 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 { public func encode(to encoder: Encoder) throws {
@@ -37,7 +37,11 @@ public final class RichChatViewModel {
public init(context: ServerContext = .local) { public init(context: ServerContext = .local) {
self.context = context self.context = context
self.dataService = HermesDataService(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( let toolCall = HermesToolCall(
callId: call.toolCallId, callId: call.toolCallId,
functionName: call.functionName, functionName: call.functionName,
arguments: call.argumentsJSON arguments: call.argumentsJSON,
startedAt: Date()
) )
streamingToolCalls.append(toolCall) streamingToolCalls.append(toolCall)
upsertStreamingMessage() upsertStreamingMessage()
} }
private func handleToolCallComplete(_ update: ACPToolCallUpdateEvent) { 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 // Finalize the streaming assistant message (with its tool calls) as a permanent message
finalizeStreamingMessage() finalizeStreamingMessage()
@@ -620,6 +640,19 @@ public final class RichChatViewModel {
buildMessageGroups() 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) { private func handlePromptComplete(response: ACPPromptResult) {
// Detect a failed prompt that produced no assistant output e.g. // Detect a failed prompt that produced no assistant output e.g.
// Hermes returning `stopReason: "refusal"` when the session was // Hermes returning `stopReason: "refusal"` when the session was
@@ -33,6 +33,16 @@ final class ChatViewModel {
var recentSessions: [HermesSession] = [] var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:] var sessionPreviews: [String: String] = [:]
/// Per-recent-session project attribution. Keyed by `HermesSession.id`,
/// value is the project's display name. Populated alongside
/// `recentSessions` via a single batched read in `loadRecentSessions()`.
/// Sessions with no entry are unattributed (global / quick chats).
private(set) var sessionProjectNames: [String: String] = [:]
/// All registered projects, used to build the project filter menu in
/// the chat session list pane. Loaded alongside `sessionProjectNames`.
private(set) var allProjects: [ProjectEntry] = []
var terminalView: LocalProcessTerminalView? var terminalView: LocalProcessTerminalView?
var hasActiveProcess = false var hasActiveProcess = false
var voiceEnabled = false var voiceEnabled = false
@@ -42,6 +52,31 @@ final class ChatViewModel {
let richChatViewModel: RichChatViewModel let richChatViewModel: RichChatViewModel
private var coordinator: Coordinator? private var coordinator: Coordinator?
/// `callId` of the tool call currently surfaced in the chat
/// inspector pane, or nil when nothing is focused. Set by
/// `ToolCallCard` taps in the transcript; cleared by the inspector's
/// xmark close. Mac-only state the inspector is a Mac-target view,
/// so this lives on the Mac `ChatViewModel` rather than the
/// cross-platform `RichChatViewModel`.
var focusedToolCallId: String?
/// Resolved focus target for the inspector. Walks
/// `richChatViewModel.messageGroups` to find the matching
/// `HermesToolCall` and its tool-result message (when present).
/// Returns nil when nothing is focused or the focused id no longer
/// resolves (e.g., session reload swept it).
var focusedToolCall: (call: HermesToolCall, result: HermesMessage?)? {
guard let id = focusedToolCallId else { return nil }
for group in richChatViewModel.messageGroups {
for msg in group.assistantMessages {
if let call = msg.toolCalls.first(where: { $0.callId == id }) {
return (call, group.toolResults[id])
}
}
}
return nil
}
/// Absolute project path for the current session, when the chat is /// Absolute project path for the current session, when the chat is
/// project-scoped (either started via a project's "New Chat" button /// project-scoped (either started via a project's "New Chat" button
/// or resumed from a session that was previously attributed via the /// or resumed from a session that was previously attributed via the
@@ -383,18 +418,28 @@ final class ChatViewModel {
// projects (creates AGENTS.md with just the block); safe on // projects (creates AGENTS.md with just the block); safe on
// template-installed projects (splices the block into // template-installed projects (splices the block into
// existing AGENTS.md without touching template content). // existing AGENTS.md without touching template content).
let contextForPrep = context
let prepLogger = logger
Task { @MainActor in
if let projectPath { if let projectPath {
let registry = ProjectDashboardService(context: context).loadRegistry() // Synchronous file I/O (ProjectDashboardService.loadRegistry +
if let project = registry.projects.first(where: { $0.path == projectPath }) { // ProjectAgentContextService.refresh, which itself walks the
// slash-commands directory) must run off the MainActor the
// detached task runs the work on the cooperative pool and we
// await it here so the AGENTS.md block lands before client.start().
await Task.detached {
let registry = ProjectDashboardService(context: contextForPrep).loadRegistry()
guard let project = registry.projects.first(where: { $0.path == projectPath }) else {
return
}
do { do {
try ProjectAgentContextService(context: context).refresh(for: project) try ProjectAgentContextService(context: contextForPrep).refresh(for: project)
} catch { } catch {
logger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)") prepLogger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)")
}
} }
}.value
} }
Task { @MainActor in
do { do {
// Start ACP process and event loop FIRST // Start ACP process and event loop FIRST
try await client.start() try await client.start()
@@ -686,9 +731,78 @@ final class ChatViewModel {
func loadRecentSessions() async { func loadRecentSessions() async {
let opened = await dataService.open() let opened = await dataService.open()
guard opened else { return } guard opened else { return }
recentSessions = await dataService.fetchSessions(limit: 10) // Bumped from 10 50 so the project filter has enough data to
sessionPreviews = await dataService.fetchSessionPreviews(limit: 10) // surface attributed sessions (older attributed sessions were
// getting truncated out of the original limit). Sessions feature
// loads 500; the chat sidebar doesn't need that, but 50 keeps
// the project filter useful without measurable cost.
let fetchedSessions = await dataService.fetchSessions(limit: 50)
let fetchedPreviews = await dataService.fetchSessionPreviews(limit: 50)
await dataService.close() await dataService.close()
// Project attribution + registry single batched off-main read.
let ctx = context
let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached {
let attribution = SessionAttributionService(context: ctx)
let registry = ProjectDashboardService(context: ctx).loadRegistry()
let pathToName = Dictionary(
uniqueKeysWithValues: registry.projects.map { ($0.path, $0.name) }
)
let map = attribution.load().mappings
var names: [String: String] = [:]
for (sessionID, path) in map {
if let name = pathToName[path] {
names[sessionID] = name
}
}
return (names: names, projects: registry.projects)
}.value
// Single batched commit assigning all four observables at once
// means SwiftUI sees one update rather than four staggered ones.
// Eliminates the brief "list flashes / project chips appear
// late" reload artifact during session switches.
recentSessions = fetchedSessions
sessionPreviews = fetchedPreviews
sessionProjectNames = bundle.names
allProjects = bundle.projects
}
/// Resolved project display name for a recent session, or nil for
/// unattributed (global / quick) sessions.
func projectName(for session: HermesSession) -> String? {
sessionProjectNames[session.id]
}
/// Rename a session via `hermes sessions rename`. Updates local
/// caches in-place on success so the chat sidebar reflects the new
/// title without a full reload. Same shell command path the
/// SessionsView feature uses.
func renameSession(_ sessionId: String, to newTitle: String) {
let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let result = context.runHermes(["sessions", "rename", sessionId, trimmed])
guard result.exitCode == 0 else { return }
if let idx = recentSessions.firstIndex(where: { $0.id == sessionId }) {
recentSessions[idx] = recentSessions[idx].withTitle(trimmed)
}
sessionPreviews[sessionId] = trimmed
}
/// Delete a session via `hermes sessions delete --yes`. Removes the
/// row from local caches on success and resets the live chat
/// transcript when the deleted session was the active one (so the
/// user isn't left looking at orphaned content).
func deleteSession(_ sessionId: String) {
let result = context.runHermes(["sessions", "delete", "--yes", sessionId])
guard result.exitCode == 0 else { return }
recentSessions.removeAll { $0.id == sessionId }
sessionPreviews.removeValue(forKey: sessionId)
sessionProjectNames.removeValue(forKey: sessionId)
if richChatViewModel.sessionId == sessionId {
richChatViewModel.reset()
focusedToolCallId = nil
}
} }
func previewFor(_ session: HermesSession) -> String { func previewFor(_ session: HermesSession) -> String {
@@ -0,0 +1,428 @@
import AppKit
import SwiftUI
import ScarfCore
import ScarfDesign
/// Right pane of the 3-pane chat layout mirrors the inspector in
/// `design/static-site/ui-kit/Chat.jsx` + `ScarfChatView.swift`. Reads
/// `chatViewModel.focusedToolCall` to resolve the focus target. Closing
/// (xmark) clears `focusedToolCallId`.
struct ChatInspectorPane: View {
@Bindable var chatViewModel: ChatViewModel
@State private var tab: Tab = .details
enum Tab: String, CaseIterable, Identifiable {
case details, output, raw
var id: String { rawValue }
var label: String {
switch self {
case .details: return "Details"
case .output: return "Output"
case .raw: return "Raw"
}
}
}
var body: some View {
VStack(spacing: 0) {
if let focus = chatViewModel.focusedToolCall {
header(focus.call)
ScrollView {
Group {
switch tab {
case .details: detailsBody(call: focus.call, result: focus.result)
case .output: outputBody(result: focus.result)
case .raw: rawBody(call: focus.call, result: focus.result)
}
}
.padding(ScarfSpace.s4)
}
footer(call: focus.call, result: focus.result)
} else {
emptyState
}
}
.background(ScarfColor.backgroundSecondary)
}
// MARK: - Header
private func header(_ call: HermesToolCall) -> some View {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
HStack(spacing: ScarfSpace.s2) {
ZStack {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(toolColor(call).opacity(0.16))
Image(systemName: call.toolKind.icon)
.font(.system(size: 11))
.foregroundStyle(toolColor(call))
}
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 1) {
Text("\(toolLabel(call)) CALL")
.scarfStyle(.captionStrong)
.tracking(0.5)
.foregroundStyle(toolColor(call))
Text(call.functionName)
.font(ScarfFont.mono)
.fontWeight(.semibold)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
}
Spacer()
Button {
chatViewModel.focusedToolCallId = nil
} label: {
Image(systemName: "xmark")
.font(.system(size: 11))
.foregroundStyle(ScarfColor.foregroundMuted)
.padding(4)
}
.buttonStyle(.plain)
.help("Close inspector")
}
Picker("", selection: $tab) {
ForEach(Tab.allCases) { t in
Text(t.label).tag(t)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
.padding(.horizontal, ScarfSpace.s4)
.padding(.vertical, ScarfSpace.s3)
.overlay(
Rectangle().fill(ScarfColor.border).frame(height: 1),
alignment: .bottom
)
}
// MARK: - Details body
private func detailsBody(call: HermesToolCall, result: HermesMessage?) -> some View {
VStack(alignment: .leading, spacing: ScarfSpace.s5) {
statusSection(call: call, result: result)
argumentsSection(call: call)
telemetrySection(call: call, result: result)
permissionsSection
}
}
private func statusSection(call: HermesToolCall, result: HermesMessage?) -> some View {
section("STATUS") {
HStack(spacing: ScarfSpace.s2) {
Image(systemName: statusIcon(call: call, result: result))
.font(.system(size: 14))
.foregroundStyle(statusColor(call: call, result: result))
VStack(alignment: .leading, spacing: 1) {
Text(statusTitle(call: call, result: result))
.scarfStyle(.captionStrong)
.foregroundStyle(statusColor(call: call, result: result))
Text(statusSubtitle(call: call))
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
Spacer()
}
.padding(ScarfSpace.s2)
.background(
RoundedRectangle(cornerRadius: 7).fill(statusColor(call: call, result: result).opacity(0.12))
.overlay(
RoundedRectangle(cornerRadius: 7)
.strokeBorder(statusColor(call: call, result: result).opacity(0.25), lineWidth: 1)
)
)
}
}
private func argumentsSection(call: HermesToolCall) -> some View {
section("ARGUMENTS") {
Text(formatJSON(call.arguments))
.font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled)
.padding(ScarfSpace.s2)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 7).fill(ScarfColor.backgroundTertiary)
)
}
}
private func telemetrySection(call: HermesToolCall, result: HermesMessage?) -> some View {
section("TELEMETRY") {
VStack(spacing: 0) {
kv("Started", call.startedAt.map { Self.timestampString($0) } ?? "", mono: true)
kv("Duration", call.duration.map { Self.durationString($0) } ?? "", mono: true)
if let tokens = result?.tokenCount, tokens > 0 {
kv("Tokens", tokens.formatted(), mono: true)
} else {
kv("Tokens", "", mono: true)
}
if let exit = call.exitCode {
kv("Exit code", "\(exit)", mono: true,
color: exit == 0 ? ScarfColor.success : ScarfColor.danger)
} else {
kv("Exit code", "", mono: true)
}
}
}
}
private var permissionsSection: some View {
section("PERMISSIONS", hint: "Tool gateway policy applied at run time") {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "checkmark.shield.fill")
.font(.system(size: 11))
.foregroundStyle(ScarfColor.success)
(Text("Allowed by ")
+ Text("scarf-default").font(ScarfFont.monoSmall)
+ Text(" profile"))
.scarfStyle(.caption)
}
HStack(spacing: 6) {
Image(systemName: "checkmark")
.font(.system(size: 11))
.foregroundStyle(ScarfColor.success)
Text("No human approval required")
.scarfStyle(.caption)
}
}
.padding(ScarfSpace.s2)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 7).fill(ScarfColor.backgroundTertiary)
)
}
}
// MARK: - Output body
private func outputBody(result: HermesMessage?) -> some View {
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
Text("OUTPUT")
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
if let content = result?.content, !content.isEmpty {
Text(content)
.font(ScarfFont.monoSmall)
.foregroundStyle(Color(red: 0.91, green: 0.88, blue: 0.82))
.textSelection(.enabled)
.padding(ScarfSpace.s3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(Color(red: 0.07, green: 0.06, blue: 0.05))
)
} else {
Text("No output yet.")
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(ScarfSpace.s3)
}
}
}
// MARK: - Raw body
private func rawBody(call: HermesToolCall, result: HermesMessage?) -> some View {
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
Text("RAW JSON")
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
Text(rawJSONString(call: call, result: result))
.font(ScarfFont.monoSmall)
.foregroundStyle(Color(red: 0.91, green: 0.88, blue: 0.82))
.textSelection(.enabled)
.padding(ScarfSpace.s3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(Color(red: 0.07, green: 0.06, blue: 0.05))
)
}
}
// MARK: - Footer
private func footer(call: HermesToolCall, result: HermesMessage?) -> some View {
HStack(spacing: ScarfSpace.s2) {
Button("Re-run") {
// TODO: wire to a /retry slash command or equivalent ACP path.
// No-op until that lands; button stays so the affordance is
// visible per the design.
}
.buttonStyle(ScarfSecondaryButton())
.disabled(true)
.help("Re-run isn't wired yet")
.frame(maxWidth: .infinity)
Button("Copy") {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(rawJSONString(call: call, result: result), forType: .string)
}
.buttonStyle(ScarfGhostButton())
}
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, ScarfSpace.s4)
.padding(.vertical, ScarfSpace.s2)
.overlay(
Rectangle().fill(ScarfColor.border).frame(height: 1),
alignment: .top
)
}
// MARK: - Empty state
private var emptyState: some View {
VStack(spacing: ScarfSpace.s2) {
Image(systemName: "magnifyingglass")
.font(.system(size: 22))
.foregroundStyle(ScarfColor.foregroundFaint)
Text("No tool selected")
.scarfStyle(.bodyEmph)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text("Click a tool call in the transcript to inspect it.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.multilineTextAlignment(.center)
.frame(maxWidth: 220)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(ScarfSpace.s5)
}
// MARK: - Section primitive
@ViewBuilder
private func section<Content: View>(_ title: String, hint: String? = nil,
@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
Text(title)
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
content()
if let hint {
Text(hint)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
}
}
}
private func kv(_ key: String, _ value: String, mono: Bool, color: Color? = nil) -> some View {
HStack {
Text(key)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
Spacer()
Text(value)
.font(mono ? ScarfFont.monoSmall : ScarfFont.caption)
.foregroundStyle(color ?? ScarfColor.foregroundPrimary)
}
.padding(.vertical, 5)
.overlay(
Rectangle().fill(ScarfColor.border).frame(height: 1),
alignment: .bottom
)
}
// MARK: - Helpers
private func toolColor(_ call: HermesToolCall) -> Color {
switch call.toolKind {
case .read: return ScarfColor.success
case .edit: return ScarfColor.info
case .execute: return ScarfColor.warning
case .fetch: return ScarfColor.Tool.web
case .browser: return ScarfColor.Tool.search
case .other: return ScarfColor.foregroundMuted
}
}
private func toolLabel(_ call: HermesToolCall) -> String {
switch call.toolKind {
case .read: return "READ"
case .edit: return "EDIT"
case .execute: return "EXECUTE"
case .fetch: return "FETCH"
case .browser: return "BROWSER"
case .other: return "TOOL"
}
}
private func statusIcon(call: HermesToolCall, result: HermesMessage?) -> String {
if let exit = call.exitCode { return exit == 0 ? "checkmark.circle.fill" : "xmark.circle.fill" }
if result != nil { return "checkmark.circle.fill" }
return "circle"
}
private func statusColor(call: HermesToolCall, result: HermesMessage?) -> Color {
if let exit = call.exitCode { return exit == 0 ? ScarfColor.success : ScarfColor.danger }
if result != nil { return ScarfColor.success }
return ScarfColor.foregroundMuted
}
private func statusTitle(call: HermesToolCall, result: HermesMessage?) -> String {
if let exit = call.exitCode { return exit == 0 ? "Completed" : "Failed" }
if result != nil { return "Completed" }
return "In progress"
}
private func statusSubtitle(call: HermesToolCall) -> String {
if let exit = call.exitCode { return "Exit \(exit)" }
if let started = call.startedAt {
return "Started \(Self.timestampString(started))"
}
return "Awaiting result"
}
private func formatJSON(_ raw: String) -> String {
guard let data = raw.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted),
let str = String(data: pretty, encoding: .utf8) else {
return raw
}
return str
}
private func rawJSONString(call: HermesToolCall, result: HermesMessage?) -> String {
let resultBody: String
if let r = result {
let escaped = r.content.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
resultBody = "\"\(escaped)\""
} else {
resultBody = "null"
}
return """
{
"id": "\(call.callId)",
"type": "tool_use",
"name": "\(call.functionName)",
"input": \(formatJSON(call.arguments)),
"result": {
"exit_code": \(call.exitCode.map { "\($0)" } ?? "null"),
"duration_seconds": \(call.duration.map { String(format: "%.3f", $0) } ?? "null"),
"content": \(resultBody)
}
}
"""
}
private static func timestampString(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f.string(from: date)
}
private static func durationString(_ seconds: TimeInterval) -> String {
if seconds < 1 { return "\(Int(seconds * 1000)) ms" }
return String(format: "%.2f s", seconds)
}
}
@@ -0,0 +1,379 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Left pane of the 3-pane chat layout mirrors the sessions list in
/// `design/static-site/ui-kit/Chat.jsx` + `ScarfChatView.swift`. Reads
/// `chatViewModel.recentSessions` (loaded on the parent view's `.task`),
/// surfaces filter pills + a search field, and renders rows that resume
/// the session on tap. Active row matches `richChat.sessionId`.
struct ChatSessionListPane: View {
@Bindable var chatViewModel: ChatViewModel
@Bindable var richChat: RichChatViewModel
@State private var searchText: String = ""
/// Project filter same semantics as the Sessions feature:
/// nil = all projects (no filter), "" = unattributed, any other
/// string matches against `chatViewModel.sessionProjectNames`.
@State private var projectFilter: String?
@State private var renameTarget: HermesSession?
@State private var renameText: String = ""
@State private var deleteTarget: HermesSession?
var body: some View {
VStack(spacing: 0) {
header
projectFilterRow
searchField
ScrollView {
LazyVStack(spacing: 0) {
ForEach(visibleSessions) { session in
ChatSessionRow(
session: session,
preview: chatViewModel.previewFor(session),
projectName: chatViewModel.projectName(for: session),
isActive: session.id == richChat.sessionId,
isLive: session.id == richChat.sessionId && richChat.isAgentWorking,
onSelect: { chatViewModel.resumeSession(session.id) }
)
.contextMenu {
Button("Rename…") {
renameText = chatViewModel.previewFor(session)
renameTarget = session
}
Divider()
Button("Delete…", role: .destructive) {
deleteTarget = session
}
}
}
if visibleSessions.isEmpty {
emptyState
}
}
.padding(.horizontal, 6)
.padding(.bottom, ScarfSpace.s2)
}
footer
}
.background(ScarfColor.backgroundTertiary)
.sheet(item: $renameTarget) { session in
renameSheet(for: session)
}
.confirmationDialog(
deleteTarget.map { "Delete \(chatViewModel.previewFor($0))?" } ?? "",
isPresented: Binding(
get: { deleteTarget != nil },
set: { if !$0 { deleteTarget = nil } }
),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let target = deleteTarget {
chatViewModel.deleteSession(target.id)
}
deleteTarget = nil
}
Button("Cancel", role: .cancel) { deleteTarget = nil }
} message: {
Text("This permanently deletes the session and all its messages.")
}
}
private func renameSheet(for session: HermesSession) -> some View {
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
Text("Rename Session")
.scarfStyle(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
ScarfTextField("Session title", text: $renameText)
.onSubmit { commitRename(session) }
HStack {
Button("Cancel") { renameTarget = nil }
.buttonStyle(ScarfGhostButton())
.keyboardShortcut(.cancelAction)
Spacer()
Button("Rename") { commitRename(session) }
.buttonStyle(ScarfPrimaryButton())
.keyboardShortcut(.defaultAction)
.disabled(renameText.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding(ScarfSpace.s5)
.frame(width: 380)
}
private func commitRename(_ session: HermesSession) {
chatViewModel.renameSession(session.id, to: renameText)
renameTarget = nil
}
// MARK: - Header
private var header: some View {
HStack(spacing: ScarfSpace.s2) {
Text("Chats")
.scarfStyle(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer()
Button {
chatViewModel.startNewSession()
} label: {
Label("New", systemImage: "plus")
}
.buttonStyle(ScarfPrimaryButton())
.fixedSize(horizontal: true, vertical: false)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.top, ScarfSpace.s3)
.padding(.bottom, ScarfSpace.s2)
}
private var projectFilterRow: some View {
Menu {
Button {
projectFilter = nil
} label: {
Label("All projects", systemImage: "tray.full")
}
Button {
projectFilter = ""
} label: {
Label("Unattributed", systemImage: "questionmark.folder")
}
if !chatViewModel.allProjects.isEmpty {
Divider()
ForEach(chatViewModel.allProjects.sorted { $0.name < $1.name }) { project in
Button {
projectFilter = project.name
} label: {
Label(project.name, systemImage: "folder.fill")
}
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: projectFilterIcon)
.font(.system(size: 11))
Text(projectFilterLabel)
.scarfStyle(.caption)
.lineLimit(1)
if projectFilter == nil {
Image(systemName: "chevron.down")
.font(.system(size: 9))
.opacity(0.7)
}
}
.foregroundStyle(projectFilter != nil ? ScarfColor.accentActive : ScarfColor.foregroundPrimary)
.padding(.horizontal, 9)
.padding(.vertical, 3)
.background(
Capsule()
.fill(projectFilter != nil ? ScarfColor.accentTint : ScarfColor.backgroundSecondary)
)
.overlay(
Capsule()
.strokeBorder(
projectFilter != nil ? ScarfColor.accent : Color.clear,
lineWidth: 1
)
)
}
.menuStyle(.borderlessButton)
.fixedSize()
.padding(.horizontal, ScarfSpace.s3)
.padding(.bottom, ScarfSpace.s2)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var projectFilterIcon: String {
switch projectFilter {
case .none: return "square.stack.3d.up"
case .some(let s) where s.isEmpty: return "questionmark.folder"
default: return "folder.fill"
}
}
private var projectFilterLabel: String {
switch projectFilter {
case .none: return "All projects"
case .some(let s) where s.isEmpty: return "Unattributed"
case .some(let s): return s
}
}
private var searchField: some View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.system(size: 11))
.foregroundStyle(ScarfColor.foregroundFaint)
TextField("Search…", text: $searchText)
.textFieldStyle(.plain)
.scarfStyle(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.fill(ScarfColor.backgroundSecondary)
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.strokeBorder(ScarfColor.borderStrong, lineWidth: 1)
)
.padding(.horizontal, ScarfSpace.s3)
.padding(.bottom, ScarfSpace.s2)
}
// MARK: - Filtering
private var visibleSessions: [HermesSession] {
var base = chatViewModel.recentSessions
// Project filter same semantics as the Sessions feature.
if let filter = projectFilter {
if filter.isEmpty {
base = base.filter { chatViewModel.projectName(for: $0) == nil }
} else {
base = base.filter { chatViewModel.projectName(for: $0) == filter }
}
}
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return base }
return base.filter { session in
chatViewModel.previewFor(session).lowercased().contains(trimmed)
}
}
// MARK: - Empty state + footer
private var emptyState: some View {
VStack(spacing: 6) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 22))
.foregroundStyle(ScarfColor.foregroundFaint)
Text(emptyMessage)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(ScarfSpace.s5)
}
private var emptyMessage: String {
if chatViewModel.recentSessions.isEmpty {
return "No sessions yet — tap New to start one."
}
if projectFilter != nil {
return "No chats in this project (showing the most recent 50)."
}
return "No matches for that search."
}
private var footer: some View {
HStack(spacing: ScarfSpace.s2) {
Image(systemName: "bubble.left")
.font(.system(size: 10))
Text("\(chatViewModel.recentSessions.count) chat\(chatViewModel.recentSessions.count == 1 ? "" : "s")")
Spacer()
}
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, ScarfSpace.s2)
.overlay(
Rectangle()
.fill(ScarfColor.border)
.frame(height: 1),
alignment: .top
)
}
}
// MARK: - Row
private struct ChatSessionRow: View {
let session: HermesSession
let preview: String
let projectName: String?
let isActive: Bool
let isLive: Bool
let onSelect: () -> Void
@State private var hover = false
var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 7) {
statusDot
Text(preview)
.scarfStyle(.bodyEmph)
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(isActive ? ScarfColor.accentActive : ScarfColor.foregroundPrimary)
Spacer(minLength: 0)
if let started = session.startedAt {
Text(started, style: .relative)
.font(ScarfFont.caption2)
.foregroundStyle(ScarfColor.foregroundFaint)
}
}
HStack(spacing: 6) {
if let projectName, !projectName.isEmpty {
HStack(spacing: 3) {
Image(systemName: "folder.fill")
.font(.system(size: 8))
Text(projectName)
.font(ScarfFont.caption2)
.lineLimit(1)
.truncationMode(.middle)
}
.foregroundStyle(ScarfColor.accentActive)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Capsule().fill(ScarfColor.accentTint))
}
Label("\(session.messageCount)", systemImage: "bubble.left")
.scarfStyle(.caption)
if session.toolCallCount > 0 {
Label("\(session.toolCallCount)", systemImage: "wrench")
.scarfStyle(.caption)
}
Spacer(minLength: 0)
}
.foregroundStyle(ScarfColor.foregroundMuted)
.padding(.leading, 14)
}
.padding(.horizontal, 10)
.padding(.vertical, ScarfSpace.s2)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(rowBackground)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hover = $0 }
}
private var rowBackground: Color {
if isActive { return ScarfColor.accentTint }
if hover { return ScarfColor.border.opacity(0.5) }
return .clear
}
@ViewBuilder
private var statusDot: some View {
if isLive {
Circle()
.fill(ScarfColor.success)
.frame(width: 7, height: 7)
.overlay(Circle().stroke(ScarfColor.success.opacity(0.20), lineWidth: 2))
} else {
Circle()
.fill(ScarfColor.foregroundFaint.opacity(0.4))
.frame(width: 6, height: 6)
}
}
}
@@ -0,0 +1,73 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Middle pane of the 3-pane chat layout composes the existing
/// `SessionInfoBar` + `RichChatMessageList` + `RichChatInputBar` with
/// no new state of its own. Pulled out of `RichChatView` so the
/// 3-pane HStack is readable.
struct ChatTranscriptPane: View {
@Bindable var richChat: RichChatViewModel
@Bindable var chatViewModel: ChatViewModel
var onSend: (String) -> Void
var isEnabled: Bool
var body: some View {
VStack(spacing: 0) {
SessionInfoBar(
session: richChat.currentSession,
isWorking: richChat.isGenerating,
acpInputTokens: richChat.acpInputTokens,
acpOutputTokens: richChat.acpOutputTokens,
acpThoughtTokens: richChat.acpThoughtTokens,
projectName: chatViewModel.currentProjectName,
gitBranch: chatViewModel.currentGitBranch
)
Divider()
// Always mount RichChatMessageList; empty state lives inside it.
// Swapping between a ContentUnavailableView and the ScrollView
// hierarchy on first message caused a full view tree rebuild,
// which manifests as a white flash.
RichChatMessageList(
groups: richChat.messageGroups,
isWorking: richChat.isGenerating,
isLoadingSession: chatViewModel.isPreparingSession,
scrollTrigger: richChat.scrollTrigger,
turnDurations: richChat.turnDurations
)
Divider()
if let hint = richChat.transientHint {
steeringToast(hint)
}
RichChatInputBar(
onSend: onSend,
isEnabled: isEnabled,
commands: richChat.availableCommands,
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
)
}
.background(ScarfColor.backgroundPrimary)
}
/// Soft pill above the composer that confirms a non-interruptive
/// command (e.g. `/steer`) was received. Auto-clears after a short
/// delay (managed by `ChatViewModel`); presence in the model is what
/// drives this view.
private func steeringToast(_ hint: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "arrowshape.turn.up.right.fill")
.foregroundStyle(ScarfColor.accent)
.scarfStyle(.caption)
Text(hint)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer(minLength: 0)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
.background(ScarfColor.accentTint)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
+7 -27
View File
@@ -242,42 +242,22 @@ struct ChatView: View {
.foregroundStyle(.red) .foregroundStyle(.red)
} }
// The session list pane on the left is the canonical home
// for browsing + resuming sessions and starting new ones.
// We keep a slim toolbar Menu for the actions that aren't
// expressed by clicking a row: "Return to Active Session"
// (scroll back to the live session) and "Continue Last
// Session" (continue the most-recent without explicit pick).
Menu { Menu {
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId { if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
Button("Return to Active Session (\(activeId.prefix(8))...)") { Button("Return to Active Session (\(activeId.prefix(8)))") {
viewModel.richChatViewModel.requestScrollToBottom() viewModel.richChatViewModel.requestScrollToBottom()
} }
Divider() Divider()
} }
Button("New Session") {
viewModel.startNewSession()
}
Button("Continue Last Session") { Button("Continue Last Session") {
viewModel.continueLastSession() viewModel.continueLastSession()
} }
if !viewModel.recentSessions.isEmpty {
Divider()
Text("Resume Session")
let activeSessionId = viewModel.richChatViewModel.sessionId
let originSessionId = viewModel.richChatViewModel.originSessionId
ForEach(viewModel.recentSessions) { session in
Button {
viewModel.resumeSession(session.id)
} label: {
HStack {
Text(viewModel.previewFor(session))
.lineLimit(1)
if let date = session.startedAt {
Text("·")
.foregroundStyle(.secondary)
Text(date, style: .relative)
.foregroundStyle(.secondary)
}
}
}
.disabled(session.id == activeSessionId || session.id == originSessionId)
}
}
} label: { } label: {
Label("Session", systemImage: "play.circle") Label("Session", systemImage: "play.circle")
.font(.caption) .font(.caption)
@@ -2,6 +2,19 @@ import SwiftUI
import ScarfCore import ScarfCore
import ScarfDesign import ScarfDesign
/// 3-pane chat layout sessions list | transcript | inspector.
/// Mirrors `design/static-site/ui-kit/Chat.jsx` and the
/// `ScarfChatView.ChatRootView` reference component, but composed over
/// the real `ChatViewModel` + `RichChatViewModel` so the live ACP
/// pipeline stays intact.
///
/// We always render the full 3-pane layout earlier `ViewThatFits`
/// fallbacks were dropping to transcript-only when the transcript's
/// own ideal width grew mid-load (long code blocks pushed the HStack
/// past the available width and ViewThatFits picked the smallest
/// variant). The window has a sensible minimum (~944 px content area
/// at the default 1100 px window width); narrower than that the user
/// can scroll horizontally inside the panes rather than losing them.
struct RichChatView: View { struct RichChatView: View {
@Bindable var richChat: RichChatViewModel @Bindable var richChat: RichChatViewModel
var onSend: (String) -> Void var onSend: (String) -> Void
@@ -13,67 +26,21 @@ struct RichChatView: View {
private var isACPMode: Bool { chatViewModel.isACPConnected } private var isACPMode: Bool { chatViewModel.isACPConnected }
var body: some View { var body: some View {
VStack(spacing: 0) { HStack(spacing: 0) {
SessionInfoBar( ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat)
session: richChat.currentSession, .frame(width: 264)
// Prefer `isGenerating` over the raw `isAgentWorking` Divider().background(ScarfColor.border)
// so the info bar drops the spinner as soon as the ChatTranscriptPane(
// assistant's reply is visible, even while ACP richChat: richChat,
// auxiliary work (title gen, usage accounting) is chatViewModel: chatViewModel,
// still in flight. See RichChatViewModel docs same onSend: onSend,
// fix as ScarfGo for pass-1 M7 #4. isEnabled: isEnabled
isWorking: richChat.isGenerating,
acpInputTokens: richChat.acpInputTokens,
acpOutputTokens: richChat.acpOutputTokens,
acpThoughtTokens: richChat.acpThoughtTokens,
// v2.3: surface the active Scarf project (if any) as
// a folder chip at the start of the bar. Driven by
// ChatViewModel.currentProjectName which is set in
// startACPSession on both new project chats and
// resumed project-attributed sessions.
projectName: chatViewModel.currentProjectName,
// v2.5: git branch indicator alongside the project chip.
gitBranch: chatViewModel.currentGitBranch
) )
Divider() .frame(maxWidth: .infinity)
Divider().background(ScarfColor.border)
// Always mount RichChatMessageList; empty state lives inside it. ChatInspectorPane(chatViewModel: chatViewModel)
// Swapping between a ContentUnavailableView and the ScrollView .frame(width: 320)
// hierarchy on first message caused a full view tree rebuild,
// which manifests as a white flash.
RichChatMessageList(
groups: richChat.messageGroups,
isWorking: richChat.isGenerating,
isLoadingSession: chatViewModel.isPreparingSession,
scrollTrigger: richChat.scrollTrigger,
turnDurations: richChat.turnDurations
)
Divider()
if let hint = richChat.transientHint {
steeringToast(hint)
} }
RichChatInputBar(
onSend: { text in
onSend(text)
},
isEnabled: isEnabled,
commands: richChat.availableCommands,
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
)
}
// `idealHeight: 500` caps what this subtree REPORTS as its ideal
// height. Load-bearing: RichChatMessageList uses a plain VStack
// (not LazyVStack see RichChatMessageList.swift:13-24 for the
// rationale) inside a ScrollView, so its natural ideal grows
// with message count. Under the WindowGroup's
// `.windowResizability(.contentMinSize)` policy, that uncapped
// ideal would open the window at a height that exceeds the
// screen on long conversations, pushing the input bar below
// the visible desktop. `maxHeight: .infinity` still lets the
// view fill any larger offered space, and `minHeight: 0`
// allows it to shrink freely the ideal cap only affects the
// initial-size hint reported up to the window.
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity) .frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
// DB polling fallback for terminal mode only never overwrite ACP messages // DB polling fallback for terminal mode only never overwrite ACP messages
.onChange(of: fileWatcher.lastChangeDate) { .onChange(of: fileWatcher.lastChangeDate) {
@@ -82,24 +49,4 @@ struct RichChatView: View {
} }
} }
} }
/// Soft pill above the composer that confirms a non-interruptive
/// command (e.g. `/steer`) was received. Auto-clears after a short
/// delay (managed by `ChatViewModel`); presence in the model is
/// what drives this view.
private func steeringToast(_ hint: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "arrowshape.turn.up.right.fill")
.foregroundStyle(ScarfColor.accent)
.scarfStyle(.caption)
Text(hint)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer(minLength: 0)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
.background(ScarfColor.accentTint)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
} }
@@ -12,6 +12,8 @@ struct RichMessageBubble: View {
/// loaded from `state.db` (no live timing available). /// loaded from `state.db` (no live timing available).
var turnDuration: TimeInterval? = nil var turnDuration: TimeInterval? = nil
@Environment(ChatViewModel.self) private var chatViewModel
var body: some View { var body: some View {
if message.isUser { if message.isUser {
userBubble userBubble
@@ -163,7 +165,9 @@ struct RichMessageBubble: View {
ForEach(message.toolCalls) { call in ForEach(message.toolCalls) { call in
ToolCallCard( ToolCallCard(
call: call, call: call,
result: toolResults[call.callId] result: toolResults[call.callId],
isFocused: chatViewModel.focusedToolCallId == call.callId,
onFocus: { chatViewModel.focusedToolCallId = call.callId }
) )
} }
} }
@@ -5,12 +5,22 @@ import ScarfDesign
struct ToolCallCard: View { struct ToolCallCard: View {
let call: HermesToolCall let call: HermesToolCall
let result: HermesMessage? let result: HermesMessage?
/// True when this card matches `chatViewModel.focusedToolCallId`.
/// Bumps the card's tint + border so users can see at a glance
/// which tool the inspector pane is currently showing.
var isFocused: Bool = false
/// Called when the user clicks the card. Wired to set
/// `chatViewModel.focusedToolCallId = call.callId` from
/// `RichMessageBubble` (Mac). Inline expansion still toggles on the
/// same click power users get both paths from one gesture.
var onFocus: (() -> Void)? = nil
@State private var expanded = false @State private var expanded = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Button { Button {
onFocus?()
withAnimation(ScarfAnimation.fast) { expanded.toggle() } withAnimation(ScarfAnimation.fast) { expanded.toggle() }
} label: { } label: {
HStack(spacing: 9) { HStack(spacing: 9) {
@@ -49,10 +59,13 @@ struct ToolCallCard: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background( .background(
RoundedRectangle(cornerRadius: 7) RoundedRectangle(cornerRadius: 7)
.fill(toolColor.opacity(0.10)) .fill(toolColor.opacity(isFocused ? 0.16 : 0.10))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 7) RoundedRectangle(cornerRadius: 7)
.strokeBorder(toolColor.opacity(0.30), lineWidth: 1) .strokeBorder(
toolColor.opacity(isFocused ? 0.55 : 0.30),
lineWidth: isFocused ? 1.4 : 1
)
) )
) )
} }
@@ -85,6 +85,7 @@ struct ProjectSlashCommandsView: View {
Button("Add Command") { viewModel.beginNew() } Button("Add Command") { viewModel.beginNew() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
VStack(spacing: 0) { VStack(spacing: 0) {
if let err = viewModel.lastError { if let err = viewModel.lastError {
+77
View File
@@ -8,6 +8,10 @@
"comment" : "A space.", "comment" : "A space.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
" profile" : {
"comment" : "A description of a tool gateway policy applied at run time.",
"isCommentAutoGenerated" : true
},
"—" : { "—" : {
"comment" : "A placeholder for a project name.", "comment" : "A placeholder for a project name.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -117,6 +121,10 @@
} }
} }
}, },
"%@ CALL" : {
"comment" : "A label that reads \"CALL\" for a tool call.",
"isCommentAutoGenerated" : true
},
"%@ ctx" : { "%@ ctx" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -427,6 +435,18 @@
} }
} }
}, },
"%lld chat%@" : {
"comment" : "A label displaying the number of chat sessions. The argument is the count of sessions.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld chat%2$@"
}
}
}
},
"%lld delivery failure%@" : { "%lld delivery failure%@" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -2345,6 +2365,10 @@
} }
} }
}, },
"Allowed by " : {
"comment" : "A prefix for a tool gateway policy.",
"isCommentAutoGenerated" : true
},
"already gone" : { "already gone" : {
"comment" : "A tag for a file that is already gone (no longer in the template).", "comment" : "A tag for a file that is already gone (no longer in the template).",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -3937,6 +3961,10 @@
} }
} }
}, },
"Chats" : {
"comment" : "The title of the chat list.",
"isCommentAutoGenerated" : true
},
"Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar." : { "Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar." : {
"comment" : "A description of the purpose of the Sessions tab.", "comment" : "A description of the purpose of the Sessions tab.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -4557,6 +4585,10 @@
}, },
"CLI" : { "CLI" : {
},
"Click a tool call in the transcript to inspect it." : {
"comment" : "A description of the content of the chat inspector pane.",
"isCommentAutoGenerated" : true
}, },
"Click Add to connect to a remote Hermes installation over SSH." : { "Click Add to connect to a remote Hermes installation over SSH." : {
"localizations" : { "localizations" : {
@@ -4758,6 +4790,10 @@
} }
} }
}, },
"Close inspector" : {
"comment" : "A button that closes the inspector.",
"isCommentAutoGenerated" : true
},
"Close Window" : { "Close Window" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -12686,6 +12722,10 @@
} }
} }
}, },
"New" : {
"comment" : "A button that starts a new chat session.",
"isCommentAutoGenerated" : true
},
"New Chat" : { "New Chat" : {
"comment" : "A button that starts a new chat session.", "comment" : "A button that starts a new chat session.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -13447,6 +13487,10 @@
} }
} }
}, },
"No human approval required" : {
"comment" : "A description of a tool's permission status.",
"isCommentAutoGenerated" : true
},
"No matches for \"%@\"." : { "No matches for \"%@\"." : {
"comment" : "A message that appears when a search yields no results. The argument is the search term.", "comment" : "A message that appears when a search yields no results. The argument is the search term.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -13571,6 +13615,10 @@
} }
} }
}, },
"No output yet." : {
"comment" : "A message displayed when a tool call has not yet produced output.",
"isCommentAutoGenerated" : true
},
"No paired users" : { "No paired users" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -14041,6 +14089,10 @@
} }
} }
}, },
"No tool selected" : {
"comment" : "A message displayed when there is no currently selected tool call.",
"isCommentAutoGenerated" : true
},
"No Updates" : { "No Updates" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -14968,6 +15020,10 @@
} }
} }
}, },
"OUTPUT" : {
"comment" : "A label displayed above the chat output.",
"isCommentAutoGenerated" : true
},
"Overview" : { "Overview" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -16474,6 +16530,10 @@
} }
} }
}, },
"RAW JSON" : {
"comment" : "A label displayed above the raw JSON output of a tool.",
"isCommentAutoGenerated" : true
},
"Raw remote output (for debugging)" : { "Raw remote output (for debugging)" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -16554,6 +16614,10 @@
} }
} }
}, },
"Re-run isn't wired yet" : {
"comment" : "A tooltip for the \"Re-run\" button.",
"isCommentAutoGenerated" : true
},
"Read" : { "Read" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -18010,6 +18074,7 @@
} }
}, },
"Resume Session" : { "Resume Session" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -18090,6 +18155,7 @@
} }
}, },
"Return to Active Session (%@...)" : { "Return to Active Session (%@...)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -18129,6 +18195,10 @@
} }
} }
}, },
"Return to Active Session (%@…)" : {
"comment" : "A button that scrolls to the bottom of the chat view.",
"isCommentAutoGenerated" : true
},
"Reveal" : { "Reveal" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -18803,6 +18873,10 @@
"comment" : "A description of how a template will be installed.", "comment" : "A description of how a template will be installed.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"scarf-default" : {
"comment" : "A tool gateway policy applied at run time.",
"isCommentAutoGenerated" : true
},
"schedule: %@" : { "schedule: %@" : {
"comment" : "A cron schedule, e.g. \"every 10 minutes\".", "comment" : "A cron schedule, e.g. \"every 10 minutes\".",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -22304,6 +22378,9 @@
"This may take a few seconds." : { "This may take a few seconds." : {
"comment" : "A description of the time it takes to connect to Nous Portal.", "comment" : "A description of the time it takes to connect to Nous Portal.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"This permanently deletes the session and all its messages." : {
}, },
"This project wasn't installed from a schemaful template." : { "This project wasn't installed from a schemaful template." : {