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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/r/annotate.json +3 -3
- package/r/approval-request.json +2 -2
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/board.json +2 -2
- package/r/calendar-view.json +3 -3
- package/r/cart.json +2 -2
- package/r/chart.json +2 -2
- package/r/chat.json +2 -2
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +3 -3
- package/r/contact-section.json +2 -2
- package/r/core.json +12 -6
- package/r/csv-import.json +3 -3
- package/r/data-table.json +2 -2
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/flow-canvas.json +2 -2
- package/r/footer.json +2 -2
- package/r/gantt.json +3 -3
- package/r/job-panel.json +8 -5
- package/r/json-editor.json +3 -3
- package/r/map.json +2 -2
- package/r/menubar.json +2 -2
- package/r/mind-map.json +3 -3
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +3 -3
- package/r/page-builder.json +2 -2
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +2 -2
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-notes.json +4 -4
- package/r/registry.json +131 -123
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +2 -2
- package/r/signature-pad.json +2 -2
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +2 -2
- package/r/stat-cards.json +3 -3
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/whiteboard.json +3 -3
- package/registry/blocks/job-panel/README.md +40 -3
- package/registry/blocks/job-panel/block.json +2 -2
- package/registry/blocks/job-panel/demo.jsx +75 -6
- package/registry/blocks/job-panel/job-panel.jsx +60 -11
- package/src/shadcn.mjs +1 -1
- package/templates/next/files/README.md +339 -73
- package/templates/next/files/app/auto-refresh.jsx +72 -10
- package/templates/next/files/app/claw-alive/route.js +17 -0
- package/templates/next/files/app/error-relay.jsx +9 -4
- package/templates/next/files/app/escape-relay.jsx +27 -0
- package/templates/next/files/app/frame.js +218 -0
- package/templates/next/files/app/layout.jsx +18 -3
- package/templates/next/files/check/run.mjs +25 -8
- package/templates/next/files/components/parts/fixture.jsx +29 -4
- package/templates/next/files/components/parts/index.js +2 -1
- package/templates/next/files/components/parts/live-work.jsx +235 -0
- package/templates/next/files/components/ui/button.jsx +6 -1
- package/templates/next/files/components/ui/date-picker.jsx +15 -2
- package/templates/next/files/components/ui/icon.jsx +37 -2
- package/templates/next/files/lib/claw.js +127 -28
- package/templates/next/files/lib/hooks.js +454 -33
- package/templates/next/files/lib/platform.js +113 -15
- package/templates/next/files/surfaces/README.md +3 -0
- 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
|
|
15
|
-
//
|
|
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
|
|
69
|
-
*
|
|
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
|
-
*
|
|
106
|
-
*
|
|
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
|
|
115
|
-
*
|
|
116
|
-
* text what the agent has written so far
|
|
117
|
-
* account
|
|
118
|
-
* is
|
|
119
|
-
* progress bumps every time
|
|
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
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
}
|