makersclaw 0.3.2 → 0.5.1

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 (121) 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 +4 -4
  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 +9 -9
  9. package/r/cart.json +2 -2
  10. package/r/chart.json +7 -7
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +2 -2
  13. package/r/comment-box.json +5 -5
  14. package/r/contact-section.json +2 -2
  15. package/r/core.json +17 -11
  16. package/r/csv-import.json +3 -3
  17. package/r/data-table.json +4 -4
  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 +4 -4
  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 +6 -6
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  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 +3 -3
  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 +6 -6
  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 +4 -4
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +2 -2
  51. package/r/spreadsheet.json +3 -3
  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/approval-request/README.md +2 -1
  59. package/registry/blocks/approval-request/approval-request.jsx +5 -3
  60. package/registry/blocks/calendar-view/README.md +8 -0
  61. package/registry/blocks/calendar-view/agenda.jsx +5 -4
  62. package/registry/blocks/calendar-view/calendar.jsx +23 -8
  63. package/registry/blocks/calendar-view/demo.jsx +2 -0
  64. package/registry/blocks/calendar-view/month.jsx +6 -5
  65. package/registry/blocks/calendar-view/time-grid.jsx +9 -7
  66. package/registry/blocks/calendar-view/tray.jsx +5 -4
  67. package/registry/blocks/chart/README.md +9 -4
  68. package/registry/blocks/chart/cartesian.jsx +31 -24
  69. package/registry/blocks/chart/core.jsx +21 -0
  70. package/registry/blocks/chart/pie.jsx +15 -10
  71. package/registry/blocks/chart/sparkline.jsx +32 -14
  72. package/registry/blocks/chat/message.jsx +4 -4
  73. package/registry/blocks/comment-box/README.md +1 -1
  74. package/registry/blocks/comment-box/comment-time.jsx +4 -6
  75. package/registry/blocks/data-table/README.md +4 -0
  76. package/registry/blocks/data-table/data-table.jsx +20 -6
  77. package/registry/blocks/flow-canvas/edges.jsx +4 -2
  78. package/registry/blocks/flow-canvas/theme.js +59 -48
  79. package/registry/blocks/job-panel/README.md +40 -3
  80. package/registry/blocks/job-panel/block.json +2 -2
  81. package/registry/blocks/job-panel/demo.jsx +75 -6
  82. package/registry/blocks/job-panel/job-panel.jsx +60 -11
  83. package/registry/blocks/mind-map/README.md +9 -2
  84. package/registry/blocks/mind-map/mind-map.jsx +51 -12
  85. package/registry/blocks/mind-map/tree.js +33 -0
  86. package/registry/blocks/org-chart/org-chart.jsx +1 -1
  87. package/registry/blocks/pdf-viewer/notes.jsx +4 -3
  88. package/registry/blocks/record-notes/README.md +1 -1
  89. package/registry/blocks/record-notes/record-notes.jsx +4 -5
  90. package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
  91. package/registry/blocks/sidebar/sidebar.jsx +13 -13
  92. package/registry/blocks/signature-pad/signature.js +8 -1
  93. package/registry/blocks/spreadsheet/cells.jsx +2 -2
  94. package/src/shadcn.mjs +1 -1
  95. package/templates/next/files/README.md +410 -86
  96. package/templates/next/files/app/auto-refresh.jsx +42 -15
  97. package/templates/next/files/app/claw-alive/route.js +17 -0
  98. package/templates/next/files/app/error-relay.jsx +9 -4
  99. package/templates/next/files/app/escape-relay.jsx +27 -0
  100. package/templates/next/files/app/frame.js +409 -0
  101. package/templates/next/files/app/layout.jsx +18 -3
  102. package/templates/next/files/check/run.mjs +51 -18
  103. package/templates/next/files/components/parts/fixture.jsx +29 -4
  104. package/templates/next/files/components/parts/index.js +2 -1
  105. package/templates/next/files/components/parts/live-work.jsx +235 -0
  106. package/templates/next/files/components/ui/button.jsx +6 -1
  107. package/templates/next/files/components/ui/calendar.jsx +24 -0
  108. package/templates/next/files/components/ui/date-picker.jsx +15 -2
  109. package/templates/next/files/components/ui/field.jsx +12 -3
  110. package/templates/next/files/components/ui/icon.jsx +37 -2
  111. package/templates/next/files/components/ui/index.js +1 -0
  112. package/templates/next/files/components/ui/sheet.jsx +38 -2
  113. package/templates/next/files/components/ui/toggle.jsx +20 -2
  114. package/templates/next/files/lib/actions.js +63 -5
  115. package/templates/next/files/lib/claw.js +127 -28
  116. package/templates/next/files/lib/data.js +4 -1
  117. package/templates/next/files/lib/db.js +18 -1
  118. package/templates/next/files/lib/hooks.js +493 -37
  119. package/templates/next/files/lib/platform.js +113 -15
  120. package/templates/next/files/surfaces/README.md +3 -0
  121. package/templates/next/template.json +19 -8
