Skip to content

Commit 9033fb6

Browse files
scgopiclaude
andcommitted
Give Quick Chats a canvas of its own
The Quick Chats header row listed sessions but opened nothing — the one sidebar row that wasn't a way somewhere. It now selects like a folder's row does and shows the chats drawn as a canvas: ruled sheet, start marker, a card per chat, pan and zoom, and a + where every other canvas keeps one. An empty list gets the same "here's what this is, here's the one thing to do about it" card a folder with no loops gets, generalised out of CanvasEmptyState rather than copied. Chat cards carry no loop-type stripe, no presence dot, and no edges: a chat has no goal, no trigger, and no place in any graph, and borrowing those channels would claim semantics it doesn't have. Every card hangs straight off the start marker, which is what each of them is. Selection grew a case rather than a reserved path — Quick Chats is not a project and the daemon has never heard of it — with selectedProjectPath kept as a facade over it so every project-only caller is untouched. The rename prompt and delete confirmation moved out of the sidebar's local state into the reducer, hosted once by AppView, now that either can be started from a sidebar row or from a card and only one of the two surfaces is ever on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 722ef2e commit 9033fb6

7 files changed

Lines changed: 665 additions & 127 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import ComposableArchitecture
2+
import Foundation
3+
import GraphcodeKit
4+
5+
/// The Quick Chats section of the root feature — the sidebar rows, the canvas
6+
/// (`QuickChatsCanvasView`), and the rename/delete prompts `AppView` hosts for both.
7+
///
8+
/// Split out of `AppFeature` rather than nested in its switch because chats share none of
9+
/// that reducer's subject matter: no project, no graph, no daemon. What they *do* share is
10+
/// state — a chat's workspace is the same `openLoop` a loop's is, and closing one falls
11+
/// back to the same `detailSelection` — so this stays a slice of the root reducer over the
12+
/// same `State` and `Action` instead of becoming a feature with a store of its own.
13+
extension AppFeature {
14+
var quickChatsReducer: some ReducerOf<Self> {
15+
Reduce { state, action in
16+
switch action {
17+
case .newQuickChatTapped:
18+
let chat = QuickChat(
19+
title: "Chat — \(Date().formatted(.dateTime.month(.abbreviated).day()))",
20+
backend: GraphcodeSettingsStore.load().defaultBackend)
21+
state.quickChats.append(chat)
22+
quickChatStore.save(Array(state.quickChats))
23+
openQuickChat(chat, &state)
24+
return .none
25+
26+
case .quickChatsTapped:
27+
state.detailSelection = .quickChats
28+
state.openLoop = nil
29+
return .none
30+
31+
case .quickChatTapped(let id):
32+
guard let chat = state.quickChats[id: id] else { return .none }
33+
guard state.openLoop?.node.id != id else { return .none }
34+
openQuickChat(chat, &state)
35+
return .none
36+
37+
case .quickChatRenameRequested(let id):
38+
guard let chat = state.quickChats[id: id] else { return .none }
39+
state.chatPendingRename = id
40+
// The field opens on the title it has: a rename is an edit, not a re-entry.
41+
state.draftChatTitle = chat.title
42+
return .none
43+
44+
case .quickChatRenameTitleChanged(let title):
45+
state.draftChatTitle = title
46+
return .none
47+
48+
case .quickChatRenameCancelled:
49+
state.chatPendingRename = nil
50+
return .none
51+
52+
case .quickChatRenameConfirmed:
53+
let trimmed = state.draftChatTitle.trimmingCharacters(in: .whitespacesAndNewlines)
54+
guard let id = state.chatPendingRename else { return .none }
55+
state.chatPendingRename = nil
56+
guard !trimmed.isEmpty, state.quickChats[id: id] != nil else { return .none }
57+
state.quickChats[id: id]?.title = trimmed
58+
quickChatStore.save(Array(state.quickChats))
59+
// The open workspace carries a synthetic copy of this chat, so its header would
60+
// otherwise keep the old name until the chat was reopened.
61+
if state.openLoop?.node.id == id {
62+
state.openLoop?.node.title = trimmed
63+
}
64+
return .none
65+
66+
case .quickChatDeleteRequested(let id):
67+
guard state.quickChats[id: id] != nil else { return .none }
68+
state.chatPendingDeletion = id
69+
return .none
70+
71+
case .quickChatDeleteCancelled:
72+
state.chatPendingDeletion = nil
73+
return .none
74+
75+
case .quickChatDeleteConfirmed:
76+
guard let id = state.chatPendingDeletion else { return .none }
77+
state.chatPendingDeletion = nil
78+
guard state.quickChats[id: id] != nil else { return .none }
79+
state.quickChats.remove(id: id)
80+
quickChatStore.save(Array(state.quickChats))
81+
if state.openLoop?.node.id == id {
82+
closeOpenWorkspace(&state)
83+
// Back to the chats' own canvas rather than to some folder: the chat that was
84+
// on screen is gone, but what you were doing was chatting.
85+
state.detailSelection = .quickChats
86+
}
87+
// The chat's session is app-owned — no daemon cleans it up the way GraphStore
88+
// does for a deleted loop, so its zmx session is killed here.
89+
return .run { _ in await ZmxSessionLauncher.killSession(id: id) }
90+
91+
default:
92+
return .none
93+
}
94+
}
95+
}
96+
97+
/// Opens a chat in the same terminal workspace a loop gets, via a synthetic node.
98+
/// `.proactive` is the one loop type whose `sessionPrompt` is nil, which is exactly
99+
/// what a chat wants: the backend starts bare, with nothing pre-typed into it. The
100+
/// node's id is the chat's, so the workspace attaches to the chat's own long-lived
101+
/// zmx session, scrollback and all. Home as the working directory — a chat belongs
102+
/// to no project on purpose.
103+
private func openQuickChat(_ chat: QuickChat, _ state: inout State) {
104+
let node = LoopNode(
105+
id: chat.id,
106+
title: chat.title,
107+
loopType: .proactive,
108+
backend: chat.backend,
109+
createdAt: chat.createdAt)
110+
let layout = terminalLayoutStore.load(forNode: chat.id) ?? .defaultLayout(forNode: chat.id)
111+
state.openLoop = LoopWorkspaceFeature.State(
112+
node: node,
113+
layout: layout,
114+
projectPath: NSHomeDirectory(),
115+
projectName: "Quick Chat")
116+
// Quick Chats is what this workspace came from, so it's what closing it falls back
117+
// to — the same rule a loop follows with its folder.
118+
state.detailSelection = .quickChats
119+
}
120+
}

graphcode/Sources/Features/App/AppFeature.swift

Lines changed: 67 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -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
1919
struct 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

Comments
 (0)