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
|
@@ -20,6 +20,9 @@ import { Checkbox, Input } from "./field";
|
|
|
20
20
|
// press outside still close it. Give it `label`.
|
|
21
21
|
// label the dialog's name for a screen reader when no `title` shows
|
|
22
22
|
//
|
|
23
|
+
// A field with autoFocus inside it takes focus once the sheet is open
|
|
24
|
+
// (otherwise the browser gives it to the first control: Close).
|
|
25
|
+
//
|
|
23
26
|
// Confirm below is the "delete this?" case — never window.confirm().
|
|
24
27
|
|
|
25
28
|
const SIDES = {
|
|
@@ -33,6 +36,37 @@ const SIDES = {
|
|
|
33
36
|
bottom: "inset-x-0 top-auto bottom-0 w-full max-w-none max-h-[85dvh] rounded-t-xl border-t",
|
|
34
37
|
};
|
|
35
38
|
|
|
39
|
+
// autoFocus inside a Sheet. React focuses an autoFocus field as the field
|
|
40
|
+
// mounts, and the dialog opens after that, in the effect below: a field
|
|
41
|
+
// mounted with the sheet, or long before it (a sheet's body is rendered
|
|
42
|
+
// while it is closed), was focused inside a closed dialog, where focus
|
|
43
|
+
// goes nowhere. showModal() then focused the first thing it could, the
|
|
44
|
+
// header's Close button, and typing in "New piece" went nowhere
|
|
45
|
+
// (weekly-content-report, 2026-09-23; CLAW-FIXES #18). So once it is
|
|
46
|
+
// open, the Sheet puts focus where a field asked for it.
|
|
47
|
+
//
|
|
48
|
+
// Which field: React renders no autofocus attribute in the browser. It
|
|
49
|
+
// keeps a host element's props on the element, under "__reactProps$…"
|
|
50
|
+
// (react-dom's own key since React 17; test:app holds the installed
|
|
51
|
+
// react-dom to that name), so that is read, for the elements React
|
|
52
|
+
// autofocuses. The attribute counts too: a server-rendered field has it.
|
|
53
|
+
const AUTOFOCUSABLE = "input, textarea, select, button, [autofocus]";
|
|
54
|
+
|
|
55
|
+
function reactProps(node) {
|
|
56
|
+
for (const key of Object.keys(node)) if (key.startsWith("__reactProps$")) return node[key];
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The field in `dialog` that asked for focus (autoFocus), or null — never
|
|
61
|
+
* one inside a sheet nested in this one, never a disabled one. */
|
|
62
|
+
export function autoFocusTarget(dialog) {
|
|
63
|
+
for (const node of dialog.querySelectorAll(AUTOFOCUSABLE)) {
|
|
64
|
+
if (node.disabled || node.closest("dialog") !== dialog) continue;
|
|
65
|
+
if (node.hasAttribute("autofocus") || reactProps(node)?.autoFocus) return node;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
36
70
|
export function Sheet({ open, onClose, title, description, side = "end", footer, bare = false, label, className, children }) {
|
|
37
71
|
const ref = useRef(null);
|
|
38
72
|
const titleId = useId();
|
|
@@ -41,8 +75,10 @@ export function Sheet({ open, onClose, title, description, side = "end", footer,
|
|
|
41
75
|
useEffect(() => {
|
|
42
76
|
const el = ref.current;
|
|
43
77
|
if (!el) return;
|
|
44
|
-
if (open && !el.open)
|
|
45
|
-
|
|
78
|
+
if (open && !el.open) {
|
|
79
|
+
el.showModal();
|
|
80
|
+
autoFocusTarget(el)?.focus();
|
|
81
|
+
} else if (!open && el.open) el.close();
|
|
46
82
|
}, [open]);
|
|
47
83
|
|
|
48
84
|
return (
|
|
@@ -88,13 +88,24 @@ const SEG = {
|
|
|
88
88
|
sm: { track: "p-0.5", item: "min-h-8 px-2.5", square: "min-h-8 min-w-8 px-0", glyph: 16 },
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
+
// The group is a scroll container, so its row never makes its parent
|
|
92
|
+
// wider (in a plain grid it pushed the page wider on a phone, 0.5.0).
|
|
93
|
+
// That also takes away the minimums a flex item gets from its content,
|
|
94
|
+
// so each comes back unless the className sizes that axis itself:
|
|
95
|
+
// shrink-0 keeps its width in a flex row (its neighbours give way, as
|
|
96
|
+
// before), min-h-min its height in a flex column.
|
|
97
|
+
const FLEXES = /(?:^|\s)!?(?:w-|flex-|grow|shrink|basis-)/;
|
|
98
|
+
const TALL = /(?:^|\s)!?(?:h-|min-h-|size-)/;
|
|
99
|
+
|
|
91
100
|
/**
|
|
92
101
|
* A row of toggles as one control, the segmented look: a muted track,
|
|
93
102
|
* the pressed items raised. `type` single (exactly one pressed — pressing
|
|
94
103
|
* it again keeps it; a filter that can be off is `multiple`) · multiple.
|
|
95
104
|
* `value` is a string (single) or an array (multiple); `onChange` gets
|
|
96
105
|
* the same shape. md is 44px tall, sm 36px. Stretches when given a width
|
|
97
|
-
* (className="w-full"): the items share it.
|
|
106
|
+
* (className="w-full"): the items share it. Never wider than its
|
|
107
|
+
* container: past that it scrolls sideways inside itself (it does not
|
|
108
|
+
* wrap unless className="flex-wrap").
|
|
98
109
|
*
|
|
99
110
|
* items [{ value, label, icon?, iconOnly?, disabled? }] — `iconOnly`
|
|
100
111
|
* shows the icon alone; `label` is then what a screen reader says
|
|
@@ -157,7 +168,14 @@ export function ToggleGroup({
|
|
|
157
168
|
<div
|
|
158
169
|
role="group"
|
|
159
170
|
aria-label={ariaLabel}
|
|
160
|
-
className={cx(
|
|
171
|
+
className={cx(
|
|
172
|
+
"inline-flex max-w-full gap-1 overflow-x-auto rounded-md bg-muted",
|
|
173
|
+
!FLEXES.test(className ?? "") && "shrink-0",
|
|
174
|
+
!TALL.test(className ?? "") && "min-h-min",
|
|
175
|
+
s.track,
|
|
176
|
+
disabled && "opacity-50",
|
|
177
|
+
className,
|
|
178
|
+
)}
|
|
161
179
|
>
|
|
162
180
|
{live.map((it, i) => {
|
|
163
181
|
const on = isOn(it.value);
|
|
@@ -43,18 +43,73 @@ function done() {
|
|
|
43
43
|
revalidatePath("/", "layout");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// updated_at: a row a write changes says when. Up to 0.4.x updateRow left
|
|
47
|
+
// it alone, so unless a package shipped its own trigger, "changed 3 days
|
|
48
|
+
// ago", a list sorted by updated_at and the per-field merges the basics
|
|
49
|
+
// apps do all read the stamp of an older write (CLAW-FIXES #22). Set only
|
|
50
|
+
// where the table has the column and a write can put now() in it: a
|
|
51
|
+
// timestamp, with or without a zone, that is not a view's computed column.
|
|
52
|
+
// Anything else (a text or a number column of that name) stays the app's.
|
|
53
|
+
// A patch that names updated_at itself wins.
|
|
54
|
+
//
|
|
55
|
+
// The catalog is asked once a minute per table, not on every write: a
|
|
56
|
+
// drag on a board is a write, and the workspace database is across the
|
|
57
|
+
// network. A minute is how long a column added meanwhile (an install
|
|
58
|
+
// widening a shared table) goes unstamped.
|
|
59
|
+
const STAMP = "updated_at";
|
|
60
|
+
const STAMP_TYPES = new Set([1114, 1184]); // timestamp, timestamptz
|
|
61
|
+
const STAMP_TTL_MS = 60_000;
|
|
62
|
+
const stamps = new Map(); // quoted source → { at, has }
|
|
63
|
+
|
|
64
|
+
async function stampsUpdatedAt(source) {
|
|
65
|
+
const table = ident(source);
|
|
66
|
+
const known = stamps.get(table);
|
|
67
|
+
if (known && Date.now() - known.at < STAMP_TTL_MS) return known.has;
|
|
68
|
+
let has = false;
|
|
69
|
+
try {
|
|
70
|
+
// to_regclass reads the quoted name as the UPDATE does: the same
|
|
71
|
+
// search_path, the same case. A domain counts as its base type.
|
|
72
|
+
const { rows } = await query(
|
|
73
|
+
`SELECT coalesce(nullif(t.typbasetype, 0), t.oid) AS type,
|
|
74
|
+
pg_column_is_updatable(a.attrelid, a.attnum, false) AS updatable
|
|
75
|
+
FROM pg_attribute a JOIN pg_type t ON t.oid = a.atttypid
|
|
76
|
+
WHERE a.attrelid = to_regclass($1) AND a.attname = $2 AND a.attnum > 0 AND NOT a.attisdropped`,
|
|
77
|
+
[table, STAMP],
|
|
78
|
+
);
|
|
79
|
+
has = Boolean(rows[0]?.updatable) && STAMP_TYPES.has(Number(rows[0]?.type));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Never fail the write for its stamp; ask again next time.
|
|
82
|
+
console.error(`[actions] could not read ${source}'s columns: ${error?.message ?? error}`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
stamps.set(table, { at: Date.now(), has });
|
|
86
|
+
return has;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** ", updated_at = now()" for an UPDATE of `source` that sets `cols`,
|
|
90
|
+
* when the table stamps it and the write does not set it itself; else "". */
|
|
91
|
+
async function stampFor(source, cols) {
|
|
92
|
+
return !cols.includes(STAMP) && (await stampsUpdatedAt(source)) ? `, ${ident(STAMP)} = now()` : "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The SET text for `cols` (from $2 on), stamped (above). */
|
|
96
|
+
async function setClause(source, cols) {
|
|
97
|
+
return cols.map((c, i) => `${ident(c)} = $${i + 2}`).join(", ") + (await stampFor(source, cols));
|
|
98
|
+
}
|
|
99
|
+
|
|
46
100
|
function fail(error) {
|
|
47
101
|
const message = error?.message ?? String(error);
|
|
48
102
|
console.error(`[actions] write failed: ${message}`);
|
|
49
103
|
return { ok: false, error: message };
|
|
50
104
|
}
|
|
51
105
|
|
|
52
|
-
/** Change columns of one row. `patch` is { column: value }
|
|
106
|
+
/** Change columns of one row. `patch` is { column: value }; updated_at
|
|
107
|
+
* becomes now() when the table has one (above). */
|
|
53
108
|
export async function updateRow({ source, idColumn = "id", id, patch, surface, action = "edited", detail, data }) {
|
|
54
109
|
try {
|
|
55
110
|
const cols = Object.keys(patch ?? {}).filter((c) => patch[c] !== undefined);
|
|
56
111
|
if (!cols.length) return { ok: false, error: "nothing to change" };
|
|
57
|
-
const sets =
|
|
112
|
+
const sets = await setClause(source, cols);
|
|
58
113
|
const { rowCount } = await query(
|
|
59
114
|
`UPDATE ${ident(source)} SET ${sets} WHERE ${ident(idColumn)} = $1`,
|
|
60
115
|
[id, ...cols.map((c) => patch[c])],
|
|
@@ -139,7 +194,8 @@ export async function deleteRow({ source, idColumn = "id", id, surface, detail }
|
|
|
139
194
|
* ON CONFLICT, because a settings table made with the `sql` verb usually
|
|
140
195
|
* has a plain `key text` column and Postgres rejects ON CONFLICT without a
|
|
141
196
|
* unique index on it. JSON mode sets the key inside the jsonb `column` of
|
|
142
|
-
* the row `id` in `source`.
|
|
197
|
+
* the row `id` in `source`. An update stamps updated_at as updateRow's
|
|
198
|
+
* does; the insert leaves it to the column's default.
|
|
143
199
|
*/
|
|
144
200
|
export async function saveSetting({
|
|
145
201
|
mode = "table",
|
|
@@ -156,15 +212,17 @@ export async function saveSetting({
|
|
|
156
212
|
try {
|
|
157
213
|
if (!key) return { ok: false, error: "no key" };
|
|
158
214
|
if (mode === "json") {
|
|
215
|
+
const stamp = await stampFor(source, [column]);
|
|
159
216
|
const { rowCount } = await query(
|
|
160
|
-
`UPDATE ${ident(source)} SET ${ident(column)} = jsonb_set(COALESCE(${ident(column)}, '{}'::jsonb), $2::text[], $3::jsonb, true) WHERE ${ident(idColumn)} = $1`,
|
|
217
|
+
`UPDATE ${ident(source)} SET ${ident(column)} = jsonb_set(COALESCE(${ident(column)}, '{}'::jsonb), $2::text[], $3::jsonb, true)${stamp} WHERE ${ident(idColumn)} = $1`,
|
|
161
218
|
[id, [key], JSON.stringify(value ?? null)],
|
|
162
219
|
);
|
|
163
220
|
if (!rowCount) return { ok: false, error: "row not found" };
|
|
164
221
|
} else {
|
|
165
222
|
const stored = typeof value === "object" && value !== null ? JSON.stringify(value) : value;
|
|
223
|
+
const stamp = await stampFor(source, [valueColumn]);
|
|
166
224
|
const { rowCount } = await query(
|
|
167
|
-
`UPDATE ${ident(source)} SET ${ident(valueColumn)} = $2 WHERE ${ident(keyColumn)} = $1`,
|
|
225
|
+
`UPDATE ${ident(source)} SET ${ident(valueColumn)} = $2${stamp} WHERE ${ident(keyColumn)} = $1`,
|
|
168
226
|
[key, stored],
|
|
169
227
|
);
|
|
170
228
|
if (!rowCount) {
|
|
@@ -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();
|
|
@@ -24,7 +24,10 @@ export function orderClause(orderBy, fallback) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/** Dates and other non-JSON values become plain so rows can cross into
|
|
27
|
-
* client components. pg already returns bigint/numeric as strings
|
|
27
|
+
* client components. pg already returns bigint/numeric as strings, and a
|
|
28
|
+
* `date` column as its day ("2026-09-28", lib/db.js), which passes
|
|
29
|
+
* through untouched: only a timestamp is still a Date here, and it
|
|
30
|
+
* becomes its ISO instant. */
|
|
28
31
|
export function plain(row) {
|
|
29
32
|
const out = {};
|
|
30
33
|
for (const [k, v] of Object.entries(row)) {
|
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
import { Pool } from "pg";
|
|
1
|
+
import { Pool, types } from "pg";
|
|
2
|
+
|
|
3
|
+
// A `date` column is a day on a wall calendar, not an instant, so it is
|
|
4
|
+
// read as its text: "2026-09-28", the same day for every reader, the
|
|
5
|
+
// kit's date format (components/ui/calendar.jsx), and what a save writes
|
|
6
|
+
// back unchanged. pg's own parser made it a Date at the SERVER's
|
|
7
|
+
// midnight: lib/data.js sent that on as an ISO timestamp, and a server
|
|
8
|
+
// east of UTC wrote "2026-09-27T22:00:00.000Z" for the 28th, a browser
|
|
9
|
+
// west of UTC showed the 27th, and a form that saved what it showed
|
|
10
|
+
// stored the day before (CLAW-FIXES #21, three store builds). Until 0.5.0
|
|
11
|
+
// it was a Date; a date[] column is text the same way. Registered on
|
|
12
|
+
// pg's shared types, so a pool made before this module loaded again (a
|
|
13
|
+
// dev server's hot reload keeps it on globalThis) reads days too.
|
|
14
|
+
const DATE = 1082;
|
|
15
|
+
const DATE_ARRAY = 1182;
|
|
16
|
+
const TEXT_ARRAY = 1009;
|
|
17
|
+
types.setTypeParser(DATE, (text) => text);
|
|
18
|
+
types.setTypeParser(DATE_ARRAY, types.getTypeParser(TEXT_ARRAY));
|
|
2
19
|
|
|
3
20
|
// One pool per process; DATABASE_URL comes from this directory's .env,
|
|
4
21
|
// written by app_database and sourced by product_run at start.
|