Skip to content

Commit d2d9707

Browse files
committed
Add a Quick Chats section to the sidebar
An ad-hoc conversation with the default backend, with none of a loop's semantics: no goal, no trigger, no place in any graph. The section sits above the projects; [+] starts a chat, right-click renames or deletes one. Chats are app-local on purpose — the daemon's job is graphs, hand-offs, and keeping unattended sessions alive, and a chat is attended by definition. What persists a chat's scrollback is its zmx session, exactly as for a loop: the chat's id is the session identity, so reopening joins the live session. The list itself is a small JSON under ~/.graphcode (QuickChatStore). A chat opens in the ordinary loop workspace via a synthetic .proactive node — the one loop type whose sessionPrompt is nil, so the backend starts bare. Working directory is home: a chat belongs to no project. Deleting a chat kills its session app-side, since no daemon cleans up after it. The sidebar's body also got split into sub-properties — one literal holding every section pushed the expression past the type-checker's budget. Verified: full test suite passes (411 tests).
1 parent d432668 commit d2d9707

5 files changed

Lines changed: 285 additions & 36 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import Foundation
2+
3+
/// An ad-hoc backend session with no loop semantics: no goal, no trigger, no place in
4+
/// any graph — just a conversation. Deliberately app-local rather than daemon-owned:
5+
/// the daemon's whole job is graphs, hand-offs, and keeping *unattended* sessions
6+
/// alive, and a chat is attended by definition. What keeps a chat's scrollback across
7+
/// app restarts is its `zmx` session, exactly as for a loop — the id here is the
8+
/// session identity (`SurfaceRef(id:).zmxSessionName`), so reopening a chat joins the
9+
/// same live session.
10+
public struct QuickChat: Identifiable, Codable, Equatable, Sendable {
11+
public let id: UUID
12+
public var title: String
13+
/// Fixed at creation from the Settings default, not re-read per open: a chat that
14+
/// silently switched backends mid-history would join a session whose scrollback came
15+
/// from a different agent.
16+
public var backend: CLISessionBackendKind
17+
public var createdAt: Date
18+
19+
public init(
20+
id: UUID = UUID(),
21+
title: String,
22+
backend: CLISessionBackendKind = .claudeCode,
23+
createdAt: Date = Date()
24+
) {
25+
self.id = id
26+
self.title = title
27+
self.backend = backend
28+
self.createdAt = createdAt
29+
}
30+
}
31+
32+
/// Reads/writes the quick-chat list — one JSON file under `<baseDirectory>`. Same shape
33+
/// as `TerminalLayoutStore` and for the same reason: small local file I/O, app-side
34+
/// state the daemon has no reason to know about.
35+
public struct QuickChatStore: Sendable {
36+
private let fileURL: URL
37+
38+
public init(baseDirectory: URL) {
39+
try? FileManager.default.createDirectory(
40+
at: baseDirectory, withIntermediateDirectories: true)
41+
fileURL = baseDirectory.appendingPathComponent("quick-chats.json")
42+
}
43+
44+
public func load() -> [QuickChat] {
45+
guard let data = try? Data(contentsOf: fileURL) else { return [] }
46+
return (try? JSONDecoder().decode([QuickChat].self, from: data)) ?? []
47+
}
48+
49+
public func save(_ chats: [QuickChat]) {
50+
guard let data = try? JSONEncoder().encode(chats) else { return }
51+
try? data.write(to: fileURL, options: .atomic)
52+
}
53+
}

GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,13 @@ public enum ZmxSessionLauncher {
184184
return await session.waitUntilFinished()
185185
}
186186