@@ -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
+
310
+ /**
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
+
104
355
  /**
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.
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,23 +409,122 @@ 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
 
418
+ // No job at all: the panel closes.
419
+ const idle = useCallback(() => {
420
+ pathRef.current = null;
421
+ sessionRef.current = null;
422
+ requestRef.current = null;
423
+ movedRef.current = null;
424
+ setState(IDLE);
425
+ }, []);
426
+
427
+ /** An ask or a stream `controller` cut off by an unmount the hook came
428
+ * back from, with nothing started since: see the effect below. */
429
+ const cutOff = (controller) => Boolean(controller.unmounted) && abortRef.current === controller && aliveRef.current;
430
+
431
+ const apply = useCallback((request) => {
432
+ if (!aliveRef.current) return;
433
+ requestRef.current = request;
434
+ const next = jobFromRequest(request);
435
+ if (next.sessionId) sessionRef.current = next.sessionId;
436
+ const mark = [request.status, request.progress?.n, request.progress?.of, request.updated_at].join("|");
437
+ const moved = mark !== movedRef.current;
438
+ movedRef.current = mark;
439
+ setState((s) => ({
440
+ ...s,
441
+ ...next,
442
+ sessionId: next.sessionId ?? s.sessionId,
443
+ progress: moved ? s.progress + 1 : s.progress,
444
+ finishedAt: next.finishedAt ? (s.finishedAt ?? next.finishedAt) : null,
445
+ }));
446
+ }, []);
447
+
448
+ // Look at the request every 2.5 s while it is live and the page is
449
+ // shown; stop when it ends. Hidden, nothing is asked; coming back looks
450
+ // at once.
451
+ const follow = useCallback(
452
+ (id, signal) => {
453
+ let timer = null;
454
+ let stopped = false;
455
+ let inflight = false;
456
+ const stop = () => {
457
+ stopped = true;
458
+ clearTimeout(timer);
459
+ screen.stop();
460
+ };
461
+ const schedule = (ms = FOLLOW_MS) => {
462
+ clearTimeout(timer);
463
+ timer = null;
464
+ if (!stopped && screen.shown()) timer = setTimeout(poll, ms);
465
+ };
466
+ const poll = async () => {
467
+ clearTimeout(timer);
468
+ timer = null;
469
+ if (stopped || inflight || !screen.shown()) return;
470
+ inflight = true;
471
+ const r = await readRequests({ id, signal });
472
+ inflight = false;
473
+ if (stopped || !aliveRef.current || r.aborted) return;
474
+ const request = r.ok ? r.data?.request : null;
475
+ if (request?.id) {
476
+ apply(request);
477
+ if (LIVE.has(request.status)) schedule();
478
+ else stop();
479
+ return;
480
+ }
481
+ if (r.status === 404) {
482
+ // Gone, or no longer this app's: stop following, close quietly.
483
+ stop();
484
+ setState((s) => ({ ...s, running: false }));
485
+ return;
486
+ }
487
+ schedule(FOLLOW_MS * 4); // a blip: look again, a little later
488
+ };
489
+ const screen = watchShown(poll);
490
+ followRef.current = stop;
491
+ schedule();
492
+ },
493
+ [apply],
494
+ );
495
+
496
+ // Unmounting aborts what is in flight and stops following. React runs
497
+ // that cleanup and mounts the hook again in development (its double
498
+ // mount; a fast refresh does the same), and up to 0.4.0 the hook came
499
+ // back to a panel that lied: 0.3.x called the aborted job done — work
500
+ // that never ran — and 0.4.0 left it running with nothing behind it
501
+ // (makersclaw-store BLOCKS.md; mind-maps and landing-page-reviewer
502
+ // started jobs from a timer instead). Now, back on its feet, the hook
503
+ // follows a live request again, and an ask the cleanup cut off before
504
+ // it was answered closes the panel: neither done nor failed (start and
505
+ // legacyStart, via cutOff).
140
506
  useEffect(() => {
141
507
  aliveRef.current = true;
508
+ const request = requestRef.current;
509
+ if (!followRef.current && pathRef.current === "request" && request?.id && LIVE.has(request.status)) {
510
+ const controller = new AbortController();
511
+ abortRef.current = controller;
512
+ follow(request.id, controller.signal);
513
+ }
142
514
  return () => {
143
515
  aliveRef.current = false;
516
+ if (abortRef.current) abortRef.current.unmounted = true;
144
517
  abortRef.current?.abort();
518
+ followRef.current?.();
519
+ followRef.current = null;
145
520
  };
146
- }, []);
521
+ }, [follow]);
147
522
 
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;
523
+ // The 0.3.x door, as it was: _claw/chat streamed into the app. Taken
524
+ // only on a claw that has no _claw/ask.
525
+ const legacyStart = useCallback(async (text, { sessionId, signal }) => {
154
526
  const thread = sessionId ?? null;
527
+ pathRef.current = "legacy";
155
528
  sessionRef.current = thread;
156
529
  setState({ ...IDLE, running: true, sessionId: thread });
157
530
 
@@ -187,12 +560,14 @@ export function useJob() {
187
560
  }
188
561
  };
189
562
 
190
- const result = await startJob(text, {
191
- sessionId: thread ?? undefined,
192
- onEvent,
193
- signal: controller.signal,
194
- });
195
- if (!aliveRef.current) return result;
563
+ const result = await startJob(text, { sessionId: thread ?? undefined, onEvent, signal });
564
+ // Aborted means a new start, a cancel or a reset already set the
565
+ // state — or an unmount cut the stream, and the panel closes.
566
+ if (!aliveRef.current || signal.aborted) {
567
+ const current = abortRef.current;
568
+ if (current?.signal === signal && cutOff(current)) idle();
569
+ return result;
570
+ }
196
571
  // A stream that ends without `done` (a dropped connection, a 4xx
197
572
  // before the first frame) must still leave the panel closable.
198
573
  if (!result.ok && !result.aborted)
@@ -201,18 +576,99 @@ export function useJob() {
201
576
  return result;
202
577
  }, []);
