@spzxg/cli-ui 0.6.1 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +145 -13
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-BKHhqcoa.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +113 -53
  26. package/src/chat/Composer.tsx +68 -57
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +16 -6
  35. package/src/core/config.ts +11 -15
  36. package/src/core/conversation.ts +4 -4
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +24 -14
  40. package/src/core/store.ts +83 -26
  41. package/src/core/types.ts +34 -0
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/msglist/CliMsgList.tsx +10 -10
  47. package/src/operator/branchFlow.ts +35 -0
  48. package/src/operator/index.ts +9 -0
  49. package/src/operator/liveStream.ts +1 -0
  50. package/src/operator/node/AgentConnect.tsx +624 -0
  51. package/src/operator/node/AppearancePanel.tsx +35 -0
  52. package/src/operator/node/BranchHistory.tsx +67 -0
  53. package/src/operator/node/BranchNavigator.tsx +36 -0
  54. package/src/operator/node/BrowserPane.tsx +128 -0
  55. package/src/operator/node/BrowserPopup.tsx +22 -0
  56. package/src/operator/node/Chat.tsx +517 -0
  57. package/src/operator/node/CommandPalette.tsx +213 -0
  58. package/src/operator/node/Composer.tsx +287 -0
  59. package/src/operator/node/InsightsView.tsx +831 -0
  60. package/src/operator/node/Layout.tsx +190 -0
  61. package/src/operator/node/Markdown.tsx +61 -0
  62. package/src/operator/node/Modal.tsx +179 -0
  63. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  64. package/src/operator/node/OnboardingWizard.tsx +250 -0
  65. package/src/operator/node/Orchestrator.tsx +634 -0
  66. package/src/operator/node/ProjectLanding.tsx +86 -0
  67. package/src/operator/node/ProjectsColumn.tsx +137 -0
  68. package/src/operator/node/ProviderAuth.tsx +561 -0
  69. package/src/operator/node/RunControls.tsx +2 -0
  70. package/src/operator/node/RunSwitch.tsx +113 -0
  71. package/src/operator/node/Services.tsx +245 -0
  72. package/src/operator/node/SessionRail.tsx +260 -0
  73. package/src/operator/node/SessionView.tsx +288 -0
  74. package/src/operator/node/SettingsView.tsx +690 -0
  75. package/src/operator/node/TaskBoard.tsx +352 -0
  76. package/src/operator/node/TaskChanges.tsx +530 -0
  77. package/src/operator/node/TaskHero.tsx +50 -0
  78. package/src/operator/node/TasksColumn.tsx +200 -0
  79. package/src/operator/node/Terminal.tsx +143 -0
  80. package/src/operator/node/Transcript.tsx +463 -0
  81. package/src/operator/node/ViewFile.tsx +79 -0
  82. package/src/operator/node/Welcome.tsx +101 -0
  83. package/src/operator/node/WorktreePrune.tsx +333 -0
  84. package/src/operator/node/agents.ts +58 -0
  85. package/src/operator/node/api.ts +40 -0
  86. package/src/operator/node/boardBackend.ts +80 -0
  87. package/src/operator/node/boardEvents.ts +22 -0
  88. package/src/operator/node/boardInteraction.ts +15 -0
  89. package/src/operator/node/boardState.ts +57 -0
  90. package/src/operator/node/boardWorkspace.ts +12 -0
  91. package/src/operator/node/branchFlow.ts +1 -0
  92. package/src/operator/node/composerDraft.ts +52 -0
  93. package/src/operator/node/fileLinks.ts +81 -0
  94. package/src/operator/node/format.ts +290 -0
  95. package/src/operator/node/github.tsx +234 -0
  96. package/src/operator/node/icons.tsx +88 -0
  97. package/src/operator/node/liveStream.ts +270 -0
  98. package/src/operator/node/modals.tsx +720 -0
  99. package/src/operator/node/navHistory.ts +70 -0
  100. package/src/operator/node/persist.ts +26 -0
  101. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  102. package/src/operator/node/protocol/agents/types.ts +394 -0
  103. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  104. package/src/operator/node/protocol/authFailure.ts +66 -0
  105. package/src/operator/node/protocol/customModels.ts +23 -0
  106. package/src/operator/node/protocol/events.ts +13 -0
  107. package/src/operator/node/protocol/features.ts +70 -0
  108. package/src/operator/node/protocol/internalUsage.ts +1 -0
  109. package/src/operator/node/protocol/promptLimits.ts +52 -0
  110. package/src/operator/node/protocol/storageRetention.ts +13 -0
  111. package/src/operator/node/protocol/types.ts +332 -0
  112. package/src/operator/node/protocol/usageLimit.ts +72 -0
  113. package/src/operator/node/shared.tsx +89 -0
  114. package/src/operator/node/transcriptModel.ts +129 -0
  115. package/src/operator/node/types.ts +296 -0
  116. package/src/operator/node/useGlobalEvents.ts +97 -0
  117. package/src/operator/node/useOrchestrator.ts +696 -0
  118. package/src/operator/node/usePrefs.ts +114 -0
  119. package/src/operator/node/useRecaps.ts +91 -0
  120. package/src/operator/node/useTaskStream.ts +279 -0
  121. package/src/operator.base.css +1798 -0
  122. package/src/operator.css +186 -16
  123. package/src/styles.css +51 -0
  124. package/src/ui/Markdown.tsx +7 -2
  125. package/src/ui/Popover.tsx +77 -0
  126. package/src/ui/TextBlock.tsx +3 -3
  127. package/src/ui/Transcript.tsx +10 -8
  128. package/src/ui/fileLinks.ts +6 -0
  129. package/src/ui/format.ts +1 -1
  130. package/src/ui/icons.tsx +1 -0
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
@@ -0,0 +1,696 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import type { Priority, Status, AskQuestion, AskAnswers } from "./protocol/types";
5
+ import type { ResolveResult } from "./TaskChanges";
6
+ import { jget, jsend } from "./api";
7
+ import { isAwaiting, blockerTitles, formatAnswersText } from "./format";
8
+ import { loadPersist, readUrlSel } from "./persist";
9
+ import { DEFAULT_SETTINGS, EMPTY_AGENTS, type AgentsBundle, type OnboardingT, type ProjectRow, type TaskRow } from "./types";
10
+ import { agentLabel } from "./agents";
11
+ import { useTaskStream } from "./useTaskStream";
12
+ import { useGlobalEvents } from "./useGlobalEvents";
13
+ import { usePrefs } from "./usePrefs";
14
+ import { useRecaps } from "./useRecaps";
15
+
16
+ // How long a send may go unanswered before the UI stops waiting for it (runTurn).
17
+ // Generous: a task's first turn creates its git worktree, which can take a
18
+ // while in a large repository.
19
+ const SEND_TIMEOUT_MS = 120_000;
20
+
21
+ type Modal = null | "task" | "context" | "project" | "sessions";
22
+
23
+ // The orchestrator's single source of truth: all client state, the derived
24
+ // views over it, the data-loading effects, and every action callback. Returns a
25
+ // flat bag the composition root (Orchestrator.tsx) wires straight into the UI.
26
+ export function useOrchestrator() {
27
+ const [projects, setProjects] = useState<ProjectRow[]>([]);
28
+ const [selProj, setSelProj] = useState<string | null>(null);
29
+ const [tasks, setTasks] = useState<TaskRow[]>([]);
30
+ const [selTask, setSelTask] = useState<string | null>(null);
31
+ // First-paint state: booted flips once the initial project fetch lands, so the
32
+ // shell can show a column skeleton instead of a blank flash; a failed boot
33
+ // surfaces as a retryable error screen instead of an empty workspace.
34
+ const [booted, setBooted] = useState(false);
35
+ const [bootError, setBootError] = useState<string | null>(null);
36
+ // Which project the current `tasks` array actually belongs to — until it
37
+ // matches the selected project, the tasks column shows a skeleton rather than
38
+ // the previous project's (stale) list.
39
+ const [tasksFor, setTasksFor] = useState<string | null>(null);
40
+ const [running, setRunning] = useState<Set<string>>(new Set());
41
+ const [modal, setModal] = useState<Modal>(null);
42
+ const [editId, setEditId] = useState<string | null>(null);
43
+ const [appearanceOpen, setAppearanceOpen] = useState(false);
44
+ const [termOpen, setTermOpen] = useState(false);
45
+ const [termMounted, setTermMounted] = useState(false); // mount once, then keep alive across collapses
46
+ const [termHeight, setTermHeight] = useState(300);
47
+ // Managed-services drawer — same mount-once-keep-alive pattern as the terminal.
48
+ const [servicesOpen, setServicesOpen] = useState(false);
49
+ const [servicesMounted, setServicesMounted] = useState(false);
50
+ const [servicesHeight, setServicesHeight] = useState(300);
51
+ // Server-backed app defaults (e.g. default reasoning / permission mode a task
52
+ // inherits). Stored in orchestrator.db, not localStorage, so runTurn can read them.
53
+ const [appDefaults, setAppDefaults] = useState<Record<string, string>>({});
54
+ const [appDefaultsReady, setAppDefaultsReady] = useState(false);
55
+ // Agent capability descriptors (GET /api/agents): the model/reasoning/permission
56
+ // pickers, per-task agent badges, cost/ask gates, and the new-task agent picker
57
+ // all read from this — no hardcoded per-agent lists in the client.
58
+ const [agents, setAgents] = useState<AgentsBundle>(EMPTY_AGENTS);
59
+ // First-run onboarding. `onboarding` is the persisted wizard state (resume
60
+ // point + connection); `wizardOpen` mounts the full-screen wizard over the app.
61
+ const [onboarding, setOnboarding] = useState<OnboardingT | null>(null);
62
+ const [wizardOpen, setWizardOpen] = useState(false);
63
+ // Shown after the built-in tutorial task is merged — the "now build your own" nudge.
64
+ const [nudge, setNudge] = useState(false);
65
+
66
+ const project = useMemo(() => projects.find((p) => p.id === selProj) ?? null, [projects, selProj]);
67
+ const activeProjects = useMemo(() => projects.filter((p) => !p.deprecated), [projects]);
68
+ const deprecatedProjects = useMemo(() => projects.filter((p) => p.deprecated), [projects]);
69
+ const realTasks = useMemo(() => tasks.filter((t) => !t.suggested), [tasks]);
70
+ const suggested = useMemo(() => tasks.filter((t) => t.suggested), [tasks]);
71
+ const task = useMemo(() => tasks.find((t) => t.id === selTask) ?? null, [tasks, selTask]);
72
+ // taskId -> titles of its unfinished blockers. A task in this map is blocked:
73
+ // it shows a "Blocked by" chip and its Start button is disabled. Recomputed from
74
+ // the live task list, so a blocker hitting 'done' auto-unblocks dependents.
75
+ const tasksById = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
76
+ const blockedBy = useMemo(() => {
77
+ const m = new Map<string, string[]>();
78
+ for (const t of tasks) {
79
+ const names = blockerTitles(t, tasksById);
80
+ if (names.length) m.set(t.id, names);
81
+ }
82
+ return m;
83
+ }, [tasks, tasksById]);
84
+
85
+ // Cross-project "Needs you" surfacing. The selected project's tasks are live in
86
+ // state (so its count reflects awaiting_input as it changes mid-turn); other
87
+ // projects come from the server-computed awaiting_count on the project list.
88
+ const liveAwaiting = useMemo(() => realTasks.filter((t) => isAwaiting(t)), [realTasks]);
89
+ const needsYouTotal = useMemo(
90
+ () => activeProjects.reduce((n, p) => n + (p.id === selProj ? liveAwaiting.length : p.awaiting_count), 0),
91
+ [activeProjects, selProj, liveAwaiting]
92
+ );
93
+
94
+ const setTaskRunning = (id: string, on: boolean) =>
95
+ setRunning((prev) => { const n = new Set(prev); on ? n.add(id) : n.delete(id); return n; });
96
+
97
+ // Capture the URL selection synchronously on first render. The URL-sync effect
98
+ // runs (once `hydrated` flips) before the async project fetch applies selection,
99
+ // and would wipe the query string while selProj/selTask are still null — so we
100
+ // must read ?project/?task before that happens, not inside the fetch callback.
101
+ const urlSelRef = useRef<{ project?: string; task?: string; view?: string } | null>(null);
102
+ if (urlSelRef.current === null) urlSelRef.current = readUrlSel();
103
+
104
+ // selProjRef tracks selProj for callbacks/intervals that must read the latest
105
+ // value without re-subscribing (the live stream handler + the recap sweep).
106
+ const selProjRef = useRef(selProj);
107
+ useEffect(() => { selProjRef.current = selProj; }, [selProj]);
108
+
109
+ // Latest agents bundle for the live stream handler (context-window sizing),
110
+ // read without re-subscribing the EventSource.
111
+ const agentsRef = useRef(agents);
112
+ useEffect(() => { agentsRef.current = agents; }, [agents]);
113
+
114
+ const taskLoads = useRef(new Map<string, number>());
115
+ const loadTasks = useCallback(async (projectId: string, selectFirst = true) => {
116
+ const request = (taskLoads.current.get(projectId) ?? 0) + 1;
117
+ taskLoads.current.set(projectId, request);
118
+ const data = await jget<{ tasks: TaskRow[] }>(`/api/projects/${projectId}`);
119
+ if (taskLoads.current.get(projectId) !== request) return;
120
+ // A slow response for the previous project must not replace the visible list
121
+ // or attach that project's task/session to the newly selected project.
122
+ if (selProjRef.current === projectId) {
123
+ setTasks(data.tasks);
124
+ setTasksFor(projectId);
125
+ if (selectFirst) {
126
+ const first = data.tasks.find((t) => !t.suggested);
127
+ setSelTask(first ? first.id : null);
128
+ }
129
+ }
130
+ // Reconcile the running set only against THIS project's tasks — add/remove
131
+ // ids it actually fetched. A partial per-project view must never imply that
132
+ // tasks it can't see (running in other projects) have stopped; those are
133
+ // cleared authoritatively by reconcileRunning() below.
134
+ setRunning((prev) => { const n = new Set(prev); for (const t of data.tasks) t.running ? n.add(t.id) : n.delete(t.id); return n; });
135
+ }, []);
136
+
137
+ // Authoritative fleet-wide reconciliation of the running set. Cross-project
138
+ // turn boundaries normally arrive on the global /api/events stream — but if
139
+ // that stream was disconnected (laptop sleep, tunnel drop) when a turn ended,
140
+ // the event is gone and the spinner would stick forever: the client only
141
+ // refetches the SELECTED project's rows, so no other path would ever clear
142
+ // it. useGlobalEvents calls this on every SSE reconnect; replacing the whole
143
+ // set with the server's global truth drains any stale entries.
144
+ const reconcileRunning = useCallback(async () => {
145
+ try { const { ids } = await jget<{ ids: string[] }>("/api/running"); setRunning(new Set(ids)); } catch {}
146
+ }, []);
147
+
148
+ // Load the agent capability bundle (drives every run-control picker AND the
149
+ // per-agent connected/authenticated/authBroken status). Re-runnable: any in-app
150
+ // connect (wizard finish, Settings → Agents) calls this so the shared bundle —
151
+ // and thus the New-task dialog's "· not connected" flag and the reconnect
152
+ // banner — updates live, no reload. Declared above the event streams because
153
+ // useGlobalEvents calls it when an agent's login dies or recovers.
154
+ const refreshAgents = useCallback(
155
+ () => jget<AgentsBundle>("/api/agents").then(setAgents).catch(() => {}),
156
+ []
157
+ );
158
+ useEffect(() => { void refreshAgents(); }, [refreshAgents]);
159
+
160
+ // Agents that are connected on record but whose credentials just stopped
161
+ // working — the titlebar reconnect banner's input. Normally empty.
162
+ const brokenAgents = useMemo(() => agents.agents.filter((a) => !!a.authBroken), [agents]);
163
+
164
+ // ---------- live task event stream + transcript state ----------
165
+ const { msgsByTask, olderByTask, loadOlder, appendMsg, setAnswerOnMsg } = useTaskStream({
166
+ selTask, selProjRef, agentsRef, setTaskRunning, setTasks, setProjects, loadTasks,
167
+ });
168
+ // Always-open global lifecycle stream (GET /api/events): keeps spinners,
169
+ // project badges, and the "N need you" pill live for tasks whose transcript
170
+ // stream ISN'T open — only the selected task has one.
171
+ useGlobalEvents({ selProjRef, setTaskRunning, setTasks, setProjects, loadTasks, reconcileRunning, refreshAgents });
172
+ const messages = selTask ? msgsByTask[selTask] ?? [] : [];
173
+ // Earlier messages of the selected task not loaded yet (paged in on scroll).
174
+ const olderCount = selTask ? olderByTask[selTask] ?? 0 : 0;
175
+ const loadOlderMessages = useCallback((beforeApply?: () => void) => (selTask ? loadOlder(selTask, beforeApply) : Promise.resolve(false)), [selTask, loadOlder]);
176
+ // No entry yet for the selected task = its SSE snapshot hasn't arrived — the
177
+ // session view shows a transcript skeleton instead of an empty chat flash.
178
+ const transcriptLoading = !!selTask && !(selTask in msgsByTask);
179
+ // The tasks in state still belong to the previously selected project.
180
+ const tasksLoading = !!project && tasksFor !== project.id;
181
+
182
+ // ---------- prefs (appearance/settings/layout/view) + persistence ----------
183
+ const { view, setView, taskView, setTaskView, appearance, setAppearance, settings, setSetting, setSettings, layout, setLayout } =
184
+ usePrefs({ selProj, selTask, urlSelRef, setSelProj, setSelTask });
185
+
186
+ // ---------- project recaps + landing decision ----------
187
+ const recapMode = appDefaults.recap_mode === "on_open" || appDefaults.recap_mode === "off"
188
+ ? appDefaults.recap_mode
189
+ : "automatic";
190
+ const { recaps, fetchRecap } = useRecaps({
191
+ selProj, selTask, tasks, setSelTask, selProjRef,
192
+ settingsReady: appDefaultsReady,
193
+ backgroundJobs: appDefaults.background_jobs !== "off",
194
+ recapMode,
195
+ });
196
+
197
+ // Load server-backed app defaults (reasoning / permission mode) once.
198
+ useEffect(() => {
199
+ jget<Record<string, string>>("/api/settings")
200
+ .then(setAppDefaults)
201
+ .finally(() => setAppDefaultsReady(true));
202
+ }, []);
203
+
204
+ // Onboarding: a fresh instance opens the wizard automatically; an already
205
+ // set-up one (onboarding_complete) never sees it unless re-run from Settings.
206
+ useEffect(() => {
207
+ jget<OnboardingT>("/api/onboarding").then((o) => { setOnboarding(o); if (!o.complete) setWizardOpen(true); }).catch(() => {});
208
+ }, []);
209
+
210
+ const finishWizard = useCallback(() => {
211
+ setWizardOpen(false);
212
+ jget<OnboardingT>("/api/onboarding").then(setOnboarding).catch(() => {});
213
+ // The wizard is the New-task dialog's "Connect" destination — pick up any
214
+ // agent it just connected so the shared bundle reflects it immediately.
215
+ void refreshAgents();
216
+ // Land the user on the built-in tutorial: select the seeded "Welcome" project
217
+ // and its ready "Try me" task (loadTasks(..., true) picks the first
218
+ // non-suggested task), so the aha moment is one click away.
219
+ const seed = projects.find((p) => p.seeded && !p.deprecated) ?? activeProjects[0];
220
+ if (seed) {
221
+ setSelProj(seed.id);
222
+ setView("workspace");
223
+ void loadTasks(seed.id, true);
224
+ }
225
+ }, [projects, activeProjects, loadTasks, setView, refreshAgents]);
226
+
227
+ // "Re-run setup" from Settings: re-arm onboarding server-side and reopen it.
228
+ const rerunOnboarding = useCallback(async () => {
229
+ const o = await jsend<OnboardingT>("/api/onboarding", "DELETE");
230
+ setOnboarding(o);
231
+ setView("workspace");
232
+ setWizardOpen(true);
233
+ }, [setView]);
234
+
235
+ // Who unlocked this instance (Cloudflare Access identity, shown in the
236
+ // titlebar). null when enforcement is off — e.g. local dev — hides the chip.
237
+ const [accessEmail, setAccessEmail] = useState<string | null>(null);
238
+ useEffect(() => {
239
+ jget<{ email: string | null }>("/api/me").then((r) => setAccessEmail(r.email)).catch(() => {});
240
+ }, []);
241
+
242
+ // initial load (also the Retry target when the first fetch fails)
243
+ const boot = useCallback(() => {
244
+ setBootError(null);
245
+ jget<ProjectRow[]>("/api/projects").then((ps) => {
246
+ setProjects(ps);
247
+ const persisted = loadPersist();
248
+ const url = urlSelRef.current ?? {};
249
+ const active = ps.filter((p) => !p.deprecated);
250
+ const wantProj = url.project ?? persisted.selProj;
251
+ const wantTask = url.task ?? persisted.selTask;
252
+ // Never land on a deprecated project — it must be restored before building.
253
+ const persistedActive = active.find((p) => p.id === wantProj)?.id;
254
+ const landProj = persistedActive ?? active[0]?.id ?? null;
255
+ setSelProj(landProj);
256
+ // Restore the task only if it belongs to the project we actually land on.
257
+ // (If wantProj was missing/deprecated we fell back to active[0] — don't
258
+ // carry over a task from a different project.) Validity within the project
259
+ // is checked once its tasks load, below.
260
+ if (wantTask && landProj === wantProj) setSelTask(wantTask);
261
+ setBooted(true);
262
+ }).catch((e) => {
263
+ setBootError(e instanceof Error ? e.message : String(e));
264
+ });
265
+ }, []);
266
+ useEffect(() => { boot(); }, [boot]);
267
+
268
+ // Entering a project loads its tasks but does NOT auto-pick one — the landing
269
+ // decision (recap vs. first task) is made in useRecaps once recap status is known.
270
+ useEffect(() => { if (selProj) loadTasks(selProj, false); }, [selProj, loadTasks]);
271
+
272
+ // A hidden tab gets throttled and its SSE streams may quietly die, so the
273
+ // project badges / "needs you" counts drift while you're away. On the
274
+ // hidden→visible transition, refetch the project list once — skipping brief
275
+ // tab flips (the stream never missed a beat) so rapid alt-tabbing doesn't
276
+ // spam the endpoint.
277
+ useEffect(() => {
278
+ let hiddenAt = 0;
279
+ const onVis = () => {
280
+ if (document.visibilityState === "hidden") { hiddenAt = Date.now(); return; }
281
+ if (!hiddenAt || Date.now() - hiddenAt < 5_000) return;
282
+ hiddenAt = 0;
283
+ jget<ProjectRow[]>("/api/projects").then(setProjects).catch(() => {});
284
+ };
285
+ document.addEventListener("visibilitychange", onVis);
286
+ return () => document.removeEventListener("visibilitychange", onVis);
287
+ }, []);
288
+
289
+ // Browser notification when a task newly needs you — the payoff for the
290
+ // permission asked for during onboarding. Fires on the awaiting transition
291
+ // (a turn ending mid-task OR Claude parking on a question), for any task in
292
+ // the current project. We seed the "already notified" set without firing so a
293
+ // page load / project switch doesn't alert for tasks that were already
294
+ // waiting, and skip the task you're actively looking at.
295
+ const notifiedRef = useRef<Set<string> | null>(null);
296
+ useEffect(() => { notifiedRef.current = null; }, [selProj]); // re-seed per project; declared before the firing effect so it runs first
297
+ useEffect(() => {
298
+ const ids = new Set(liveAwaiting.map((t) => t.id));
299
+ if (notifiedRef.current === null) { notifiedRef.current = ids; return; }
300
+ const seen = notifiedRef.current;
301
+ if (typeof window !== "undefined" && "Notification" in window && Notification.permission === "granted") {
302
+ for (const t of liveAwaiting) {
303
+ if (seen.has(t.id)) continue;
304
+ // You're already staring at it — no need to interrupt.
305
+ if (t.id === selTask && document.visibilityState === "visible") continue;
306
+ const n = new Notification(`${agentLabel(agents, t.agent)} 需要你的输入`, { body: t.title, tag: `await-${t.id}` });
307
+ n.onclick = () => { window.focus(); setSelTask(t.id); n.close(); };
308
+ }
309
+ }
310
+ notifiedRef.current = ids;
311
+ }, [liveAwaiting, selTask, agents]);
312
+
313
+ // Persist a server-backed app default and adopt the server's echoed-back state.
314
+ const setAppDefault = async (key: string, value: string | null) => {
315
+ const fresh = await jsend<Record<string, string>>("/api/settings", "PATCH", { [key]: value });
316
+ setAppDefaults(fresh);
317
+ if (key.startsWith("default_") || key === "utility_agent") await refreshAgents();
318
+ };
319
+
320
+ // ---------- sending a turn ----------
321
+ // POST hands the turn to a detached server-side runner and returns at once;
322
+ // everything that happens next (including the user-message echo) arrives
323
+ // over the task's event stream. A reload mid-turn no longer kills it.
324
+ const runTurn = useCallback(async (taskId: string, text: string, isInitial: boolean) => {
325
+ setTaskRunning(taskId, true);
326
+ setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, started: 1, status: "in_progress", suggested: 0, awaiting_input: 0 } : x)));
327
+ // The POST returns as soon as the turn is launched (seconds, git catch-up
328
+ // included). One that hangs means the request never got through — say so
329
+ // instead of spinning forever.
330
+ const ctl = new AbortController();
331
+ const timer = setTimeout(() => ctl.abort(), SEND_TIMEOUT_MS);
332
+ try {
333
+ const res = await fetch(`/api/tasks/${taskId}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), signal: ctl.signal });
334
+ if (!res.ok) {
335
+ const raw = await res.text(); let msg = raw;
336
+ try { msg = JSON.parse(raw).error ?? raw; } catch {}
337
+ throw new Error(msg);
338
+ }
339
+ return true;
340
+ } catch (err) {
341
+ setTaskRunning(taskId, false);
342
+ const content = ctl.signal.aborted
343
+ ? `⚠ 消息发送超时:${SEND_TIMEOUT_MS / 1000} 秒内没有收到节点的响应,可能没有送达。请检查网络,刷新页面后确认消息是否已出现,再决定是否重发。`
344
+ : err instanceof Error ? err.message : String(err);
345
+ appendMsg(taskId, { id: `e-${Date.now()}`, role: "system", content, generation: tasks.find((t) => t.id === taskId)?.generation ?? 1 });
346
+ // Re-read the server's truth: the turn may have started after all.
347
+ try {
348
+ const fresh = await jget<TaskRow>(`/api/tasks/${taskId}`);
349
+ setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, ...fresh } : x)));
350
+ if (fresh.running) setTaskRunning(taskId, true);
351
+ } catch {}
352
+ return false;
353
+ } finally {
354
+ clearTimeout(timer);
355
+ }
356
+ }, [tasks, appendMsg]);
357
+
358
+ const forkFromTasks = async (text: string, sourceIds: string[], requestId: string): Promise<boolean> => {
359
+ if (!task || !project) return false;
360
+ const originTaskId = task.id, projectId = project.id;
361
+ const result = await jsend<{ tasks: TaskRow[] }>(`/api/tasks/${originTaskId}/fork`, "POST", { text, sourceIds, requestId });
362
+ if (selProjRef.current === projectId) {
363
+ // A simultaneous SSE refresh can supersede our list request. Install the
364
+ // returned rows before selecting the child, so selection validation never
365
+ // mistakes the newly created task for a deleted one.
366
+ taskLoads.current.set(projectId, (taskLoads.current.get(projectId) ?? 0) + 1);
367
+ setTasks((prev) => [...prev.filter((t) => !result.tasks.some((child) => child.id === t.id)), ...result.tasks]);
368
+ setTasksFor(projectId);
369
+ setSelTask((current) => current === originTaskId ? result.tasks[0]?.id ?? current : current);
370
+ void loadTasks(projectId, false).catch(() => {});
371
+ }
372
+ void jget<ProjectRow[]>("/api/projects").then(setProjects).catch(() => {});
373
+ return true;
374
+ };
375
+
376
+ // Submit the user's answer to an AskUserQuestion. In the common case the live
377
+ // turn is parked waiting and continues in its existing stream (resolved:true).
378
+ // If nothing was waiting (e.g. the turn was torn down by a page reload), resume
379
+ // the session with the answer as a normal reply.
380
+ const answerQuestion = useCallback(async (taskId: string, askId: string, questions: AskQuestion[], answers: AskAnswers) => {
381
+ setAnswerOnMsg(taskId, askId, answers); // optimistic — the stream echoes ask_answered
382
+ try {
383
+ // No tool_use id to resolve against — e.g. a question persisted before
384
+ // ask-id tracking was added, or one whose turn was already torn down. The
385
+ // /answer route requires a non-empty askId, so skip it and just send the
386
+ // choices as a normal reply, which resumes the current session.
387
+ if (!askId) { await runTurn(taskId, formatAnswersText(questions, answers), false); return; }
388
+ const { resolved } = await jsend<{ resolved: boolean }>(`/api/tasks/${taskId}/answer`, "POST", { askId, answers });
389
+ if (!resolved) await runTurn(taskId, formatAnswersText(questions, answers), false);
390
+ } catch (err) {
391
+ appendMsg(taskId, { id: `e-${Date.now()}`, role: "system", content: err instanceof Error ? err.message : String(err), generation: tasks.find((t) => t.id === taskId)?.generation ?? 1 });
392
+ }
393
+ }, [runTurn, tasks, appendMsg, setAnswerOnMsg]);
394
+
395
+ // Interrupt a running turn — the ONLY way one stops early now that turns are
396
+ // detached from connections. The server aborts the SDK query, persists the
397
+ // partial transcript, and publishes turn_end, which the event stream handler
398
+ // turns into a task refresh (now awaiting_input, resumable).
399
+ const stopTurn = useCallback(async (taskId: string):Promise<boolean> => {
400
+ try {const response=await fetch(`/api/tasks/${taskId}/abort`,{method:"POST"});return response.ok;} catch{return false;}
401
+ }, []);
402
+
403
+ // Drop a queued (not-yet-run) follow-up. The server publishes `dequeued`,
404
+ // which the stream handler turns into removing the bubble — so this is
405
+ // fire-and-forget; no optimistic local mutation needed.
406
+ const cancelQueued = useCallback(async (taskId: string, pendingId: string) => {
407
+ try { await fetch(`/api/tasks/${taskId}/pending`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pendingId }) }); } catch {}
408
+ }, []);
409
+
410
+ // Materialize a conflicted merge in the task's worktree, then stream an AI
411
+ // resolution turn into the transcript. Returns enough for TaskChanges to show
412
+ // the right follow-up (review state, clean-merge done, or an error).
413
+ const resolveConflictsWithAI = useCallback(async (taskId: string): Promise<ResolveResult> => {
414
+ try {
415
+ const res = await fetch(`/api/tasks/${taskId}/merge/prepare`, { method: "POST" });
416
+ const prep = await res.json();
417
+ if (!res.ok || !prep?.ok) return { ok: false, error: prep?.error || "could not prepare the merge" };
418
+ // Clean trial merge — it landed immediately, no AI needed.
419
+ if (prep.clean) {
420
+ if (selProj) loadTasks(selProj, false);
421
+ try { const fresh = await jget<TaskRow>(`/api/tasks/${taskId}`); setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, ...fresh } : x))); } catch {}
422
+ return prep.merged?.ok ? { ok: true, merged: true } : { ok: false, error: prep.merged?.error || "merge failed" };
423
+ }
424
+ // Conflicts present — stream the resolution turn (shows in the transcript).
425
+ // Fire-and-forget: the caller switches to the chat view as soon as this
426
+ // returns, so the user watches the resolution stream in live rather than
427
+ // sitting on the "Changes" tab until the whole turn completes.
428
+ void runTurn(taskId, prep.prompt, false);
429
+ return { ok: true, conflicts: prep.conflicts, binaryConflicts: prep.binaryConflicts };
430
+ } catch (e) {
431
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
432
+ }
433
+ }, [runTurn, selProj, loadTasks]);
434
+
435
+ // A merge just landed. Refresh the task list so its merged state shows, and —
436
+ // if this was the tutorial (a task in the seeded Welcome project) — fire the
437
+ // "now build your own" nudge, the payoff for finishing the loop.
438
+ const onMerged = useCallback(() => {
439
+ if (selProj) void loadTasks(selProj, false);
440
+ if (project?.seeded) setNudge(true);
441
+ }, [selProj, loadTasks, project]);
442
+
443
+ // A PR was just opened (or updated) for a task. Refresh the task list so the
444
+ // header's PR chip picks up the stored URL without waiting for the next poll.
445
+ const onPrCreated = useCallback(() => {
446
+ if (selProj) void loadTasks(selProj, false);
447
+ }, [selProj, loadTasks]);
448
+
449
+ // ---------- actions ----------
450
+ const selectProject = (id: string) => { setSelProj(id); setSelTask(null); setView("workspace"); };
451
+
452
+ // Jump to the next task waiting on the user: prefer one in the current project,
453
+ // else switch to the first other project that has one (its group sits at the top).
454
+ const jumpToNeedsYou = () => {
455
+ if (liveAwaiting.length > 0) { setSelTask(liveAwaiting[0].id); return; }
456
+ const p = activeProjects.find((p) => p.id !== selProj && p.awaiting_count > 0);
457
+ if (p) selectProject(p.id);
458
+ };
459
+
460
+ // Jump straight to a specific task in any project — the "need you" dropdown's
461
+ // row action. Setting selProj fires the load-tasks effect with selectFirst=false
462
+ // (useEffect on selProj), so it won't clobber the selTask we set here.
463
+ const goToTask = (projectId: string, taskId: string) => {
464
+ setView("workspace");
465
+ setSelProj(projectId);
466
+ setSelTask(taskId);
467
+ };
468
+
469
+ const clearSession = useCallback(async (taskId: string) => {
470
+ const t = tasks.find((x) => x.id === taskId);
471
+ if (!t || running.has(taskId) || !t.started) return;
472
+ setTaskRunning(taskId, true);
473
+ try {
474
+ const { summary } = await jsend<{ summary: string }>(`/api/tasks/${taskId}/clear`, "POST");
475
+ appendMsg(taskId, { id: `sb-${Date.now()}`, role: "session_break", content: summary, generation: t.generation });
476
+ const fresh = await jget<TaskRow>(`/api/tasks/${taskId}`);
477
+ setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, ...fresh } : x)));
478
+ } finally {
479
+ setTaskRunning(taskId, false);
480
+ }
481
+ // Spin up the fresh window; its injected context carries task metadata + summary.
482
+ runTurn(taskId, "", true);
483
+ }, [tasks, running, runTurn, appendMsg]);
484
+
485
+ // Cheap local patch: a mutation just took a task out of the "needs you" set,
486
+ // so drop it from its project's badge now rather than waiting for the SSE
487
+ // roundtrip (the published lifecycle event re-syncs the exact count moments
488
+ // later; the selected project's pill reads liveAwaiting, so this only matters
489
+ // once you navigate away).
490
+ const decAwaiting = (t: TaskRow) =>
491
+ setProjects((prev) => prev.map((p) => (p.id === t.project_id ? { ...p, awaiting_count: Math.max(0, p.awaiting_count - 1) } : p)));
492
+
493
+ const setStatus = async (s: Status) => {
494
+ if (!task) return;
495
+ const wasAwaiting = isAwaiting(task);
496
+ const fresh = await jsend<TaskRow>(`/api/tasks/${task.id}`, "PATCH", { status: s });
497
+ setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, ...fresh } : x)));
498
+ // The server clears awaiting_input on a manual status change.
499
+ if (wasAwaiting) decAwaiting(task);
500
+ };
501
+ const setPriority = async (p: Priority) => {
502
+ if (!task) return;
503
+ const fresh = await jsend<TaskRow>(`/api/tasks/${task.id}`, "PATCH", { priority: p });
504
+ setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, ...fresh } : x)));
505
+ };
506
+ const setModel = async (m: string | null) => {
507
+ if (!task) return;
508
+ const fresh = await jsend<TaskRow>(`/api/tasks/${task.id}`, "PATCH", { model: m });
509
+ setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, ...fresh } : x)));
510
+ };
511
+ // Mid-task agent switch: takes effect from the next message (the server seeds
512
+ // the new agent with the conversation so far and logs the switch in the transcript).
513
+ const setAgent = async (a: string) => {
514
+ if (!task || a === task.agent) return;
515
+ const fresh = await jsend<TaskRow>(`/api/tasks/${task.id}`, "PATCH", { agent: a });
516
+ setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, ...fresh } : x)));
517
+ };
518
+ const setReasoning = async (r: string | null) => {
519
+ if (!task) return;
520
+ const fresh = await jsend<TaskRow>(`/api/tasks/${task.id}`, "PATCH", { reasoning: r });
521
+ setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, ...fresh } : x)));
522
+ };
523
+ const setPermission = async (p: string | null) => {
524
+ if (!task) return;
525
+ const fresh = await jsend<TaskRow>(`/api/tasks/${task.id}`, "PATCH", { permission_mode: p });
526
+ setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, ...fresh } : x)));
527
+ };
528
+ // The task-start "Send saved project context" checkbox (TaskHero) persists
529
+ // immediately so the flag is already on the row when the first turn launches.
530
+ const setSendContext = async (v: boolean) => {
531
+ if (!task) return;
532
+ const fresh = await jsend<TaskRow>(`/api/tasks/${task.id}`, "PATCH", { send_context: v ? 1 : 0 });
533
+ setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, ...fresh } : x)));
534
+ };
535
+
536
+ const createTask = async (input: { title: string; desc: string; priority: Priority; agent: string; startNow: boolean; sendContext: boolean; depends_on: string[]; auto_start: boolean; browser: boolean }) => {
537
+ if (!project) return;
538
+ const t = await jsend<TaskRow>("/api/tasks", "POST", {
539
+ project_id: project.id, title: input.title, description: input.desc, priority: input.priority, agent: input.agent, send_context: input.sendContext,
540
+ // Same field CLITaskCenter sends; omitted when unticked so the request is unchanged.
541
+ ...(input.browser ? { capabilities: ["browser"] } : {}),
542
+ });
543
+ // Dependencies (and the auto-start opt-in that rides on them) are an
544
+ // edit-after-create step (the task id doesn't exist until now).
545
+ if (input.depends_on.length) await jsend(`/api/tasks/${t.id}`, "PATCH", { depends_on: input.depends_on, auto_start: input.auto_start ? 1 : 0 });
546
+ await loadTasks(project.id, false);
547
+ setSelTask(t.id);
548
+ setModal(null);
549
+ if (input.startNow) runTurn(t.id, "", true);
550
+ };
551
+
552
+ // A board drop: optionally re-status the dragged task (it landed in another
553
+ // column) and persist the new manual order. `orderedIds` is the project's
554
+ // full task list flattened in column order. Optimistic on both counts — the
555
+ // card lands where it was dropped instantly; a failure reloads server truth.
556
+ const moveTask = useCallback(async (id: string, patch: Partial<Pick<TaskRow, "status" | "suggested">>, orderedIds: string[]) => {
557
+ const hasPatch = Object.keys(patch).length > 0;
558
+ setTasks((prev) => {
559
+ const byId = new Map(prev.map((t) => [t.id, t]));
560
+ const listed = new Set(orderedIds);
561
+ const next = [
562
+ ...orderedIds.map((tid) => byId.get(tid)).filter((t): t is TaskRow => !!t),
563
+ ...prev.filter((t) => !listed.has(t.id)),
564
+ ];
565
+ // Mirror the server: a manual status change clears the "your turn" flag.
566
+ return hasPatch ? next.map((t) => (t.id === id ? { ...t, ...patch, awaiting_input: patch.status ? 0 : t.awaiting_input } : t)) : next;
567
+ });
568
+ try {
569
+ if (hasPatch) {
570
+ const fresh = await jsend<TaskRow>(`/api/tasks/${id}`, "PATCH", patch);
571
+ setTasks((prev) => prev.map((x) => (x.id === id ? { ...x, ...fresh } : x)));
572
+ }
573
+ await jsend("/api/tasks/reorder", "POST", { ids: orderedIds });
574
+ } catch {
575
+ if (selProjRef.current) void loadTasks(selProjRef.current, false);
576
+ }
577
+ }, [loadTasks]);
578
+
579
+ const saveTask = async (id: string, patch: { title: string; description: string; priority: Priority; agent?: string; depends_on: string[]; auto_start: boolean }) => {
580
+ const fresh = await jsend<TaskRow>(`/api/tasks/${id}`, "PATCH", { ...patch, auto_start: patch.auto_start ? 1 : 0 });
581
+ setTasks((prev) => prev.map((x) => (x.id === id ? { ...x, ...fresh } : x)));
582
+ setEditId(null);
583
+ };
584
+
585
+ // Hard-deletes the task (and its worktree/branch server-side), closes the edit
586
+ // modal, and drops it from the selection if it was the one being viewed.
587
+ const removeTask = async (id: string) => {
588
+ const t = tasks.find((x) => x.id === id);
589
+ await jsend(`/api/tasks/${id}`, "DELETE");
590
+ setTasks((prev) => prev.filter((x) => x.id !== id));
591
+ // A deleted task can no longer need you — the task_deleted event confirms
592
+ // with the server-recomputed count moments later.
593
+ if (t && isAwaiting(t)) decAwaiting(t);
594
+ setEditId(null);
595
+ setSelTask((cur) => (cur === id ? null : cur));
596
+ };
597
+
598
+ const startSuggestion = async (id: string) => {
599
+ await jsend<TaskRow>(`/api/tasks/${id}`, "PATCH", { suggested: 0 });
600
+ if (selProj) await loadTasks(selProj, false);
601
+ setSelTask(id);
602
+ runTurn(id, "", true);
603
+ };
604
+ const acceptSuggestion = async (id: string) => {
605
+ await jsend<TaskRow>(`/api/tasks/${id}`, "PATCH", { suggested: 0 });
606
+ if (selProj) await loadTasks(selProj, false);
607
+ };
608
+ const dismissSuggestion = async (id: string) => {
609
+ await jsend(`/api/tasks/${id}`, "DELETE");
610
+ if (selProj) await loadTasks(selProj, false);
611
+ };
612
+
613
+ const saveContext = async (patch: { name: string; context: string; send_context: number; repo_path: string; branch: string; dev_command: string; setup_command: string; test_command: string }) => {
614
+ if (!project) return;
615
+ await jsend(`/api/projects/${project.id}`, "PATCH", patch);
616
+ const ps = await jget<ProjectRow[]>("/api/projects");
617
+ setProjects(ps);
618
+ setModal(null);
619
+ };
620
+ const createProject = async (input: { name: string; sub: string; color: string; context: string; repo_path: string; branch?: string }) => {
621
+ const p = await jsend<ProjectRow>("/api/projects", "POST", input);
622
+ const ps = await jget<ProjectRow[]>("/api/projects");
623
+ setProjects(ps);
624
+ setSelProj(p.id);
625
+ setSelTask(null);
626
+ setModal(null);
627
+ };
628
+ const reorderProjects = useCallback((ids: string[]) => {
629
+ // Optimistically reorder, then persist. On failure, reload from server.
630
+ // `ids` covers only the active list; deprecated projects keep their order.
631
+ setProjects((prev) => {
632
+ const byId = new Map(prev.map((p) => [p.id, p]));
633
+ const reordered = ids.map((id) => byId.get(id)).filter((p): p is ProjectRow => !!p);
634
+ const untouched = prev.filter((p) => !ids.includes(p.id));
635
+ return [...reordered, ...untouched];
636
+ });
637
+ jsend("/api/projects/reorder", "POST", { ids }).catch(() => {
638
+ jget<ProjectRow[]>("/api/projects").then(setProjects);
639
+ });
640
+ }, []);
641
+ const removeProject = async (id: string) => {
642
+ await jsend(`/api/projects/${id}`, "DELETE");
643
+ const ps = await jget<ProjectRow[]>("/api/projects");
644
+ setProjects(ps);
645
+ setModal(null);
646
+ if (selProj === id) { setSelProj(ps.find((p) => !p.deprecated)?.id ?? null); setSelTask(null); }
647
+ };
648
+ const setDeprecated = async (id: string, deprecated: boolean) => {
649
+ await jsend(`/api/projects/${id}`, "PATCH", { deprecated: deprecated ? 1 : 0 });
650
+ const ps = await jget<ProjectRow[]>("/api/projects");
651
+ setProjects(ps);
652
+ setModal(null);
653
+ // Leaving a project that just got hidden: fall back to the first active one.
654
+ if (deprecated && selProj === id) { setSelProj(ps.find((p) => !p.deprecated)?.id ?? null); setSelTask(null); }
655
+ // Restoring: jump straight into the project so you can build on it again.
656
+ if (!deprecated) { setSelProj(id); setSelTask(null); }
657
+ };
658
+
659
+ // Restore run defaults to built-ins: clear every server-backed default_* key
660
+ // (agent-scoped and legacy) plus the reset of client-only settings.
661
+ const resetSettings = async () => {
662
+ const patch:Record<string,null>={};
663
+ for(const key of Object.keys(appDefaults)) if(key.startsWith("default_") || ["utility_agent","background_jobs","recap_mode","agent_failover_enabled"].includes(key)) patch[key]=null;
664
+ const fresh=await jsend<Record<string,string>>("/api/settings","PATCH",patch);
665
+ setAppDefaults(fresh);setSettings(DEFAULT_SETTINGS);await refreshAgents();
666
+ };
667
+
668
+ // Set a project's default agent for new tasks (persisted; existing tasks keep
669
+ // the agent they were created with).
670
+ const setProjectDefaultAgent = async (agent: string) => {
671
+ if (!project) return;
672
+ await jsend(`/api/projects/${project.id}`, "PATCH", { default_agent: agent });
673
+ setProjects((prev) => prev.map((p) => (p.id === project.id ? { ...p, default_agent: agent } : p)));
674
+ };
675
+
676
+ return {
677
+ // state + derived
678
+ booted, bootError, retryBoot: boot, tasksLoading, transcriptLoading, olderCount, loadOlderMessages,
679
+ projects, activeProjects, deprecatedProjects, selProj, setSelProj, project,
680
+ tasks, realTasks, suggested, selTask, task, messages, running,
681
+ blockedBy, liveAwaiting, needsYouTotal,
682
+ modal, setModal, editId, setEditId, view, setView, taskView, setTaskView,
683
+ appearance, setAppearance, appearanceOpen, setAppearanceOpen,
684
+ settings, setSetting, appDefaults, setAppDefault, agents, refreshAgents, brokenAgents,
685
+ onboarding, wizardOpen, finishWizard, rerunOnboarding, nudge, setNudge, onMerged, onPrCreated,
686
+ layout, setLayout, accessEmail, recaps,
687
+ termOpen, setTermOpen, termMounted, setTermMounted, termHeight, setTermHeight,
688
+ servicesOpen, setServicesOpen, servicesMounted, setServicesMounted, servicesHeight, setServicesHeight,
689
+ // actions
690
+ setSelTask, fetchRecap, forkFromTasks, runTurn, answerQuestion, stopTurn, cancelQueued, resolveConflictsWithAI,
691
+ selectProject, jumpToNeedsYou, goToTask, clearSession, setStatus, setPriority, setModel, setAgent,
692
+ setReasoning, setPermission, setSendContext, createTask, saveTask, removeTask, moveTask, startSuggestion, acceptSuggestion,
693
+ dismissSuggestion, saveContext, createProject, reorderProjects, removeProject, setDeprecated,
694
+ resetSettings, setProjectDefaultAgent,
695
+ };
696
+ }