187+
/// Kills the session behind an id that isn't a graph node — a quick chat. Public
188+
/// because chats are app-owned: no daemon deletes their sessions for them, the way
189+
/// `GraphStore` does when a loop is deleted.
190+
public static func killSession(id: UUID) async {
191+
await kill(LoopNode(id: id, title: ""))
192+
}
193+
187194
static func kill(_ node: LoopNode) async {
188195
guard ZmxLocator.isInstalled else { return }
189196
guard
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Dependencies
2+
import Foundation
3+
import GraphcodeKit
4+
5+
/// Retroactive `DependencyKey` conformance for `GraphcodeKit`'s `QuickChatStore` —
6+
/// here rather than in `GraphcodeKit` for the same reason as `TerminalLayoutStore`'s:
7+
/// `GraphcodeKit` never imports `Dependencies`, and quick chats are app-UI-only.
8+
extension QuickChatStore: DependencyKey {
9+
public static let liveValue = QuickChatStore(baseDirectory: SupportDirectory.url)
10+
}
11+
12+
extension DependencyValues {
13+
var quickChatStore: QuickChatStore {
14+
get { self[QuickChatStore.self] }
15+
set { self[QuickChatStore.self] = newValue }
16+
}
17+
}

graphcode/Sources/Features/App/AppFeature.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ struct AppFeature {
2424
var selectedProjectPath: String?
2525
var openLoop: LoopWorkspaceFeature.State?
2626

27+
/// Ad-hoc backend sessions with no loop semantics — the sidebar's Quick Chats
28+
/// section. App-local (see `QuickChat`); loaded once at `.task` and saved on every
29+
/// mutation. A chat's workspace is opened through the same `openLoop` as a loop's,
30+
/// with a synthetic node — membership in this list is what marks it as a chat.
31+
var quickChats: IdentifiedArrayOf<QuickChat> = []
32+
33+
/// Whether the open workspace is a quick chat rather than a graph node's loop.
34+
func isQuickChat(_ nodeID: UUID) -> Bool { quickChats[id: nodeID] != nil }
35+
2736
/// The orchestrator's needs-attention rollup, across every open project
2837
/// (docs/05-orchestrator.md#monitoring-surface). Derived rather than stored: it's a
2938
/// pure function of the graphs the daemon already broadcasts, and a cached copy
@@ -101,12 +110,18 @@ struct AppFeature {
101110
case selectPreviousLoop
102111
/// The stop/kill affordance docs/05-orchestrator.md asks the monitor for.
103112
case stopNodeTapped(projectPath: String, nodeID: UUID)
113+
/// The Quick Chats section's actions — see `State.quickChats`.
114+
case newQuickChatTapped
115+
case quickChatTapped(UUID)
116+
case quickChatRenamed(id: UUID, title: String)
117+
case quickChatDeleteConfirmed(UUID)
104118
}
105119

106120
private enum CancelID { case daemonSubscription }
107121

108122
@Dependency(\.orchestratorClient) var orchestratorClient
109123
@Dependency(\.terminalLayoutStore) var terminalLayoutStore
124+
@Dependency(\.quickChatStore) var quickChatStore
110125
/// Only for the cases where a workspace goes away because the *loop* did. Merely
111126
/// switching to another loop leaves its surfaces alive on purpose — see
112127
/// `TerminalSurfaceStore` — but a deleted loop, or a closed project, is never coming
@@ -120,6 +135,7 @@ struct AppFeature {
120135
Reduce { state, action in
121136
switch action {
122137
case .task:
138+
state.quickChats = IdentifiedArray(uniqueElements: quickChatStore.load())
123139
return .merge(
124140
.run { send in
125141
for await event in orchestratorClient.connect() {
@@ -274,6 +290,43 @@ struct AppFeature {
274290
.graphCommand(projectPath: projectPath, command: .stopNode(nodeID)))
275291
}
276292

293+
case .newQuickChatTapped:
294+
let chat = QuickChat(
295+
title: "Chat — \(Date().formatted(.dateTime.month(.abbreviated).day()))",
296+
backend: GraphcodeSettingsStore.load().defaultBackend)
297+
state.quickChats.append(chat)
298+
quickChatStore.save(Array(state.quickChats))
299+
openQuickChat(chat, &state)
300+
return .none
301+
302+
case .quickChatTapped(let id):
303+
guard let chat = state.quickChats[id: id] else { return .none }
304+
guard state.openLoop?.node.id != id else { return .none }
305+
openQuickChat(chat, &state)
306+
return .none
307+
308+
case .quickChatRenamed(let id, let title):
309+
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
310+
guard !trimmed.isEmpty, state.quickChats[id: id] != nil else { return .none }
311+
state.quickChats[id: id]?.title = trimmed
312+
quickChatStore.save(Array(state.quickChats))
313+
if state.openLoop?.node.id == id {
314+
state.openLoop?.node.title = trimmed
315+
}
316+
return .none
317+
318+
case .quickChatDeleteConfirmed(let id):
319+
guard state.quickChats[id: id] != nil else { return .none }
320+
state.quickChats.remove(id: id)
321+
quickChatStore.save(Array(state.quickChats))
322+
if state.openLoop?.node.id == id {
323+
closeOpenWorkspace(&state)
324+
state.selectedProjectPath = state.projects.first?.id
325+
}
326+
// The chat's session is app-owned — no daemon cleans it up the way GraphStore
327+
// does for a deleted loop, so its zmx session is killed here.
328+
return .run { _ in await ZmxSessionLauncher.killSession(id: id) }
329+
277330
// When creating a new loop while another loop's workspace is open, inherit that
278331
// loop's backend. Matches `parentBackend: nil` only — the re-sent action carries
279332
// a value, so it falls through instead of looping.
@@ -308,6 +361,9 @@ struct AppFeature {
308361
case .openLoop(.primarySurfaceExited(let succeeded)):
309362
guard let id = state.openLoop?.node.id, let projectPath = state.selectedProjectPath
310363
else { return .none }
364+
// A chat's session ending resolves nothing — there is no node in any graph for
365+
// the daemon to update, so telling it would only earn an unknown-node error.
366+
guard !state.isQuickChat(id) else { return .none }
311367
let command: GraphCommand = succeeded ? .nodeCheckApproved(id) : .nodeCheckRejected(id)
312368
return .run { _ in
313369
try? await orchestratorClient.send(
@@ -370,6 +426,28 @@ struct AppFeature {
370426
state.openLoop = nil
371427
}
372428

429+
/// Opens a chat in the same terminal workspace a loop gets, via a synthetic node.
430+
/// `.proactive` is the one loop type whose `sessionPrompt` is nil, which is exactly
431+
/// what a chat wants: the backend starts bare, with nothing pre-typed into it. The
432+
/// node's id is the chat's, so the workspace attaches to the chat's own long-lived
433+
/// zmx session, scrollback and all. Home as the working directory — a chat belongs
434+
/// to no project on purpose.
435+
private func openQuickChat(_ chat: QuickChat, _ state: inout State) {
436+
let node = LoopNode(
437+
id: chat.id,
438+
title: chat.title,
439+
loopType: .proactive,
440+
backend: chat.backend,
441+
createdAt: chat.createdAt)
442+
let layout = terminalLayoutStore.load(forNode: chat.id) ?? .defaultLayout(forNode: chat.id)
443+
state.openLoop = LoopWorkspaceFeature.State(
444+
node: node,
445+
layout: layout,
446+
projectPath: NSHomeDirectory(),
447+
projectName: "Quick Chat")
448+
state.selectedProjectPath = nil
449+
}
450+
373451
private func removeFromSidebar(_ state: inout State, path: String) {
374452
state.projects.remove(id: path)
375453
if state.openLoop?.projectPath == path {

0 commit comments

Comments
 (0)