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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/r/annotate.json +3 -3
- package/r/approval-request.json +4 -4
- 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 +9 -9
- package/r/cart.json +2 -2
- package/r/chart.json +7 -7
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +5 -5
- package/r/contact-section.json +2 -2
- package/r/core.json +17 -11
- package/r/csv-import.json +3 -3
- package/r/data-table.json +4 -4
- 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 +4 -4
- 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 +6 -6
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- 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 +3 -3
- 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 +6 -6
- 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 +4 -4
- package/r/signature-pad.json +3 -3
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +3 -3
- 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/approval-request/README.md +2 -1
- package/registry/blocks/approval-request/approval-request.jsx +5 -3
- package/registry/blocks/calendar-view/README.md +8 -0
- package/registry/blocks/calendar-view/agenda.jsx +5 -4
- package/registry/blocks/calendar-view/calendar.jsx +23 -8
- package/registry/blocks/calendar-view/demo.jsx +2 -0
- package/registry/blocks/calendar-view/month.jsx +6 -5
- package/registry/blocks/calendar-view/time-grid.jsx +9 -7
- package/registry/blocks/calendar-view/tray.jsx +5 -4
- package/registry/blocks/chart/README.md +9 -4
- package/registry/blocks/chart/cartesian.jsx +31 -24
- package/registry/blocks/chart/core.jsx +21 -0
- package/registry/blocks/chart/pie.jsx +15 -10
- package/registry/blocks/chart/sparkline.jsx +32 -14
- package/registry/blocks/chat/message.jsx +4 -4
- package/registry/blocks/comment-box/README.md +1 -1
- package/registry/blocks/comment-box/comment-time.jsx +4 -6
- package/registry/blocks/data-table/README.md +4 -0
- package/registry/blocks/data-table/data-table.jsx +20 -6
- package/registry/blocks/flow-canvas/edges.jsx +4 -2
- package/registry/blocks/flow-canvas/theme.js +59 -48
- 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/registry/blocks/mind-map/README.md +9 -2
- package/registry/blocks/mind-map/mind-map.jsx +51 -12
- package/registry/blocks/mind-map/tree.js +33 -0
- package/registry/blocks/org-chart/org-chart.jsx +1 -1
- package/registry/blocks/pdf-viewer/notes.jsx +4 -3
- package/registry/blocks/record-notes/README.md +1 -1
- package/registry/blocks/record-notes/record-notes.jsx +4 -5
- package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
- package/registry/blocks/sidebar/sidebar.jsx +13 -13
- package/registry/blocks/signature-pad/signature.js +8 -1
- package/registry/blocks/spreadsheet/cells.jsx +2 -2
- package/src/shadcn.mjs +1 -1
- package/templates/next/files/README.md +410 -86
- package/templates/next/files/app/auto-refresh.jsx +42 -15
- 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 +409 -0
- package/templates/next/files/app/layout.jsx +18 -3
- package/templates/next/files/check/run.mjs +51 -18
- 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/calendar.jsx +24 -0
- package/templates/next/files/components/ui/date-picker.jsx +15 -2
- package/templates/next/files/components/ui/field.jsx +12 -3
- package/templates/next/files/components/ui/icon.jsx +37 -2
- package/templates/next/files/components/ui/index.js +1 -0
- package/templates/next/files/components/ui/sheet.jsx +38 -2
- package/templates/next/files/components/ui/toggle.jsx +20 -2
- package/templates/next/files/lib/actions.js +63 -5
- package/templates/next/files/lib/claw.js +127 -28
- package/templates/next/files/lib/data.js +4 -1
- package/templates/next/files/lib/db.js +18 -1
- package/templates/next/files/lib/hooks.js +493 -37
- package/templates/next/files/lib/platform.js +113 -15
- package/templates/next/files/surfaces/README.md +3 -0
- 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
|
|
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
|
+
|
|
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
|
-
*
|
|
106
|
-
* time per hook — call it once per place the founder can
|
|
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,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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
}
|