@@ -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