makersclaw 0.3.2 → 0.4.0

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 (81) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/r/annotate.json +3 -3
  4. package/r/approval-request.json +2 -2
  5. package/r/audio-player.json +2 -2
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +2 -2
  8. package/r/calendar-view.json +3 -3
  9. package/r/cart.json +2 -2
  10. package/r/chart.json +2 -2
  11. package/r/chat.json +2 -2
  12. package/r/code-editor.json +2 -2
  13. package/r/comment-box.json +3 -3
  14. package/r/contact-section.json +2 -2
  15. package/r/core.json +12 -6
  16. package/r/csv-import.json +3 -3
  17. package/r/data-table.json +2 -2
  18. package/r/diagram.json +2 -2
  19. package/r/doc-editor.json +2 -2
  20. package/r/doc-page.json +3 -3
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +2 -2
  24. package/r/flow-canvas.json +2 -2
  25. package/r/footer.json +2 -2
  26. package/r/gantt.json +3 -3
  27. package/r/job-panel.json +8 -5
  28. package/r/json-editor.json +3 -3
  29. package/r/map.json +2 -2
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +3 -3
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +3 -3
  35. package/r/page-builder.json +2 -2
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +2 -2
  39. package/r/product-detail.json +2 -2
  40. package/r/product-grid.json +3 -3
  41. package/r/record-form.json +2 -2
  42. package/r/record-notes.json +4 -4
  43. package/r/registry.json +131 -123
  44. package/r/search-dialog.json +2 -2
  45. package/r/settings-list.json +2 -2
  46. package/r/setup-checklist.json +2 -2
  47. package/r/share-dialog.json +2 -2
  48. package/r/sidebar.json +2 -2
  49. package/r/signature-pad.json +2 -2
  50. package/r/sketch-canvas.json +2 -2
  51. package/r/spreadsheet.json +2 -2
  52. package/r/stat-cards.json +3 -3
  53. package/r/task-list.json +3 -3
  54. package/r/task-model.json +2 -2
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +2 -2
  57. package/r/whiteboard.json +3 -3
  58. package/registry/blocks/job-panel/README.md +40 -3
  59. package/registry/blocks/job-panel/block.json +2 -2
  60. package/registry/blocks/job-panel/demo.jsx +75 -6
  61. package/registry/blocks/job-panel/job-panel.jsx +60 -11
  62. package/src/shadcn.mjs +1 -1
  63. package/templates/next/files/README.md +339 -73
  64. package/templates/next/files/app/auto-refresh.jsx +72 -10
  65. package/templates/next/files/app/claw-alive/route.js +17 -0
  66. package/templates/next/files/app/error-relay.jsx +9 -4
  67. package/templates/next/files/app/escape-relay.jsx +27 -0
  68. package/templates/next/files/app/frame.js +218 -0
  69. package/templates/next/files/app/layout.jsx +18 -3
  70. package/templates/next/files/check/run.mjs +25 -8
  71. package/templates/next/files/components/parts/fixture.jsx +29 -4
  72. package/templates/next/files/components/parts/index.js +2 -1
  73. package/templates/next/files/components/parts/live-work.jsx +235 -0
  74. package/templates/next/files/components/ui/button.jsx +6 -1
  75. package/templates/next/files/components/ui/date-picker.jsx +15 -2
  76. package/templates/next/files/components/ui/icon.jsx +37 -2
  77. package/templates/next/files/lib/claw.js +127 -28
  78. package/templates/next/files/lib/hooks.js +454 -33
  79. package/templates/next/files/lib/platform.js +113 -15
  80. package/templates/next/files/surfaces/README.md +3 -0
  81. package/templates/next/template.json +17 -6
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
4
- import { claw, publishStatus } from "./claw";
5
- import { cancelJob, startJob } from "./platform";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { claw, publishDesk, publishStatus } from "./claw";
5
+ import { ask, cancelJob, openRequest, requests as readRequests, startJob, stopRequest } from "./platform";
6
6
 
7
7
  // The React side of the _claw API, without any look: fetching, polling,
8
8
  // streaming and cleanup. A surface renders what comes back and owns every
@@ -11,9 +11,13 @@ import { cancelJob, startJob } from "./platform";
11
11
  // There is no chat, runs or activity hook here, and there will not be:
12
12
  // those are STANDING surfaces and the OS draws them as tabs on the app
13
13
  // window (docs/ARTIFACT-CONTRACTS.md §9). `useJob` is the other thing —
14
- // work a founder commissions from inside the app, which is not a surface
15
- // at all: it opens because they pressed something and it closes when the
16
- // work lands (SOP §2.3, ARTIFACT-CONTRACTS §9f).
14
+ // work a founder asks for from inside the app, which is not a surface at
15
+ // all: it opens because they pressed something and it closes when the
16
+ // work lands (SOP §2.3, ARTIFACT-CONTRACTS §9f). Since 0.4.0 that work is
17
+ // a request: the conversation lives in the desk's agent column, and the
18
+ // app follows only its status. `useRequests` is the same requests seen
19
+ // from a surface: the asks in flight on this app, so an empty table can
20
+ // show the work coming instead of a blank.
17
21
  //
18
22
  // While the gateway has no _claw routes yet (`unavailable`), polls back
19
23
  // off to once a minute so a preview that is still being wired does not
@@ -21,6 +25,64 @@ import { cancelJob, startJob } from "./platform";
21
25
 
22
26
  const BACKOFF_MS = 60000;
23
27
 
28
+ /** How often useRequests looks again while nothing is working. */
29
+ const IDLE_MS = 20000;
30
+
31
+ /** How often useJob looks at its request while the work goes on. */
32
+ const FOLLOW_MS = 2500;
33
+
34
+ // A request's status is one of eleven (claw's shared/src/requests.ts).
35
+ // Seven are live: the work is still going. `answered` is open but not
36
+ // live — the agent answered and waits. Three are closed.
37
+ const LIVE = new Set(["routing", "queued", "installing", "setting_up", "building", "working", "needs_you"]);
38
+ const CLOSED = new Set(["done", "stopped", "didnt_finish"]);
39
+
40
+ /**
41
+ * Is the page shown: the browser tab visible AND the App tab on screen?
42
+ * In the desk the App tab stays mounted, hidden, behind the window's
43
+ * other tabs, and visibilityState still reads "visible" there; only an
44
+ * IntersectionObserver on the root sees it (app/frame.js says why, and
45
+ * app/auto-refresh.jsx watches it the same way). `onShown` runs each
46
+ * time the page comes back. → { shown(), stop() }
47
+ */
48
+ function watchShown(onShown) {
49
+ let onScreen = true;
50
+ const shown = () => document.visibilityState === "visible" && onScreen;
51
+ const onVisible = () => {
52
+ if (shown()) onShown();
53
+ };
54
+ let observer = null;
55
+ if (typeof IntersectionObserver === "function") {
56
+ observer = new IntersectionObserver((entries) => {
57
+ const was = onScreen;
58
+ onScreen = entries[entries.length - 1].isIntersecting;
59
+ if (onScreen && !was && shown()) onShown();
60
+ });
61
+ observer.observe(document.documentElement);
62
+ }
63
+ document.addEventListener("visibilitychange", onVisible);
64
+ return {
65
+ shown,
66
+ stop() {
67
+ observer?.disconnect();
68
+ document.removeEventListener("visibilitychange", onVisible);
69
+ },
70
+ };
71
+ }
72
+
73
+ // An ask made through useJob tells every useRequests on the page, so the
74
+ // empty state shows the new work at once instead of at its next look.
75
+ const asked = new Set();
76
+
77
+ function onAsked(listener) {
78
+ asked.add(listener);
79
+ return () => asked.delete(listener);
80
+ }
81
+
82
+ function announceAsk() {
83
+ for (const listener of [...asked]) listener();
84
+ }
85
+
24
86
  /**
25
87
  * GET one _claw route. Fetches on mount, again every `every` ms while the
26
88
  * tab is visible, and on reload(). A failed poll keeps the last data and
@@ -65,8 +127,9 @@ export function useClaw(path, { every, enabled = true } = {}) {
65
127
  }
66
128
 
67
129
  /**
68
- * Who this app is (_claw/me). Also publishes the status onto <html>
69
- * so reportError can tell a preview fault from a runtime one.
130
+ * Who this app is (_claw/me). Also publishes the status and the desk's
131
+ * origin onto <html>: reportError reads the status to tell a preview
132
+ * fault from a runtime one, and openInDesk posts to the origin.
70
133
  * → { artifact, user, blueprint, loading, error, unavailable, reload }
71
134
  */
