makersclaw 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/r/annotate.json +3 -3
  4. package/r/approval-request.json +2 -2
  5. package/r/audio-player.json +2 -2
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +2 -2
  8. package/r/calendar-view.json +3 -3
  9. package/r/cart.json +2 -2
  10. package/r/chart.json +2 -2
  11. package/r/chat.json +2 -2
  12. package/r/code-editor.json +2 -2
  13. package/r/comment-box.json +3 -3
  14. package/r/contact-section.json +2 -2
  15. package/r/core.json +12 -6
  16. package/r/csv-import.json +3 -3
  17. package/r/data-table.json +2 -2
  18. package/r/diagram.json +2 -2
  19. package/r/doc-editor.json +2 -2
  20. package/r/doc-page.json +3 -3
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +2 -2
  24. package/r/flow-canvas.json +2 -2
  25. package/r/footer.json +2 -2
  26. package/r/gantt.json +3 -3
  27. package/r/job-panel.json +8 -5
  28. package/r/json-editor.json +3 -3
  29. package/r/map.json +2 -2
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +3 -3
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +3 -3
  35. package/r/page-builder.json +2 -2
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +2 -2
  39. package/r/product-detail.json +2 -2
  40. package/r/product-grid.json +3 -3
  41. package/r/record-form.json +2 -2
  42. package/r/record-notes.json +4 -4
  43. package/r/registry.json +131 -123
  44. package/r/search-dialog.json +2 -2
  45. package/r/settings-list.json +2 -2
  46. package/r/setup-checklist.json +2 -2
  47. package/r/share-dialog.json +2 -2
  48. package/r/sidebar.json +2 -2
  49. package/r/signature-pad.json +2 -2
  50. package/r/sketch-canvas.json +2 -2
  51. package/r/spreadsheet.json +2 -2
  52. package/r/stat-cards.json +3 -3
  53. package/r/task-list.json +3 -3
  54. package/r/task-model.json +2 -2
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +2 -2
  57. package/r/whiteboard.json +3 -3
  58. package/registry/blocks/job-panel/README.md +40 -3
  59. package/registry/blocks/job-panel/block.json +2 -2
  60. package/registry/blocks/job-panel/demo.jsx +75 -6
  61. package/registry/blocks/job-panel/job-panel.jsx +60 -11
  62. package/src/shadcn.mjs +1 -1
  63. package/templates/next/files/README.md +339 -73
  64. package/templates/next/files/app/auto-refresh.jsx +72 -10
  65. package/templates/next/files/app/claw-alive/route.js +17 -0
  66. package/templates/next/files/app/error-relay.jsx +9 -4
  67. package/templates/next/files/app/escape-relay.jsx +27 -0
  68. package/templates/next/files/app/frame.js +218 -0
  69. package/templates/next/files/app/layout.jsx +18 -3
  70. package/templates/next/files/check/run.mjs +25 -8
  71. package/templates/next/files/components/parts/fixture.jsx +29 -4
  72. package/templates/next/files/components/parts/index.js +2 -1
  73. package/templates/next/files/components/parts/live-work.jsx +235 -0
  74. package/templates/next/files/components/ui/button.jsx +6 -1
  75. package/templates/next/files/components/ui/date-picker.jsx +15 -2
  76. package/templates/next/files/components/ui/icon.jsx +37 -2
  77. package/templates/next/files/lib/claw.js +127 -28
  78. package/templates/next/files/lib/hooks.js +454 -33
  79. package/templates/next/files/lib/platform.js +113 -15
  80. package/templates/next/files/surfaces/README.md +3 -0
  81. package/templates/next/template.json +17 -6
@@ -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) · secondary ·
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-12 [&::-webkit-calendar-picker-indicator]:hidden tabular-nums",
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={size}
193
- height={size}
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 or a network error means the
7
- // gateway has no such route yet, and comes back as `unavailable: true` so
8
- // a surface renders a quiet "not connected yet" state instead of breaking.
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 four of these. `me` (identity, status) and `events`
11
- // (errors) are plumbing. `chat` and `cancel` are COMMISSIONING — work the
12
- // founder asks for from inside the app, streamed back so they can watch
13
- // it (SOP §2.3); lib/platform.js wraps them as startJob / cancelJob and
14
- // lib/hooks.js as useJob. What an app still never calls is monitoring:
15
- // the OS draws Agent, Runs and Activity as tabs on its window. The rest
16
- // of the gateway's routes (§5) stay reachable through claw() /
17
- // clawStream() for a surface that has a real reason.
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
- async function readError(res) {
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
- const parsed = JSON.parse(text);
66
- return parsed.error || parsed.message || text || res.statusText;
156
+ parsed = JSON.parse(text);
67
157
  } catch {
68
- return text || res.statusText || `HTTP ${res.status}`;
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
- return { ok: false, status: 404, error: "not available yet", unavailable: true };
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
- return { ok: false, status: 404, error: "not available yet", unavailable: true };
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();