makersclaw 0.3.1 → 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 +6 -6
- 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/flow-canvas/README.md +5 -0
- package/registry/blocks/flow-canvas/board.js +22 -0
- package/registry/blocks/flow-canvas/flow-canvas.jsx +8 -2
- package/registry/blocks/flow-canvas/index.js +1 -0
- 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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { isValidElement, useState } from "react";
|
|
4
|
+
import { Button, Icon, StatusDot, cx } from "../ui";
|
|
5
|
+
|
|
6
|
+
// The empty state that shows the work coming, never an example. Inside a
|
|
7
|
+
// Makersclaw workspace a table opens empty (fixture.jsx: examples show
|
|
8
|
+
// only outside one), and a table is usually empty because its agent has
|
|
9
|
+
// not got to it yet — or is on it right now. So the empty block says
|
|
10
|
+
// which: the ask in flight on this surface (its line, whose ask it is,
|
|
11
|
+
// what it waits behind, the one press it needs), or, with nothing in
|
|
12
|
+
// flight, what would appear here and the one way to make it. Up to 0.3.x
|
|
13
|
+
// an example filled that gap; since 0.4.0 no app shows example data in a
|
|
14
|
+
// workspace (claw's docs/JARVIS-PLATFORM.md §9.6 and §12, promise 6).
|
|
15
|
+
//
|
|
16
|
+
// const work = useRequests({ surface: "deals" }); // lib/hooks.js
|
|
17
|
+
// if (!rows.length) {
|
|
18
|
+
// return <LiveEmpty work={work} surface="deals" icon="inbox" title="No deals yet"
|
|
19
|
+
// line="Ask the agent to find some, or add one." action={{ label: "Add a deal", onClick: add }} />;
|
|
20
|
+
// }
|
|
21
|
+
//
|
|
22
|
+
// Props in, callbacks out: `work` is useRequests()'s answer as it stands,
|
|
23
|
+
// and the row calls back through it — work.open(request) opens the
|
|
24
|
+
// conversation in the desk's agent column (where a need is answered),
|
|
25
|
+
// work.stop(id) stops an ask. The part imports nothing from lib/, so the
|
|
26
|
+
// parts still travel as one folder; in a shadcn project, with no `work`,
|
|
27
|
+
// it is the plain empty block.
|
|
28
|
+
|
|
29
|
+
// A request's status is one of eleven (claw's shared/src/requests.ts;
|
|
30
|
+
// lib/hooks.js keeps the same list — this folder may not import it).
|
|
31
|
+
// Every step before the agent is on it reads as Working: the member asked
|
|
32
|
+
// for the result, not for its plumbing.
|
|
33
|
+
const WORDS = new Map([
|
|
34
|
+
["routing", "Working"],
|
|
35
|
+
["queued", "Working"],
|
|
36
|
+
["installing", "Working"],
|
|
37
|
+
["setting_up", "Working"],
|
|
38
|
+
["building", "Working"],
|
|
39
|
+
["working", "Working"],
|
|
40
|
+
["needs_you", "Needs you"],
|
|
41
|
+
["answered", "Answered"],
|
|
42
|
+
["done", "Done"],
|
|
43
|
+
["stopped", "Stopped"],
|
|
44
|
+
["didnt_finish", "Didn't finish"],
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** The seven statuses in which the work still goes. `answered` is open
|
|
48
|
+
* but not live: the agent answered and waits, and no row is coming. */
|
|
49
|
+
const LIVE = new Set(["routing", "queued", "installing", "setting_up", "building", "working", "needs_you"]);
|
|
50
|
+
|
|
51
|
+
/** A scheduled run's statuses while it goes (a brief's, not a request's:
|
|
52
|
+
* _claw/requests returns the run only in one of these). */
|
|
53
|
+
const RUN_LIVE = new Set(["queued", "planning", "awaiting_approval", "in_progress"]);
|
|
54
|
+
|
|
55
|
+
/** The status word for a request or a bare status: "Working", "Needs
|
|
56
|
+
* you", "Answered", "Done", "Stopped", "Didn't finish". An unknown
|
|
57
|
+
* status is "" — a word the platform has not taught this part yet is
|
|
58
|
+
* better unsaid than guessed. */
|
|
59
|
+
export function liveWords(requestOrStatus) {
|
|
60
|
+
const status = typeof requestOrStatus === "string" ? requestOrStatus : requestOrStatus?.status;
|
|
61
|
+
return WORDS.get(status) ?? "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runGoing(run) {
|
|
65
|
+
return Boolean(run && typeof run === "object" && (run.status == null || RUN_LIVE.has(run.status)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One piece of work in flight, as a row: a pulsing dot, the request's
|
|
70
|
+
* line, whose request it is when it is not yours, what it waits behind.
|
|
71
|
+
* A need gets the warning tone and Answer (it is answered in the desk's
|
|
72
|
+
* column, where the conversation lives); your own ask gets Stop. With no
|
|
73
|
+
* request, a scheduled run that is going shows its title.
|
|
74
|
+
*
|
|
75
|
+
* request one of useRequests().live (lib/hooks.js)
|
|
76
|
+
* run useRequests().run, shown when there is no request
|
|
77
|
+
* onOpen (request) → open its conversation; Answer needs it
|
|
78
|
+
* onStop (id) → stop it; Stop needs it, and shows only to the asker
|
|
79
|
+
*/
|
|
80
|
+
export function LiveWork({ request, run, onOpen, onStop, className }) {
|
|
81
|
+
const [stopping, setStopping] = useState(false);
|
|
82
|
+
|
|
83
|
+
if (request && typeof request === "object") {
|
|
84
|
+
const status = request.status;
|
|
85
|
+
const word = liveWords(request);
|
|
86
|
+
const needs = status === "needs_you";
|
|
87
|
+
const queued = status === "queued";
|
|
88
|
+
const theirs = request.mine === false;
|
|
89
|
+
const main = request.line || word || request.title || "";
|
|
90
|
+
const meta = [];
|
|
91
|
+
if (theirs) meta.push(`from ${request.asker_name || "a teammate"}'s request`);
|
|
92
|
+
// claw's line for a queued request already says "Queued · after …"
|
|
93
|
+
// (lineOf, shared/src/requests.ts); say it once, under a line that
|
|
94
|
+
// does not.
|
|
95
|
+
if (queued && !/^Queued\b/.test(main)) meta.push(`Queued · after ${request.queued?.after_title || "the job before it"}`);
|
|
96
|
+
// Only the asker answers: a teammate's need opens on "Waiting on …"
|
|
97
|
+
// in the desk, so it is shown, not offered.
|
|
98
|
+
const answer = needs && !theirs && onOpen;
|
|
99
|
+
const going = LIVE.has(status);
|
|
100
|
+
const stop = !needs && going && request.mine === true && onStop;
|
|
101
|
+
const onStopClick = async () => {
|
|
102
|
+
setStopping(true);
|
|
103
|
+
try {
|
|
104
|
+
await onStop(request.id);
|
|
105
|
+
} finally {
|
|
106
|
+
setStopping(false);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
return (
|
|
110
|
+
<Row
|
|
111
|
+
tone={needs ? "warning" : going && !queued ? "primary" : "neutral"}
|
|
112
|
+
pulse={going && !queued}
|
|
113
|
+
word={word}
|
|
114
|
+
warn={needs}
|
|
115
|
+
main={main}
|
|
116
|
+
meta={meta}
|
|
117
|
+
className={className}
|
|
118
|
+
>
|
|
119
|
+
{answer ? (
|
|
120
|
+
<Button size="sm" variant="primary" onClick={() => onOpen(request)}>
|
|
121
|
+
Answer
|
|
122
|
+
</Button>
|
|
123
|
+
) : stop ? (
|
|
124
|
+
<Button size="sm" variant="quiet" loading={stopping} onClick={onStopClick}>
|
|
125
|
+
Stop
|
|
126
|
+
</Button>
|
|
127
|
+
) : null}
|
|
128
|
+
</Row>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (runGoing(run)) {
|
|
133
|
+
return (
|
|
134
|
+
<Row tone="primary" pulse word="Working" main={run.title || "Working"} meta={run.title ? ["Working"] : []} className={className} />
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function Row({ tone, pulse, word, warn, main, meta, className, children }) {
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
className={cx(
|
|
144
|
+
"flex items-center gap-3 rounded-lg border bg-card px-3 py-2.5 text-left text-sm text-card-foreground",
|
|
145
|
+
warn ? "border-warning/50" : "border-border",
|
|
146
|
+
className,
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
<StatusDot tone={tone} pulse={pulse} label={word || "Working"} hideLabel />
|
|
150
|
+
<div aria-live="polite" className="min-w-0 flex-1">
|
|
151
|
+
<p className="truncate font-medium">{main}</p>
|
|
152
|
+
{meta.length ? <p className="truncate text-xs text-muted-foreground">{meta.join(" · ")}</p> : null}
|
|
153
|
+
</div>
|
|
154
|
+
{children ? <div className="flex shrink-0 items-center gap-2">{children}</div> : null}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Does a request belong to this empty state? Each filter given is a way
|
|
160
|
+
* in (lib/hooks.js reads it the same way): an ask made on this surface,
|
|
161
|
+
* or work on this job wherever it was asked for. No filter: any. */
|
|
162
|
+
function belongs(request, surface, job) {
|
|
163
|
+
if (!surface && !job) return true;
|
|
164
|
+
return Boolean((surface && request.surface === surface) || (job && request.job_key === job));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The one to show when several are in flight: what needs an answer, then
|
|
168
|
+
// what is working, then what waits in the queue; yours before a
|
|
169
|
+
// teammate's; the newest first.
|
|
170
|
+
const rank = (r) => (r.status === "needs_you" ? 0 : r.status === "queued" ? 2 : 1);
|
|
171
|
+
|
|
172
|
+
function pickRequest(live, surface, job) {
|
|
173
|
+
const list = (Array.isArray(live) ? live : [])
|
|
174
|
+
.map((r, i) => ({ r, i }))
|
|
175
|
+
.filter(({ r }) => r && typeof r === "object" && LIVE.has(r.status) && belongs(r, surface, job));
|
|
176
|
+
list.sort(
|
|
177
|
+
(a, b) =>
|
|
178
|
+
rank(a.r) - rank(b.r) ||
|
|
179
|
+
Number(b.r.mine === true) - Number(a.r.mine === true) ||
|
|
180
|
+
String(b.r.created_at ?? "").localeCompare(String(a.r.created_at ?? "")) ||
|
|
181
|
+
a.i - b.i,
|
|
182
|
+
);
|
|
183
|
+
return list[0]?.r ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const FRAME = "grid place-items-center rounded-lg border border-dashed border-border px-6 py-10 text-center";
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* What an empty collection shows in a workspace. With work in flight for
|
|
190
|
+
* this surface or job — or the app's scheduled run going — the work, as
|
|
191
|
+
* a LiveWork row, and "Rows appear here as they land". With none, the
|
|
192
|
+
* empty block: icon, title, line and the one action that fills it.
|
|
193
|
+
*
|
|
194
|
+
* work useRequests()'s answer ({ live, run, open, stop }); absent
|
|
195
|
+
* in a shadcn project, where only the empty block shows
|
|
196
|
+
* surface the surface key its asks carry; job — the job key whose
|
|
197
|
+
* work lands here. Either is a way in; neither: any live work
|
|
198
|
+
* icon the empty block's glyph (inbox)
|
|
199
|
+
* title what would be here: "No deals yet"
|
|
200
|
+
* line how it gets here: "Ask the agent to find some, or add one."
|
|
201
|
+
* action { label, onClick, href, icon, variant } for a Button, or an
|
|
202
|
+
* element as it is
|
|
203
|
+
* children anything the surface adds under either state
|
|
204
|
+
*/
|
|
205
|
+
export function LiveEmpty({ work, surface, job, icon = "inbox", title, line, action, children, className }) {
|
|
206
|
+
const request = pickRequest(work?.live, surface, job);
|
|
207
|
+
const run = !request && runGoing(work?.run) ? work.run : null;
|
|
208
|
+
|
|
209
|
+
if (request || run) {
|
|
210
|
+
return (
|
|
211
|
+
<div className={cx(FRAME, "gap-3", className)}>
|
|
212
|
+
<LiveWork request={request} run={run} onOpen={work.open} onStop={work.stop} className="w-full max-w-md" />
|
|
213
|
+
<p className="text-sm text-muted-foreground">Rows appear here as they land</p>
|
|
214
|
+
{children}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const button = isValidElement(action) ? (
|
|
220
|
+
action
|
|
221
|
+
) : action && typeof action === "object" && action.label ? (
|
|
222
|
+
<Button variant={action.variant ?? "secondary"} icon={action.icon} href={action.href} onClick={action.onClick}>
|
|
223
|
+
{action.label}
|
|
224
|
+
</Button>
|
|
225
|
+
) : null;
|
|
226
|
+
return (
|
|
227
|
+
<div className={cx(FRAME, "gap-2", className)}>
|
|
228
|
+
<Icon name={icon} size={22} className="text-muted-foreground" />
|
|
229
|
+
{title ? <p className="text-sm font-medium">{title}</p> : null}
|
|
230
|
+
{line ? <p className="max-w-prose text-sm text-muted-foreground">{line}</p> : null}
|
|
231
|
+
{button ? <div className="mt-2">{button}</div> : null}
|
|
232
|
+
{children}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -9,7 +9,12 @@ import { Spinner } from "./feedback";
|
|
|
9
9
|
// by hand in a surface: the look is the workspace's, the behaviour is
|
|
10
10
|
// the browser's, and this is the only place both meet.
|
|
11
11
|
//
|
|
12
|
-
// variant primary (the one main action per surface
|
|
12
|
+
// variant primary (the one main action per surface, filled with
|
|
13
|
+
// --primary, the app's accent) · secondary (the default, the
|
|
14
|
+
// workspace's neutral --secondary: a Button that does not say
|
|
15
|
+
// primary is never the accent — a surface's "Add guest"
|
|
16
|
+
// written without variant="primary" came out dark grey on a
|
|
17
|
+
// coral app, 2026-09-25) ·
|
|
13
18
|
// quiet (no fill) · danger (destructive, red text) ·
|
|
14
19
|
// link (text only: inline, no box, padding or min-height — it
|
|
15
20
|
// wraps and truncates with the sentence it sits in) · nav (a navigation row: quiet, full
|
|
@@ -275,6 +275,15 @@ export function DatePicker({
|
|
|
275
275
|
* value, onChange(date | null), defaultValue, min, max, disabled
|
|
276
276
|
* (true, or a function for the Calendar's days), name, invalid, size,
|
|
277
277
|
* locale (the Calendar's; the field itself follows the browser), today
|
|
278
|
+
* clearable an × beside the calendar button once the field holds a
|
|
279
|
+
* date (false — DatePicker's is true)
|
|
280
|
+
*
|
|
281
|
+
* Every other prop goes onto the <input>, so a prop this part names must
|
|
282
|
+
* be taken out here: until 0.3.3 `clearable` was DatePicker's alone, and
|
|
283
|
+
* a surface that wrote <DateInput clearable> put it on the <input> ("Received
|
|
284
|
+
* `true` for a non-boolean attribute clearable", a podcast guest sheet,
|
|
285
|
+
* 2026-09-25). It is DateInput's now too, off unless asked, so a field
|
|
286
|
+
* that did not ask keeps its width.
|
|
278
287
|
*/
|
|
279
288
|
export function DateInput({
|
|
280
289
|
id,
|
|
@@ -290,6 +299,7 @@ export function DateInput({
|
|
|
290
299
|
locale,
|
|
291
300
|
today,
|
|
292
301
|
weekStartsOn,
|
|
302
|
+
clearable = false,
|
|
293
303
|
className,
|
|
294
304
|
...rest
|
|
295
305
|
}) {
|
|
@@ -299,6 +309,7 @@ export function DateInput({
|
|
|
299
309
|
const [month, setMonth] = useState(null);
|
|
300
310
|
const off = disabled === true;
|
|
301
311
|
const out = !!value && ((min && value < min) || (max && value > max));
|
|
312
|
+
const showClear = clearable && !off && !!value;
|
|
302
313
|
|
|
303
314
|
function set(v) {
|
|
304
315
|
if (valueProp === undefined) setOwn(v);
|
|
@@ -318,12 +329,14 @@ export function DateInput({
|
|
|
318
329
|
invalid={invalid || out}
|
|
319
330
|
onChange={(e) => set(e.target.value || null)}
|
|
320
331
|
className={cx(
|
|
321
|
-
"pr-
|
|
332
|
+
showClear ? "pr-20" : "pr-12",
|
|
333
|
+
"[&::-webkit-calendar-picker-indicator]:hidden tabular-nums",
|
|
322
334
|
size === "sm" && "min-h-9",
|
|
323
335
|
)}
|
|
324
336
|
{...rest}
|
|
325
337
|
/>
|
|
326
|
-
<span className={cx("absolute right-1", size === "sm" ? "top-0" : "top-1")}>
|
|
338
|
+
<span className={cx("absolute right-1 flex", size === "sm" ? "top-0" : "top-1")}>
|
|
339
|
+
{showClear ? <IconButton icon="x" label="Clear date" size="sm" onClick={() => set(null)} /> : null}
|
|
327
340
|
<Popover
|
|
328
341
|
open={open}
|
|
329
342
|
onOpenChange={(next) => {
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
// one here, never an <svg> in a surface. `name` is a key below; unknown
|
|
4
4
|
// names render nothing (and `npm run check` names them).
|
|
5
5
|
//
|
|
6
|
+
// `size` is pixels (16, the default) or one of the kit's size names:
|
|
7
|
+
// xs 12 · sm 14 · md 16 · lg 20 · xl 24. Every other part of the kit
|
|
8
|
+
// takes size="sm", so a surface writes it here too — and until 0.3.3 a
|
|
9
|
+
// name went onto the <svg> as width="sm", which is not a length: the
|
|
10
|
+
// browser logged "Expected length, 'sm'" and drew the glyph at its
|
|
11
|
+
// 300×150 default, a calendar as big as the card around it (a podcast
|
|
12
|
+
// guest board, 2026-09-25).
|
|
13
|
+
//
|
|
14
|
+
// A glyph added for a build takes lucide's name when lucide has one
|
|
15
|
+
// (calendar-check, trash-2), because that is the name a build reaches for
|
|
16
|
+
// first; the path is our own, drawn to the same 24 box, never copied.
|
|
17
|
+
//
|
|
6
18
|
// BRANDS holds the brand marks: google, github, apple, microsoft,
|
|
7
19
|
// x-logo (the X / Twitter mark; `x` is the close glyph), linkedin,
|
|
8
20
|
// instagram, youtube, facebook. Unlike PATHS they are FILLED shapes
|
|
@@ -167,6 +179,13 @@ const PATHS = {
|
|
|
167
179
|
kanban: "M4 4h4v14H4zM10 4h4v9h-4zM16 4h4v16h-4z",
|
|
168
180
|
table: "M3 4h18v16H3zM3 10h18M3 15h18M9 10v10",
|
|
169
181
|
gantt: "M4 4v16h16M8 8h6M11 12h7M9 16h5",
|
|
182
|
+
// Names builds reached for that the sprite lacked, so `npm run check`
|
|
183
|
+
// refused the ship and the build spent a round renaming them (the
|
|
184
|
+
// launch build test, 2026-09-25, and the local build logs before it):
|
|
185
|
+
// a day that is booked, the bin with its slats, a small tree of nodes.
|
|
186
|
+
"calendar-check": "M3 5h18v16H3zM16 3v4M8 3v4M3 10h18M9 15.5l2 2 4-4",
|
|
187
|
+
"trash-2": "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14M10 11v6M14 11v6",
|
|
188
|
+
network: "M9 2h6v6H9zM2 16h6v6H2zM16 16h6v6h-6zM12 8v4M5 16v-4h14v4",
|
|
170
189
|
};
|
|
171
190
|
|
|
172
191
|
const BRANDS = {
|
|
@@ -183,14 +202,30 @@ const BRANDS = {
|
|
|
183
202
|
|
|
184
203
|
export const ICON_NAMES = [...Object.keys(PATHS), ...Object.keys(BRANDS)];
|
|
185
204
|
|
|
205
|
+
// The kit's size names in pixels. A number, or a length the <svg> can
|
|
206
|
+
// read ("18", "1.25em"), is kept as it was; anything else is the default,
|
|
207
|
+
// never an attribute the browser cannot read.
|
|
208
|
+
const SIZES = { xs: 12, sm: 14, md: 16, lg: 20, xl: 24 };
|
|
209
|
+
const LENGTH = /^(\d+\.?\d*|\.\d+)(px|em|rem|%)?$/;
|
|
210
|
+
|
|
211
|
+
function pixels(size) {
|
|
212
|
+
if (typeof size === "number" && size > 0) return size;
|
|
213
|
+
if (typeof size === "string") {
|
|
214
|
+
if (SIZES[size]) return SIZES[size];
|
|
215
|
+
if (LENGTH.test(size)) return size;
|
|
216
|
+
}
|
|
217
|
+
return SIZES.md;
|
|
218
|
+
}
|
|
219
|
+
|
|
186
220
|
export function Icon({ name, size = 16, className, label }) {
|
|
187
221
|
const brand = BRANDS[name];
|
|
188
222
|
const d = PATHS[name] || brand;
|
|
189
223
|
if (!d) return null;
|
|
224
|
+
const px = pixels(size);
|
|
190
225
|
return (
|
|
191
226
|
<svg
|
|
192
|
-
width={
|
|
193
|
-
height={
|
|
227
|
+
width={px}
|
|
228
|
+
height={px}
|
|
194
229
|
viewBox="0 0 24 24"
|
|
195
230
|
fill={brand ? "currentColor" : "none"}
|
|
196
231
|
fillRule={brand ? "evenodd" : undefined}
|
|
@@ -3,18 +3,24 @@
|
|
|
3
3
|
// (docs/ARTIFACT-CONTRACTS.md §5). It is reachable from the BROWSER only —
|
|
4
4
|
// the grant cookie is the identity — so everything here is client-side.
|
|
5
5
|
//
|
|
6
|
-
// Every call resolves; nothing throws. A 404
|
|
7
|
-
// gateway has no such route yet, and comes back
|
|
8
|
-
// a surface renders a quiet "not connected yet"
|
|
6
|
+
// Every call resolves; nothing throws. A 404 that names no refusal, or a
|
|
7
|
+
// network error, means the gateway has no such route yet, and comes back
|
|
8
|
+
// as `unavailable: true` so a surface renders a quiet "not connected yet"
|
|
9
|
+
// state instead of breaking. A 404 with a `code` is a route that exists
|
|
10
|
+
// saying no (notFound, below).
|
|
9
11
|
//
|
|
10
|
-
// An app calls
|
|
11
|
-
// (errors) are plumbing. `
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// lib/
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
12
|
+
// An app calls a handful of these. `me` (identity, status, the desk's
|
|
13
|
+
// origin) and `events` (errors) are plumbing. `ask` and `requests` are
|
|
14
|
+
// ASKING — a screen asks its agent for work, with a row as context, and
|
|
15
|
+
// the conversation lives in the desk's agent column, not in the app;
|
|
16
|
+
// lib/platform.js wraps them as ask / requests / stopRequest and
|
|
17
|
+
// lib/hooks.js as useJob and useRequests. `chat` and `cancel` are the
|
|
18
|
+
// 0.3.x door to the same work, streamed into the app (startJob /
|
|
19
|
+
// cancelJob): kept for a claw that has no `ask` yet. What an app still
|
|
20
|
+
// never calls is monitoring: the OS draws Agent, Runs and Activity as
|
|
21
|
+
// tabs on its window. The rest of the gateway's routes (§5) stay
|
|
22
|
+
// reachable through claw() / clawStream() for a surface that has a real
|
|
23
|
+
// reason.
|
|
18
24
|
|
|
19
25
|
/** The prefix the app is served under. The <html> attribute is set by the
|
|
20
26
|
* running server, so it wins over the value inlined at build time. */
|
|
@@ -59,20 +65,121 @@ export function errorSource() {
|
|
|
59
65
|
return PRELAUNCH.has(clawStatus()) ? "preview" : "runtime";
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// The desk around the frame
|
|
70
|
+
//
|
|
71
|
+
// The app runs in a frame on the founder's desk, and on its own host in
|
|
72
|
+
// production, so nothing but a message crosses to the desk. A screen that
|
|
73
|
+
// asks its agent (lib/platform.js, ask) tells the desk which request to
|
|
74
|
+
// open in its agent column; the desk checks the message came from this
|
|
75
|
+
// frame and this app's origin before it acts (claw's web/src/os/
|
|
76
|
+
// frame-grant.ts, openRequestMessage).
|
|
77
|
+
//
|
|
78
|
+
// The message goes to the desk's exact origin, never "*": "*" hands the
|
|
79
|
+
// request and session ids to whatever page happens to frame the app.
|
|
80
|
+
// The origin comes from the gateway (`desk_origin` on _claw/me and on
|
|
81
|
+
// every ask) and lives on <html data-claw-desk-origin> the way the status
|
|
82
|
+
// does, so any code that asks can post without a component's state.
|
|
83
|
+
|
|
84
|
+
/** The message the app posts to the desk: open this request's
|
|
85
|
+
* conversation in the agent column. */
|
|
86
|
+
export const OPEN_REQUEST_MESSAGE = "claw:open-request";
|
|
87
|
+
|
|
88
|
+
/** An http(s) origin, or null. A URL with a path is cut to its origin; a
|
|
89
|
+
* "*", a relative path or anything else unparseable is refused. */
|
|
90
|
+
function originOf(value) {
|
|
91
|
+
if (typeof value !== "string" || !value) return null;
|
|
92
|
+
try {
|
|
93
|
+
const url = new URL(value);
|
|
94
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
95
|
+
return url.origin;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Keep the desk's origin, as last heard from the gateway. Anything that
|
|
102
|
+
* is not an origin clears it, as a missing status clears the status. */
|
|
103
|
+
export function publishDesk(origin) {
|
|
104
|
+
if (typeof document === "undefined") return;
|
|
105
|
+
const clean = originOf(origin);
|
|
106
|
+
if (clean) document.documentElement.dataset.clawDeskOrigin = clean;
|
|
107
|
+
else delete document.documentElement.dataset.clawDeskOrigin;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function deskOrigin() {
|
|
111
|
+
if (typeof document === "undefined") return null;
|
|
112
|
+
return originOf(document.documentElement.dataset.clawDeskOrigin);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function currentWindow() {
|
|
116
|
+
return typeof window === "undefined" ? undefined : window;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Is the page inside a frame? Only then is there a desk to talk to. */
|
|
120
|
+
function framed(win) {
|
|
121
|
+
return Boolean(win && win.parent && win.parent !== win);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Ask the desk to open a request's conversation in its agent column.
|
|
126
|
+
* Posts only when the app is framed and the desk's origin is known, and
|
|
127
|
+
* only to that origin. → true when the message went.
|
|
128
|
+
*/
|
|
129
|
+
export function openInDesk({ requestId, sessionId } = {}, win = currentWindow()) {
|
|
130
|
+
const origin = deskOrigin();
|
|
131
|
+
if (!requestId || !origin || !framed(win)) return false;
|
|
132
|
+
try {
|
|
133
|
+
win.parent.postMessage(
|
|
134
|
+
{ type: OPEN_REQUEST_MESSAGE, request_id: String(requestId), session_id: sessionId ? String(sessionId) : null },
|
|
135
|
+
origin,
|
|
136
|
+
);
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false; // a parent that will not take messages
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Is this message from the desk, of this type? Only the window around
|
|
144
|
+
* the frame may send one — app/frame.js isDeskEscape, for any type. */
|
|
145
|
+
export function isDeskMessage(event, type, win = currentWindow()) {
|
|
146
|
+
return Boolean(framed(win) && event?.source === win.parent && event?.data?.type === type);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A refusal's words, and its `code` when the gateway names one
|
|
150
|
+
* (`not_your_thread`, `needs_answer`, …): the code is what a caller
|
|
151
|
+
* branches on, the words are what it shows. */
|
|
152
|
+
async function readFailure(res) {
|
|
63
153
|
const text = await res.text().catch(() => "");
|
|
154
|
+
let parsed = null;
|
|
64
155
|
try {
|
|
65
|
-
|
|
66
|
-
return parsed.error || parsed.message || text || res.statusText;
|
|
156
|
+
parsed = JSON.parse(text);
|
|
67
157
|
} catch {
|
|
68
|
-
|
|
158
|
+
/* not JSON: the words are the text */
|
|
159
|
+
}
|
|
160
|
+
if (parsed && typeof parsed === "object") {
|
|
161
|
+
const error = parsed.error || parsed.message || text || res.statusText;
|
|
162
|
+
return typeof parsed.code === "string" && parsed.code ? { error, code: parsed.code } : { error };
|
|
69
163
|
}
|
|
164
|
+
return { error: text || res.statusText || `HTTP ${res.status}` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A 404 is two different answers. With a `code` it is a route that
|
|
168
|
+
* exists refusing (_claw/ask's `unknown_thread`): an error like any
|
|
169
|
+
* other. Without one it is a gateway that has no such route — a claw
|
|
170
|
+
* from before it, or no gateway at all — and comes back `unavailable`,
|
|
171
|
+
* as every 404 did before codes existed. useJob reads that difference
|
|
172
|
+
* to choose between _claw/ask and the 0.3.x _claw/chat. */
|
|
173
|
+
async function notFound(res) {
|
|
174
|
+
const { error, code } = await readFailure(res);
|
|
175
|
+
if (code) return { ok: false, status: 404, error, code, unavailable: false };
|
|
176
|
+
return { ok: false, status: 404, error: "not available yet", unavailable: true };
|
|
70
177
|
}
|
|
71
178
|
|
|
72
179
|
/**
|
|
73
180
|
* claw("me") / claw("agent", { method: "PATCH", body: {...} })
|
|
74
181
|
* → { ok: true, status, data }
|
|
75
|
-
* → { ok: false, status, error, unavailable }
|
|
182
|
+
* → { ok: false, status, error, unavailable, code? }
|
|
76
183
|
*/
|
|
77
184
|
export async function claw(path, { method = "GET", body, signal } = {}) {
|
|
78
185
|
let res;
|
|
@@ -92,12 +199,8 @@ export async function claw(path, { method = "GET", body, signal } = {}) {
|
|
|
92
199
|
if (error?.name === "AbortError") return { ok: false, status: 0, error: "aborted", aborted: true };
|
|
93
200
|
return { ok: false, status: 0, error: "not reachable", unavailable: true };
|
|
94
201
|
}
|
|
95
|
-
if (res.status === 404)
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
if (!res.ok) {
|
|
99
|
-
return { ok: false, status: res.status, error: await readError(res), unavailable: false };
|
|
100
|
-
}
|
|
202
|
+
if (res.status === 404) return notFound(res);
|
|
203
|
+
if (!res.ok) return { ok: false, status: res.status, ...(await readFailure(res)), unavailable: false };
|
|
101
204
|
const text = await res.text().catch(() => "");
|
|
102
205
|
let data = null;
|
|
103
206
|
if (text) {
|
|
@@ -155,12 +258,8 @@ export async function clawStream(path, body, onEvent, { signal } = {}) {
|
|
|
155
258
|
if (error?.name === "AbortError") return { ok: false, status: 0, error: "aborted", aborted: true };
|
|
156
259
|
return { ok: false, status: 0, error: "not reachable", unavailable: true };
|
|
157
260
|
}
|
|
158
|
-
if (res.status === 404)
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
if (!res.ok) {
|
|
162
|
-
return { ok: false, status: res.status, error: await readError(res), unavailable: false };
|
|
163
|
-
}
|
|
261
|
+
if (res.status === 404) return notFound(res);
|
|
262
|
+
if (!res.ok) return { ok: false, status: res.status, ...(await readFailure(res)), unavailable: false };
|
|
164
263
|
if (!res.body) return { ok: false, status: res.status, error: "empty stream" };
|
|
165
264
|
|
|
166
265
|
const reader = res.body.getReader();
|