72
135
  export function useArtifact() {
@@ -75,9 +138,135 @@ export function useArtifact() {
75
138
  useEffect(() => {
76
139
  publishStatus(artifact?.status);
77
140
  }, [artifact?.status]);
141
+ useEffect(() => {
142
+ if (data) publishDesk(data.desk_origin);
143
+ }, [data]);
78
144
  return { artifact, user: data?.user ?? null, blueprint: data?.blueprint ?? null, ...rest };
79
145
  }
80
146
 
147
+ /** Does a request belong to what a surface asked for? Each filter given
148
+ * is a way in: a screen's ask made on this surface, or work on this job
149
+ * wherever it was asked for (a desk ask handed to the app has no
150
+ * surface, only its job). No filter: every request. */
151
+ function belongs(request, { surface, job }) {
152
+ if (!surface && !job) return true;
153
+ return Boolean((surface && request.surface === surface) || (job && request.job_key === job));
154
+ }
155
+
156
+ function ended(request) {
157
+ return CLOSED.has(request.status) || Boolean(request.ended_at);
158
+ }
159
+
160
+ /**
161
+ * This app's requests: the work asked of its agent, from a screen or
162
+ * from the desk, by anyone in the workspace. For an empty state that
163
+ * shows the live work instead of a blank (the LiveEmpty part):
164
+ *
165
+ * const work = useRequests({ surface: "deals" });
166
+ * if (!rows.length) return <LiveEmpty work={work} surface="deals" … />;
167
+ *
168
+ * requests every request the gateway returned: the live ones and
169
+ * those that ended in the last day
170
+ * live the ones not ended, for this surface or job (a filter
171
+ * given is a way in: the surface's own asks, or the job's
172
+ * work from anywhere)
173
+ * run the app's scheduled run while one is going, or null
174
+ * loading, error, unavailable, reload
175
+ * open(r) open a request's conversation in the desk's column
176
+ * stop(id) stop one, then look again
177
+ *
178
+ * Looks every `every` ms while work is going, every 20 s when nothing
179
+ * is, once a minute while the gateway has no such route — and only while
180
+ * the page is shown: not behind the window's other tabs, not in a hidden
181
+ * browser tab. Coming back looks at once.
182
+ */
183
+ export function useRequests({ surface, job, every = 5000 } = {}) {
184
+ const [state, setState] = useState({ requests: [], run: null, loading: true, error: null, unavailable: false });
185
+ const [tick, setTick] = useState(0);
186
+ const reload = useCallback(() => setTick((t) => t + 1), []);
187
+ const filterRef = useRef({ surface, job });
188
+ filterRef.current = { surface, job };
189
+ const often = Math.max(Number(every) || 5000, 1000);
190
+
191
+ useEffect(() => {
192
+ const controller = new AbortController();
193
+ let alive = true;
194
+ let timer = null;
195
+ let inflight = false;
196
+ let again = false;
197
+ let unavailable = false;
198
+ let working = false;
199
+
200
+ const schedule = () => {
201
+ clearTimeout(timer);
202
+ timer = null;
203
+ // Hidden: no timer at all. Coming back on screen looks again.
204
+ if (!alive || !screen.shown()) return;
205
+ // Asked to look while a look was out: that answer may predate the
206
+ // ask, so look once more now rather than at the next tick.
207
+ if (again) {
208
+ again = false;
209
+ load();
210
+ return;
211
+ }
212
+ timer = setTimeout(load, unavailable ? BACKOFF_MS : working ? often : IDLE_MS);
213
+ };
214
+ const load = async () => {
215
+ clearTimeout(timer);
216
+ timer = null;
217
+ if (!alive || !screen.shown()) return;
218
+ if (inflight) {
219
+ again = true;
220
+ return;
221
+ }
222
+ inflight = true;
223
+ const r = await readRequests({ signal: controller.signal });
224
+ inflight = false;
225
+ if (!alive || r.aborted) return;
226
+ unavailable = Boolean(r.unavailable);
227
+ if (r.ok) {
228
+ const list = Array.isArray(r.data?.requests) ? r.data.requests : [];
229
+ const run = r.data?.run ?? null;
230
+ if (r.data?.desk_origin) publishDesk(r.data.desk_origin);
231
+ working = Boolean(run) || list.some((q) => LIVE.has(q.status) && belongs(q, filterRef.current));
232
+ setState({ requests: list, run, loading: false, error: null, unavailable: false });
233
+ } else {
234
+ working = false;
235
+ setState((s) => ({ ...s, loading: false, error: r.error, unavailable }));
236
+ }
237
+ schedule();
238
+ };
239
+
240
+ const screen = watchShown(load);
241
+ const unsubscribe = onAsked(load);
242
+ load();
243
+ return () => {
244
+ alive = false;
245
+ controller.abort();
246
+ clearTimeout(timer);
247
+ screen.stop();
248
+ unsubscribe();
249
+ };
250
+ }, [often, tick]);
251
+
252
+ const live = useMemo(
253
+ () => state.requests.filter((q) => q && !ended(q) && belongs(q, { surface, job })),
254
+ [state.requests, surface, job],
255
+ );
256
+
257
+ const open = useCallback((request) => openRequest(request), []);
258
+ const stop = useCallback(
259
+ async (id) => {
260
+ const r = await stopRequest(id);
261
+ reload();
262
+ return r;
263
+ },
264
+ [reload],
265
+ );
266
+
267
+ return { ...state, live, reload, open, stop };
268
+ }
269
+
81
270
  /** A settled tool call closes the LAST running step with that name, not
82
271
  * the first: two calls to the same tool in one turn settle in order. */
83
272
  function settleStep(steps, name, status) {
@@ -99,33 +288,118 @@ const IDLE = {
99
288
  sessionId: null,
100
289
  progress: 0,
101
290
  finishedAt: null,
291
+ // From the request the job is (null on a claw before _claw/ask).
292
+ status: null,
293
+ requestId: null,
294
+ queued: false,
295
+ queuedAfter: null,
296
+ needsYou: false,
297
+ liveLine: null,
298
+ count: null,
299
+ target: null,
300
+ request: null,
102
301
  };
103
302
 
303
+ /** A request's status as a step's: running | done | failed, the words
304
+ * the job panel reads. */
305
+ function stepStatus(status) {
306
+ if (LIVE.has(status)) return "running";
307
+ return status === "done" || status === "answered" ? "done" : "failed";
308
+ }
309
+
104
310
  /**
105
- * Commission work and follow it: the job half of SOP §2.3. One job at a
106
- * time per hook — call it once per place the founder can start something.
311
+ * What useJob shows for a request (_claw/ask's and _claw/requests'
312
+ * AppRequestView). Pure, so the template's tests hold it to each status.
313
+ * The one step is the request itself, labelled with its line; the text
314
+ * is empty because the agent's account is in the desk's column now.
315
+ */
316
+ export function jobFromRequest(request) {
317
+ const r = request && typeof request === "object" ? request : {};
318
+ const status = typeof r.status === "string" ? r.status : null;
319
+ const line = r.line || null;
320
+ return {
321
+ running: LIVE.has(status),
322
+ status,
323
+ requestId: r.id ?? null,
324
+ sessionId: r.app_session_id ?? null,
325
+ queued: status === "queued",
326
+ queuedAfter: r.queued ?? null,
327
+ needsYou: status === "needs_you",
328
+ liveLine: line,
329
+ count: r.progress?.n ?? null,
330
+ target: r.progress?.of ?? null,
331
+ steps: status ? [{ name: "request", label: line || r.title || "", status: stepStatus(status) }] : [],
332
+ text: "",
333
+ error: status === "didnt_finish" ? line || "Didn't finish" : status === "stopped" ? "Stopped" : null,
334
+ finishedAt:
335
+ status === "done" || status === "answered" ? r.ended_at || r.updated_at || new Date().toISOString() : null,
336
+ request: request ?? null,
337
+ };
338
+ }
339
+
340
+ // A thread this screen may not continue gets a thread of its own, once:
341
+ // a teammate's (403), or one the claw holds no request for (404) — a
342
+ // session kept on a row from the 0.3.x door, before asks were requests.
343
+ const START_FRESH = new Set(["not_your_thread", "unknown_thread"]);
344
+
345
+ /** On the 0.3.x door there is no context field: what the ask carries
346
+ * rides in the message instead, as the platform's hand-off writes it. A
347
+ * 0.3.x call site passes neither, so its message goes as it always did. */
348
+ function legacyMessage(text, row, how) {
349
+ const parts = [text];
350
+ if (how) parts.push(String(how));
351
+ if (row && typeof row === "object") parts.push(`The row: ${JSON.stringify(row).slice(0, 8000)}`);
352
+ return parts.join("\n\n");
353
+ }
354
+
355
+ /**
356
+ * Ask the app's agent for work and follow it: the job half of SOP §2.3.
357
+ * One job at a time per hook — call it once per place the founder can
358
+ * start something.
107
359
  *
108
360
  * const job = useJob();
109
361
  * <button onClick={() => job.start("Draft a launch checklist for …")}>
110
362
  *
363
+ * start() asks (lib/platform.js, ask): the platform makes the ask a
364
+ * request, hands it to the app's agent, and the desk opens its
365
+ * conversation in the agent column. The hook then follows the request's
366
+ * status until it ends. start resolves once the ask is taken or refused;
367
+ * on a claw from before _claw/ask it streams the 0.3.x way and resolves
368
+ * when the job ends, as it always did.
369
+ *
111
370
  * State, for the panel the surface draws:
112
371
  * running a job is in flight
372
+ * status the request's status (working, queued, needs_you, done, …)
373
+ * queued waiting its turn; queuedAfter = { reason, after_id,
374
+ * after_title }
375
+ * needsYou the agent waits on the founder: open() takes them to the
376
+ * question in the column
377
+ * liveLine the one line the desk shows for it ("Drafting · 12 of 50")
378
+ * count, target the counted progress (12, 50), or null
113
379
  * steps [{ name, label, status }] — `label` is the founder's
114
- * phrase ("Searching the web"), `status` running | done |
115
- * failed. Show labels; the raw name is there for debugging.
116
- * text what the agent has written so far, growing. This is its
117
- * account of the work, NOT the deliverable — the deliverable
118
- * is rows on a surface.
119
- * progress bumps every time a step settles. Feed it to a data hook's
380
+ * phrase, `status` running | done | failed. Show labels;
381
+ * the raw name is there for debugging.
382
+ * text on the 0.3.x path, what the agent has written so far —
383
+ * its account, NOT the deliverable. Empty on the request
384
+ * path: the account is in the column.
385
+ * progress bumps every time the work moves. Feed it to a data hook's
120
386
  * reload so rows appear as they land:
121
387
  * useEffect(() => { reload(); }, [job.progress]);
122
388
  * error a message, or null
123
389
  * sessionId the thread. Pass it back to start() to refine what this
124
390
  * job produced; keep it on the object's row and the founder
125
391
  * can pick the conversation up where the thing lives.
392
+ * requestId the request, or null on the 0.3.x path
126
393
  * finishedAt ISO time the last job settled, or null
127
394
  *
128
- * Actions: start(message, { sessionId }) · cancel() · reset().
395
+ * Actions: start(message, { sessionId, row, how, job, list, open }) ·
396
+ * cancel() · reset() · open().
397
+ *
398
+ * row the row the ask is about, as context for the agent
399
+ * how instructions for the agent only (the AGENTS.md section)
400
+ * job a declared job's key; by default the platform picks the job
401
+ * whose view is this surface, else the app's only job
402
+ * open false keeps the desk's column where it is
129
403
  *
130
404
  * The panel this drives must not exist when nothing is running — that is
131
405
  * §2.3's test, and a standing one is the monitoring console §2.7 warns
@@ -135,6 +409,10 @@ export function useJob() {
135
409
  const [state, setState] = useState(IDLE);
136
410
  const abortRef = useRef(null);
137
411
  const sessionRef = useRef(null);
412
+ const requestRef = useRef(null); // the request followed
413
+ const pathRef = useRef(null); // "request" | "legacy": which door the job went through
414
+ const followRef = useRef(null); // stops following
415
+ const movedRef = useRef(null); // the last status|progress|updated_at seen
138
416
  const aliveRef = useRef(true);
139
417
 
140
418
  useEffect(() => {
@@ -142,16 +420,81 @@ export function useJob() {
142
420
  return () => {
143
421
  aliveRef.current = false;
144
422
  abortRef.current?.abort();
423
+ followRef.current?.();
424
+ followRef.current = null;
145
425
  };
146
426
  }, []);
147
427
 
148
- const start = useCallback(async (message, { sessionId } = {}) => {
149
- const text = String(message ?? "").trim();
150
- if (!text) return { ok: false, error: "nothing to ask" };
151
- abortRef.current?.abort();
152
- const controller = new AbortController();
153
- abortRef.current = controller;
428
+ const apply = useCallback((request) => {
429
+ if (!aliveRef.current) return;
430
+ requestRef.current = request;
431
+ const next = jobFromRequest(request);
432
+ if (next.sessionId) sessionRef.current = next.sessionId;
433
+ const mark = [request.status, request.progress?.n, request.progress?.of, request.updated_at].join("|");
434
+ const moved = mark !== movedRef.current;
435
+ movedRef.current = mark;
436
+ setState((s) => ({
437
+ ...s,
438
+ ...next,
439
+ sessionId: next.sessionId ?? s.sessionId,
440
+ progress: moved ? s.progress + 1 : s.progress,
441
+ finishedAt: next.finishedAt ? (s.finishedAt ?? next.finishedAt) : null,
442
+ }));
443
+ }, []);
444
+
445
+ // Look at the request every 2.5 s while it is live and the page is
446
+ // shown; stop when it ends. Hidden, nothing is asked; coming back looks
447
+ // at once.
448
+ const follow = useCallback(
449
+ (id, signal) => {
450
+ let timer = null;
451
+ let stopped = false;
452
+ let inflight = false;
453
+ const stop = () => {
454
+ stopped = true;
455
+ clearTimeout(timer);
456
+ screen.stop();
457
+ };
458
+ const schedule = (ms = FOLLOW_MS) => {
459
+ clearTimeout(timer);
460
+ timer = null;
461
+ if (!stopped && screen.shown()) timer = setTimeout(poll, ms);
462
+ };
463
+ const poll = async () => {
464
+ clearTimeout(timer);
465
+ timer = null;
466
+ if (stopped || inflight || !screen.shown()) return;
467
+ inflight = true;
468
+ const r = await readRequests({ id, signal });
469
+ inflight = false;
470
+ if (stopped || !aliveRef.current || r.aborted) return;
471
+ const request = r.ok ? r.data?.request : null;
472
+ if (request?.id) {
473
+ apply(request);
474
+ if (LIVE.has(request.status)) schedule();
475
+ else stop();
476
+ return;
477
+ }
478
+ if (r.status === 404) {
479
+ // Gone, or no longer this app's: stop following, close quietly.
480
+ stop();
481
+ setState((s) => ({ ...s, running: false }));
482
+ return;
483
+ }
484
+ schedule(FOLLOW_MS * 4); // a blip: look again, a little later
485
+ };
486
+ const screen = watchShown(poll);
487
+ followRef.current = stop;
488
+ schedule();
489
+ },
490
+ [apply],
491
+ );
492
+
493
+ // The 0.3.x door, as it was: _claw/chat streamed into the app. Taken
494
+ // only on a claw that has no _claw/ask.
495
+ const legacyStart = useCallback(async (text, { sessionId, signal }) => {
154
496
  const thread = sessionId ?? null;
497
+ pathRef.current = "legacy";
155
498
  sessionRef.current = thread;
156
499
  setState({ ...IDLE, running: true, sessionId: thread });
157
500
 
@@ -187,12 +530,9 @@ export function useJob() {
187
530
  }
188
531
  };
189
532
 
190
- const result = await startJob(text, {
191
- sessionId: thread ?? undefined,
192
- onEvent,
193
- signal: controller.signal,
194
- });
195
- if (!aliveRef.current) return result;
533
+ const result = await startJob(text, { sessionId: thread ?? undefined, onEvent, signal });
534
+ // Aborted means a new start, a cancel or a reset already set the state.
535
+ if (!aliveRef.current || signal.aborted) return result;
196
536
  // A stream that ends without `done` (a dropped connection, a 4xx
197
537
  // before the first frame) must still leave the panel closable.
198
538
  if (!result.ok && !result.aborted)
@@ -201,18 +541,99 @@ export function useJob() {
201
541
  return result;
202
542
  }, []);
203
543
 
544
+ const start = useCallback(
545
+ async (message, { sessionId, row, how, job = true, list, open } = {}) => {
546
+ const text = String(message ?? "").trim();
547
+ if (!text) return { ok: false, error: "nothing to ask" };
548
+ abortRef.current?.abort();
549
+ followRef.current?.();
550
+ followRef.current = null;
551
+ const controller = new AbortController();
552
+ abortRef.current = controller;
553
+ const thread = sessionId ?? null;
554
+ pathRef.current = "request";
555
+ sessionRef.current = thread;
556
+ requestRef.current = null;
557
+ movedRef.current = null;
558
+ setState({ ...IDLE, running: true, sessionId: thread });
559
+
560
+ const asking = (session) =>
561
+ ask({ text, row, how, job, list, sessionId: session ?? undefined, open, signal: controller.signal });
562
+ let result = await asking(thread);
563
+ if (!result.ok && thread && START_FRESH.has(result.code) && !controller.signal.aborted) {
564
+ sessionRef.current = null;
565
+ setState((s) => ({ ...s, sessionId: null }));
566
+ result = await asking(null);
567
+ }
568
+ if (!aliveRef.current || controller.signal.aborted) return result;
569
+
570
+ // A 404 that names no refusal: a claw from before _claw/ask. Its
571
+ // _claw/chat still runs the job, so a 0.4.0 app works there too.
572
+ // The thread is the one still held: after a retry that dropped a
573
+ // teammate's, it is none — never theirs.
574
+ if (!result.ok && result.status === 404 && !result.code)
575
+ return legacyStart(legacyMessage(text, row, how), { sessionId: sessionRef.current, signal: controller.signal });
576
+
577
+ if (!result.ok) {
578
+ setState((s) => ({ ...s, running: false, error: s.error ?? result.error }));
579
+ return result;
580
+ }
581
+ const request = result.data?.request;
582
+ if (!request?.id) {
583
+ setState((s) => ({ ...s, running: false, finishedAt: new Date().toISOString() }));
584
+ return result;
585
+ }
586
+ apply(request);
587
+ announceAsk();
588
+ if (LIVE.has(request.status)) follow(request.id, controller.signal);
589
+ return result;
590
+ },
591
+ [apply, follow, legacyStart],
592
+ );
593
+
204
594
  const cancel = useCallback(async () => {
205
595
  abortRef.current?.abort();
206
- const id = sessionRef.current;
207
- setState((s) => ({ ...s, running: false, finishedAt: new Date().toISOString() }));
208
- if (id) await cancelJob(id);
596
+ followRef.current?.();
597
+ followRef.current = null;
598
+ const request = requestRef.current;
599
+ const live = Boolean(request?.id && LIVE.has(request.status));
600
+ // A stopped request reads the same whoever stopped it: 'Stopped', as
601
+ // jobFromRequest gives one seen by polling, with its step settled —
602
+ // never the surface's doneTitle over a pulsing step. The 0.3.x path
603
+ // closes the panel as it always did.
604
+ setState((s) => ({
605
+ ...s,
606
+ running: false,
607
+ finishedAt: new Date().toISOString(),
608
+ ...(live
609
+ ? {
610
+ status: "stopped",
611
+ queued: false,
612
+ needsYou: false,
613
+ error: "Stopped",
614
+ steps: (s.steps ?? []).map((x) => (x.status === "running" ? { ...x, status: "failed" } : x)),
615
+ }
616
+ : {}),
617
+ }));
618
+ if (live) return stopRequest(request.id);
619
+ if (pathRef.current === "legacy" && sessionRef.current) return cancelJob(sessionRef.current);
620
+ return undefined;
209
621
  }, []);
210
622
 
211
623
  const reset = useCallback(() => {
212
624
  abortRef.current?.abort();
625
+ followRef.current?.();
626
+ followRef.current = null;
627
+ pathRef.current = null;
213
628
  sessionRef.current = null;
629
+ requestRef.current = null;
630
+ movedRef.current = null;
214
631
  setState(IDLE);
215
632
  }, []);
216
633
 
217
- return { ...state, start, cancel, reset };
634
+ // Take the founder to the conversation: the question when it needs
635
+ // them, the account of the work otherwise. False on the 0.3.x path.
636
+ const open = useCallback(() => (requestRef.current ? openRequest(requestRef.current) : false), []);
637
+
638
+ return { ...state, start, cancel, reset, open };
218
639
  }