@@ -13,17 +13,41 @@ import GraphcodeKit
1313/// project's canvas is the fallback" can live inside any one `ProjectFeature.State`.
1414/// They live here instead: `openLoop` (at most one loop's whole terminal
1515/// workspace — tabs and splits, see `LoopWorkspaceFeature` — is open at a time, the way
16- /// a supacode worktree owns its own terminal area) and `selectedProjectPath ` (which
17- /// project's canvas is the fallback when no loop is open).
16+ /// a supacode worktree owns its own terminal area) and `detailSelection ` (which canvas —
17+ /// a folder's, or Quick Chats' — is the fallback when no loop is open).
1818@Reducer
1919struct AppFeature {
20+ /// Which canvas the detail pane falls back to when no loop's workspace is open.
21+ ///
22+ /// Quick Chats is a case rather than a reserved path because it is not a project and
23+ /// has no graph the daemon knows about — it's the app's own surface, drawn like a
24+ /// folder's canvas (see `QuickChatsCanvasView`) because that's what it is to a human:
25+ /// another place sessions live.
26+ enum DetailSelection : Equatable {
27+ case project( String )
28+ case quickChats
29+ }
30+
2031 @ObservableState
2132 struct State : Equatable {
2233 var welcome = WelcomeFeature . State ( )
2334 var projects : IdentifiedArrayOf < ProjectFeature . State > = [ ]
24- var selectedProjectPath : String ?
2535 var openLoop : LoopWorkspaceFeature . State ?
2636
37+ var detailSelection : DetailSelection ?
38+
39+ /// The selected *folder*, when the selection is one. Every caller that only ever
40+ /// deals in projects — opening one, closing one, following a node tap — keeps
41+ /// reading and writing selection through this; `nil` now also covers "Quick Chats",
42+ /// which no folder path could ever name.
43+ var selectedProjectPath : String ? {
44+ get {
45+ if case . project( let path) = detailSelection { return path }
46+ return nil
47+ }
48+ set { detailSelection = newValue. map ( DetailSelection . project) }
49+ }
50+
2751 /// Ad-hoc backend sessions with no loop semantics — the sidebar's Quick Chats
2852 /// section. App-local (see `QuickChat`); loaded once at `.task` and saved on every
2953 /// mutation. A chat's workspace is opened through the same `openLoop` as a loop's,
@@ -33,6 +57,20 @@ struct AppFeature {
3357 /// Whether the open workspace is a quick chat rather than a graph node's loop.
3458 func isQuickChat( _ nodeID: UUID ) -> Bool { quickChats [ id: nodeID] != nil }
3559
60+ /// The chat a rename prompt is up for, plus what has been typed so far, and the chat
61+ /// a delete confirmation is up for.
62+ ///
63+ /// In the reducer rather than in a view's `@State` for the same reason
64+ /// `pendingLoopRename` is: both the sidebar and the Quick Chats canvas start these
65+ /// verbs, only one of the two is on screen at any moment, and the dialogs are hosted
66+ /// once — by `AppView` — so neither surface can present a version of its own.
67+ var chatPendingRename : UUID ?
68+ var draftChatTitle = " "
69+ var chatPendingDeletion : UUID ?
70+
71+ var pendingChatRename : QuickChat ? { chatPendingRename. flatMap { quickChats [ id: $0] } }
72+ var pendingChatDeletion : QuickChat ? { chatPendingDeletion. flatMap { quickChats [ id: $0] } }
73+
3674 /// Up on first launch (`.task` checks the persisted flag) and whenever the
3775 /// sidebar's help button asks for it again.
3876 var showingOnboarding = false
@@ -119,9 +157,19 @@ struct AppFeature {
119157 case onboardingDismissed
120158 /// The Quick Chats section's actions — see `State.quickChats`.
121159 case newQuickChatTapped
160+ /// The Quick Chats header row: shows the chats' own canvas, the way a folder row
161+ /// shows that folder's.
162+ case quickChatsTapped
122163 case quickChatTapped( UUID )
123- case quickChatRenamed( id: UUID , title: String )
124- case quickChatDeleteConfirmed( UUID )
164+ /// Rename and delete, each startable from the sidebar row *or* the canvas card —
165+ /// which is why the prompt they raise lives in state; see `State.chatPendingRename`.
166+ case quickChatRenameRequested( UUID )
167+ case quickChatRenameTitleChanged( String )
168+ case quickChatRenameConfirmed
169+ case quickChatRenameCancelled
170+ case quickChatDeleteRequested( UUID )
171+ case quickChatDeleteConfirmed
172+ case quickChatDeleteCancelled
125173 }
126174
127175 private enum CancelID { case daemonSubscription }
@@ -139,6 +187,10 @@ struct AppFeature {
139187 Scope ( state: \. welcome, action: \. welcome) {
140188 WelcomeFeature ( )
141189 }
190+ // The Quick Chats section, in `AppFeature+QuickChats.swift` — the same state and
191+ // actions, kept in one place of its own because chats are a whole surface (a sidebar
192+ // section, a canvas, and two dialogs) that has nothing to do with projects or graphs.
193+ quickChatsReducer
142194 Reduce { state, action in
143195 switch action {
144196 case . task:
@@ -313,43 +365,15 @@ struct AppFeature {
313365 UserDefaults . standard. set ( true , forKey: " hasSeenOnboarding " )
314366 return . none
315367
316- case . newQuickChatTapped :
317- let chat = QuickChat (
318- title : " Chat — \( Date ( ) . formatted ( . dateTime . month ( . abbreviated ) . day ( ) ) ) " ,
319- backend : GraphcodeSettingsStore . load ( ) . defaultBackend )
320- state . quickChats . append ( chat )
321- quickChatStore . save ( Array ( state . quickChats ) )
322- openQuickChat ( chat , & state )
368+ // Every Quick Chats action is handled by `quickChatsReducer`, in
369+ // `AppFeature+QuickChats.swift` — listed here only so this switch stays exhaustive
370+ // and a new action can't be added without deciding which of the two answers it.
371+ case . newQuickChatTapped , . quickChatsTapped , . quickChatTapped ,
372+ . quickChatRenameRequested , . quickChatRenameTitleChanged , . quickChatRenameConfirmed ,
373+ . quickChatRenameCancelled , . quickChatDeleteRequested , . quickChatDeleteConfirmed ,
374+ . quickChatDeleteCancelled :
323375 return . none
324376
325- case . quickChatTapped( let id) :
326- guard let chat = state. quickChats [ id: id] else { return . none }
327- guard state. openLoop? . node. id != id else { return . none }
328- openQuickChat ( chat, & state)
329- return . none
330-
331- case . quickChatRenamed( let id, let title) :
332- let trimmed = title. trimmingCharacters ( in: . whitespacesAndNewlines)
333- guard !trimmed. isEmpty, state. quickChats [ id: id] != nil else { return . none }
334- state. quickChats [ id: id] ? . title = trimmed
335- quickChatStore. save ( Array ( state. quickChats) )
336- if state. openLoop? . node. id == id {
337- state. openLoop? . node. title = trimmed
338- }
339- return . none
340-
341- case . quickChatDeleteConfirmed( let id) :
342- guard state. quickChats [ id: id] != nil else { return . none }
343- state. quickChats. remove ( id: id)
344- quickChatStore. save ( Array ( state. quickChats) )
345- if state. openLoop? . node. id == id {
346- closeOpenWorkspace ( & state)
347- state. selectedProjectPath = state. projects. first? . id
348- }
349- // The chat's session is app-owned — no daemon cleans it up the way GraphStore
350- // does for a deleted loop, so its zmx session is killed here.
351- return . run { _ in await ZmxSessionLauncher . killSession ( id: id) }
352-
353377 // When creating a new loop while another loop's workspace is open, inherit that
354378 // loop's backend. Matches `parentBackend: nil` only — the re-sent action carries
355379 // a value, so it falls through instead of looping.
@@ -442,35 +466,14 @@ struct AppFeature {
442466 private func isGlobal( _ path: String ) -> Bool { path == LoopGraphScope . globalPath }
443467
444468 /// Closes the open workspace *and ends its terminals* — for when the loop itself is
445- /// gone, as opposed to merely not being the one on screen any more.
446- private func closeOpenWorkspace( _ state: inout State ) {
469+ /// gone, as opposed to merely not being the one on screen any more. Not `private`
470+ /// because a deleted chat needs the same treatment and lives in the other file.
471+ func closeOpenWorkspace( _ state: inout State ) {
447472 guard let openLoop = state. openLoop else { return }
448473 terminalSurfaceClient. retire ( openLoop. layout. tabs. flatMap { $0. surfaces. map ( \. id) } )
449474 state. openLoop = nil
450475 }
451476
452- /// Opens a chat in the same terminal workspace a loop gets, via a synthetic node.
453- /// `.proactive` is the one loop type whose `sessionPrompt` is nil, which is exactly
454- /// what a chat wants: the backend starts bare, with nothing pre-typed into it. The
455- /// node's id is the chat's, so the workspace attaches to the chat's own long-lived
456- /// zmx session, scrollback and all. Home as the working directory — a chat belongs
457- /// to no project on purpose.
458- private func openQuickChat( _ chat: QuickChat , _ state: inout State ) {
459- let node = LoopNode (
460- id: chat. id,
461- title: chat. title,
462- loopType: . proactive,
463- backend: chat. backend,
464- createdAt: chat. createdAt)
465- let layout = terminalLayoutStore. load ( forNode: chat. id) ?? . defaultLayout( forNode: chat. id)
466- state. openLoop = LoopWorkspaceFeature . State (
467- node: node,
468- layout: layout,
469- projectPath: NSHomeDirectory ( ) ,
470- projectName: " Quick Chat " )
471- state. selectedProjectPath = nil
472- }
473-
474477 /// The nearest project on the given side of `index` that lives in the same sidebar
475478 /// section — local folders and remote repositories are separate sections, and a move
476479 /// must not carry a project across the divider. `nil` when there's nothing of its
0 commit comments