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 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).
|
||||||
if let projectPath {
|
let contextForPrep = context
|
||||||
let registry = ProjectDashboardService(context: context).loadRegistry()
|
let prepLogger = logger
|
||||||
if let project = registry.projects.first(where: { $0.path == projectPath }) {
|
|
||||||
do {
|
|
||||||
try ProjectAgentContextService(context: context).refresh(for: project)
|
|
||||||
} catch {
|
|
||||||
logger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
if let projectPath {
|
||||||
|
// Synchronous file I/O (ProjectDashboardService.loadRegistry +
|
||||||
|
// 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 {
|
||||||
|
try ProjectAgentContextService(context: contextForPrep).refresh(for: project)
|
||||||
|
} catch {
|
||||||
|
prepLogger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
|
|
||||||
// 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: { text in
|
|
||||||
onSend(text)
|
|
||||||
},
|
|
||||||
isEnabled: isEnabled,
|
|
||||||
commands: richChat.availableCommands,
|
|
||||||
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
|
|
||||||
)
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
Divider().background(ScarfColor.border)
|
||||||
|
ChatInspectorPane(chatViewModel: chatViewModel)
|
||||||
|
.frame(width: 320)
|
||||||
}
|
}
|
||||||
// `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 {
|
||||||
|
|||||||
@@ -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." : {
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user