203
578
 
579
+ const start = useCallback(
580
+ async (message, { sessionId, row, how, job = true, list, open } = {}) => {
581
+ const text = String(message ?? "").trim();
582
+ if (!text) return { ok: false, error: "nothing to ask" };
583
+ abortRef.current?.abort();
584
+ followRef.current?.();
585
+ followRef.current = null;
586
+ const controller = new AbortController();
587
+ abortRef.current = controller;
588
+ const thread = sessionId ?? null;
589
+ pathRef.current = "request";
590
+ sessionRef.current = thread;
591
+ requestRef.current = null;
592
+ movedRef.current = null;
593
+ setState({ ...IDLE, running: true, sessionId: thread });
594
+
595
+ const asking = (session) =>
596
+ ask({ text, row, how, job, list, sessionId: session ?? undefined, open, signal: controller.signal });
597
+ let result = await asking(thread);
598
+ if (!result.ok && thread && START_FRESH.has(result.code) && !controller.signal.aborted) {
599
+ sessionRef.current = null;
600
+ setState((s) => ({ ...s, sessionId: null }));
601
+ result = await asking(null);
602
+ }
603
+ if (!aliveRef.current || controller.signal.aborted) {
604
+ // Cut off unanswered by an unmount: nothing to follow, no panel.
605
+ if (cutOff(controller)) idle();
606
+ return result;
607
+ }
608
+
609
+ // A 404 that names no refusal: a claw from before _claw/ask. Its
610
+ // _claw/chat still runs the job, so a 0.4.0 app works there too.
611
+ // The thread is the one still held: after a retry that dropped a
612
+ // teammate's, it is none — never theirs.
613
+ if (!result.ok && result.status === 404 && !result.code)
614
+ return legacyStart(legacyMessage(text, row, how), { sessionId: sessionRef.current, signal: controller.signal });
615
+
616
+ if (!result.ok) {
617
+ setState((s) => ({ ...s, running: false, error: s.error ?? result.error }));
618
+ return result;
619
+ }
620
+ const request = result.data?.request;
621
+ if (!request?.id) {
622
+ setState((s) => ({ ...s, running: false, finishedAt: new Date().toISOString() }));
623
+ return result;
624
+ }
625
+ apply(request);
626
+ announceAsk();
627
+ if (LIVE.has(request.status)) follow(request.id, controller.signal);
628
+ return result;
629
+ },
630
+ [apply, follow, legacyStart],
631
+ );
632
+
204
633
  const cancel = useCallback(async () => {
205
634
  abortRef.current?.abort();
206
- const id = sessionRef.current;
207
- setState((s) => ({ ...s, running: false, finishedAt: new Date().toISOString() }));
208
- if (id) await cancelJob(id);
635
+ followRef.current?.();
636
+ followRef.current = null;
637
+ const request = requestRef.current;
638
+ const live = Boolean(request?.id && LIVE.has(request.status));
639
+ // A stopped request reads the same whoever stopped it: 'Stopped', as
640
+ // jobFromRequest gives one seen by polling, with its step settled —
641
+ // never the surface's doneTitle over a pulsing step. The 0.3.x path
642
+ // closes the panel as it always did.
643
+ setState((s) => ({
644
+ ...s,
645
+ running: false,
646
+ finishedAt: new Date().toISOString(),
647
+ ...(live
648
+ ? {
649
+ status: "stopped",
650
+ queued: false,
651
+ needsYou: false,
652
+ error: "Stopped",
653
+ steps: (s.steps ?? []).map((x) => (x.status === "running" ? { ...x, status: "failed" } : x)),
654
+ }
655
+ : {}),
656
+ }));
657
+ if (live) return stopRequest(request.id);
658
+ if (pathRef.current === "legacy" && sessionRef.current) return cancelJob(sessionRef.current);
659
+ return undefined;
209
660
  }, []);
210
661
 
211
662
  const reset = useCallback(() => {
212
663
  abortRef.current?.abort();
213
- sessionRef.current = null;
214
- setState(IDLE);
215
- }, []);
664
+ followRef.current?.();
665
+ followRef.current = null;
666
+ idle();
667
+ }, [idle]);
668
+
669
+ // Take the founder to the conversation: the question when it needs
670
+ // them, the account of the work otherwise. False on the 0.3.x path.
671
+ const open = useCallback(() => (requestRef.current ? openRequest(requestRef.current) : false), []);
216
672
 
217
- return { ...state, start, cancel, reset };
673
+ return { ...state, start, cancel, reset, open };
218
674
  }