@hanzo/build 0.2.27 → 0.2.29

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.
@@ -15,6 +15,17 @@
15
15
  * ✓ step / → step / • step its plan
16
16
  * tokens used / <n> the end; the final message follows once more, bare
17
17
  *
18
+ * The sandbox's older harness (dev 0.6.94) prints another grammar: it stamps
19
+ * each item `[2026-09-29T16:07:35] ` and spells its headers its own way —
20
+ * `User instructions:`, `dev`, `thinking`, `exec <command> in <dir>`,
21
+ * `<command> succeeded in 1m 05s:`, `tool <call>`, `🌐 Search: <query>`,
22
+ * `apply_patch auto_approved=true:`, `turn diff:`, `tokens used: <n>`. A stamped
23
+ * line starts an item and says where the unstamped lines after it belong
24
+ * (`stamped`). Every stamped line no header owns is the harness's diagnostics.
25
+ *
26
+ * Warnings, errors, retries and diagnostics are the agent's log, one collapsed
27
+ * step, never the conversation; token counts are dropped.
28
+ *
18
29
  * The chunks are cut by a clock, not by line, so a line is read only once it
19
30
  * is whole. Lines no header owns — reasoning, or the tail of output from before
20
31
  * the recorded events begin — are prose. Everything here becomes TEXT on the
@@ -129,6 +129,13 @@ export function sections(diff) {
129
129
  return out;
130
130
  }
131
131
  const HEAD = /^(exec|codex|thinking|user|apply patch|tokens used|turn interrupted|context compacted)$/;
132
+ /** The older harness's stamp on each item it starts. */
133
+ const STAMP = /^\[\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z?\] /;
134
+ /** How long a stamped command or tool took: `14ms`, `2.1s`, `1m 05s`, `1h 02m`. */
135
+ const TOOK = '(?: in [\\dhms. ]+)?';
136
+ const RAN = new RegExp(`^(.+) (succeeded|exited (-?\\d+))${TOOK}:$`);
137
+ const CALLED = new RegExp(`^(.+) (success|failed)${TOOK}:$`);
138
+ const PATCHED = new RegExp(`^apply_patch\\(.*\\) exited (-?\\d+)${TOOK}:$`);
132
139
  const LEAD = /^(patch|mcp|web search|warning|ERROR|deprecated|model rerouted|hook): /;
133
140
  const RESULT = /^\s*(succeeded|exited (-?\d+)|declined|in progress)( in [\d.]+m?s)?:$/;
134
141
  /** A reader that appends the cards it finds to `out`, keyed by `mint`. */
@@ -168,8 +175,181 @@ export function harness(out, mint) {
168
175
  out.push(c);
169
176
  return c;
170
177
  };
178
+ // The agent's log: its warnings, errors and diagnostics, one step for the run.
179
+ let log = null;
180
+ const logged = (text) => {
181
+ if (!log)
182
+ log = step('Agent log', '', 'done');
183
+ log.output += log.output ? `\n${text}` : text;
184
+ };
185
+ let stamped = false;
186
+ let sink = 'none';
187
+ let into = null;
188
+ // A stamped command or tool is ended by name: its text as the harness printed it.
189
+ const running = new Map();
190
+ const begin = (item, name) => {
191
+ running.set(name, item);
192
+ };
193
+ const end = (name, ok) => {
194
+ const item = running.get(name);
195
+ if (!item)
196
+ return null;
197
+ running.delete(name);
198
+ item.ran = ok ? 'done' : 'error';
199
+ return item;
200
+ };
201
+ const stamp = (l) => {
202
+ settle();
203
+ into = null;
204
+ sink = 'none';
205
+ if (/^Hanzo Dev v\S+$/.test(l)) {
206
+ rules = 0;
207
+ sink = 'banner';
208
+ return;
209
+ }
210
+ if (l.startsWith('binary: ')) {
211
+ sink = 'banner';
212
+ return;
213
+ }
214
+ if (l === 'User instructions:') {
215
+ const c = { kind: 'said', key: mint(), who: 'person', text: '' };
216
+ out.push(c);
217
+ tasks.push({ card: c, lines: [] });
218
+ sink = 'task';
219
+ return;
220
+ }
221
+ if (l === 'dev') {
222
+ into = said('');
223
+ sink = 'said';
224
+ return;
225
+ }
226
+ if (l === 'thinking') {
227
+ into = step('Thinking', '', 'done');
228
+ sink = 'out';
229
+ return;
230
+ }
231
+ const exec = /^exec (.+) in (\S+)$/.exec(l);
232
+ if (exec) {
233
+ const file = reads(exec[1]);
234
+ const c = file
235
+ ? { kind: 'read', key: mint(), file: shown(file), ran: 'running' }
236
+ : { kind: 'shell', key: mint(), command: unwrap(exec[1]), output: '', ran: 'running' };
237
+ out.push(c);
238
+ begin(c, exec[1]);
239
+ return;
240
+ }
241
+ const ran = RAN.exec(l);
242
+ if (ran) {
243
+ const c = end(ran[1], ran[2] === 'succeeded');
244
+ if (c) {
245
+ // A read's output is the file it read, which the chip already names.
246
+ into = c;
247
+ sink = c.kind === 'shell' ? 'out' : 'drop';
248
+ return;
249
+ }
250
+ }
251
+ const custom = /^tool (.+) (success|failed)$/.exec(l);
252
+ if (custom) {
253
+ into = end(custom[1], custom[2] === 'success') ?? step(custom[1], 'tool', custom[2] === 'success' ? 'done' : 'error');
254
+ sink = 'out';
255
+ return;
256
+ }
257
+ const tool = /^tool (.+)$/.exec(l);
258
+ if (tool) {
259
+ into = step(tool[1], 'tool', 'running');
260
+ begin(into, tool[1]);
261
+ sink = 'out';
262
+ return;
263
+ }
264
+ const called = CALLED.exec(l);
265
+ if (called && running.has(called[1])) {
266
+ into = end(called[1], called[2] === 'success');
267
+ sink = 'out';
268
+ return;
269
+ }
270
+ const search = /^🌐 Search: (.*)$/.exec(l);
271
+ if (search) {
272
+ step('Web search', search[1], 'done');
273
+ return;
274
+ }
275
+ if (/^apply_patch auto_approved=\w+:$/.test(l)) {
276
+ patch();
277
+ sink = 'patch';
278
+ return;
279
+ }
280
+ const patched = PATCHED.exec(l);
281
+ if (patched) {
282
+ if (edit)
283
+ edit.card.ran = patched[1] === '0' ? 'done' : 'error';
284
+ sink = 'drop';
285
+ return;
286
+ }
287
+ if (l === 'turn diff:') {
288
+ diff = [];
289
+ sink = 'diff';
290
+ return;
291
+ }
292
+ if (/^tokens used: [\d,]+$/.test(l))
293
+ return;
294
+ if (/^task (interrupted|aborted)/.test(l)) {
295
+ for (const item of running.values())
296
+ item.ran = 'cancelled';
297
+ running.clear();
298
+ out.push({ kind: 'note', key: mint(), text: 'The agent was interrupted' });
299
+ return;
300
+ }
301
+ logged(l);
302
+ sink = 'log';
303
+ };
304
+ // An unstamped line of the stamped grammar, where the last stamped line said.
305
+ const under = (l) => {
306
+ switch (sink) {
307
+ case 'banner':
308
+ if (l === '--------' && ++rules === 3)
309
+ sink = 'none';
310
+ return;
311
+ case 'task':
312
+ tasks[tasks.length - 1].lines.push(l);
313
+ return;
314
+ case 'said':
315
+ if (into && into.kind === 'said')
316
+ into.text += into.text ? `\n${l}` : l;
317
+ return;
318
+ case 'out':
319
+ if (into && (into.kind === 'shell' || into.kind === 'step'))
320
+ into.output += into.output ? `\n${l}` : l;
321
+ return;
322
+ case 'patch': {
323
+ const file = /^[AMD] (.+?)(?: -> (.+))?$/.exec(l);
324
+ if (file && edit)
325
+ edit.paths.push(file[2] ?? file[1]);
326
+ return;
327
+ }
328
+ case 'diff':
329
+ diff.push(l);
330
+ return;
331
+ case 'log':
332
+ if (l.trim())
333
+ logged(l);
334
+ return;
335
+ case 'drop':
336
+ return;
337
+ }
338
+ if (l.trim())
339
+ logged(l);
340
+ };
171
341
  const line = (raw) => {
172
342
  const l = raw.replace(ANSI, '').replace(/\r$/, '');
343
+ const at = STAMP.exec(l);
344
+ if (at) {
345
+ stamped = true;
346
+ stamp(l.slice(at[0].length));
347
+ return;
348
+ }
349
+ if (stamped) {
350
+ under(l);
351
+ return;
352
+ }
173
353
  if (mode === 'config') {
174
354
  if (l === '--------' && ++rules === 2)
175
355
  mode = 'prose';
@@ -286,10 +466,10 @@ export function harness(out, mint) {
286
466
  mode = 'prose';
287
467
  return;
288
468
  }
289
- const warn = /^(warning|ERROR|deprecated|model rerouted): ?(.*)$/.exec(l);
469
+ const warn = mode === 'output' || mode === 'agent' ? null : /^(warning|ERROR|deprecated|model rerouted): ?(.*)$/.exec(l);
290
470
  if (warn) {
291
471
  settle();
292
- out.push({ kind: 'note', key: mint(), text: warn[1] === 'ERROR' ? warn[2] : `${warn[1]}: ${warn[2]}` });
472
+ logged(l);
293
473
  card = null;
294
474
  mode = 'prose';
295
475
  return;
package/lib/api/turn.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import type { Event } from './sessions.ts';
2
2
  /** The payload as an object. A live frame may carry it as a JSON string. */
3
3
  export declare function decode(payload: unknown): Record<string, unknown> | string | null;
4
+ /**
5
+ * A run's error as one plain line with what to do, never the log it came from:
6
+ * that goes in the Error step beside it (cards).
7
+ */
8
+ export declare function plain(error: string): string;
4
9
  /**
5
10
  * One event's sentence, or '' when it says nothing a person needs. `mode` is
6
11
  * the run's, from its record: a plan run's final status says the plan.
package/lib/api/turn.js CHANGED
@@ -70,7 +70,7 @@ function status(b, mode) {
70
70
  return branch ? `Pushed ${branch}${tail}${site}` : `Done${tail}${site}`;
71
71
  }
72
72
  case 'error':
73
- return str(b.error) || 'The run hit an error';
73
+ return plain(str(b.error));
74
74
  case 'stopped':
75
75
  return branch && b.changed ? `Stopped — work kept on ${branch}` : 'Stopped';
76
76
  case 'paused':
@@ -81,6 +81,20 @@ function status(b, mode) {
81
81
  }
82
82
  return str(b.status);
83
83
  }
84
+ /**
85
+ * A run's error as one plain line with what to do, never the log it came from:
86
+ * that goes in the Error step beside it (cards).
87
+ */
88
+ export function plain(error) {
89
+ if (!error)
90
+ return 'The run hit an error';
91
+ const ours = /providers_exhausted|every provider refused|temporarily unavailable/i.test(error);
92
+ if (!ours && /\b402 Payment Required|insufficient (credit|balance)/i.test(error))
93
+ return 'Your balance is out of credit. Add credit, then try again.';
94
+ if (ours || /\b(429 Too Many Requests|503 Service Unavailable)|stream disconnected|retries exhausted|rate limit/i.test(error))
95
+ return 'The model did not answer. Try again in a moment, or pick another model.';
96
+ return 'The run stopped with an error. Try again, or follow up with what to change.';
97
+ }
84
98
  /**
85
99
  * One event's sentence, or '' when it says nothing a person needs. `mode` is
86
100
  * the run's, from its record: a plan run's final status says the plan.
@@ -353,6 +367,8 @@ export function cards(events, mode = '') {
353
367
  const line = status(b, mode);
354
368
  if (line)
355
369
  out.push({ kind: 'note', key: mint(), text: line });
370
+ if (str(b.status) === 'error' && str(b.error))
371
+ out.push({ kind: 'step', key: mint(), name: 'Error', detail: '', output: str(b.error), ran: 'error' });
356
372
  break;
357
373
  }
358
374
  case 'control': {
package/lib/desk.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type DropdownMenuProps } from '@hanzo/ui';
2
2
  import type { Event } from './api/sessions.ts';
3
- export declare function Desk({ id, repo, branch, base, environment, mode, pr, title, project, sandbox, events, live, menu, refused, retry, onRetry, onHide, }: {
3
+ export declare function Desk({ id, repo, branch, base, environment, mode, pr, title, project, sandbox, site, published, events, live, menu, refused, retry, onRetry, onHide, }: {
4
4
  id: string;
5
5
  repo: string;
6
6
  branch: string;
@@ -15,6 +15,10 @@ export declare function Desk({ id, repo, branch, base, environment, mode, pr, ti
15
15
  project: string;
16
16
  /** The sandbox the run holds, or '' before it leases one and on a machine. */
17
17
  sandbox: string;
18
+ /** Where the run's work is published, or '' before it is. */
19
+ site: string;
20
+ /** The run's latest publish, which loads the page again. */
21
+ published: number;
18
22
  events: Event[];
19
23
  live: boolean;
20
24
  /** What can be done to the run — rename, share, copy its id — as its header offers it. */
package/lib/desk.js CHANGED
@@ -1,28 +1,39 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * The pane beside a run's transcript: its codebase's environment, what it
4
- * pushed, its sandbox's desktop and shell, its files, and what it listens to.
3
+ * The pane beside a run's transcript, as tabs: the Browser showing where the run
4
+ * is published, its sandbox's Desktop and Terminal, what it produced, its files,
5
+ * what it pushed, and its codebase's environment.
5
6
  */
6
7
  import { SizableText, XStack, YStack } from '@hanzo/gui';
7
- import { Info, MoreHorizontal, PanelRightClose } from '@hanzogui/lucide-icons-2';
8
+ import { ExternalLink, Info, MoreHorizontal, PanelRightClose, RefreshCcw } from '@hanzogui/lucide-icons-2';
8
9
  import { Button, DropdownMenu } from '@hanzo/ui';
9
- import { useState } from 'react';
10
+ import { PreviewFrame } from '@hanzo/ui/agents';
11
+ import { useRef, useState } from 'react';
10
12
  import { shell } from './api/turn.js';
11
13
  import { Door } from './door.js';
12
14
  import { Environment } from './environment.js';
13
- import { Files } from './files.js';
15
+ import { Artifacts, Files } from './files.js';
14
16
  import { Git } from './git.js';
17
+ import { Out } from './out.js';
15
18
  /** The tabs that are a surface of their own, drawn edge to edge. */
16
- const BLEED = ['desktop', 'terminal'];
17
- export function Desk({ id, repo, branch, base, environment, mode, pr, title, project, sandbox, events, live, menu, refused, retry, onRetry, onHide, }) {
18
- // Until a tab is picked it follows the run's mode, which is known once the run
19
- // is read: a setup run's work IS the environment, so it opens there.
19
+ const BLEED = ['browser', 'desktop', 'terminal'];
20
+ export function Desk({ id, repo, branch, base, environment, mode, pr, title, project, sandbox, site, published, events, live, menu, refused, retry, onRetry, onHide, }) {
21
+ // Until a tab is picked it follows the run: a setup run's work IS the
22
+ // environment, a published run opens on its site, any other on its terminal.
20
23
  const [picked, setTab] = useState(null);
21
- const tab = picked ?? (mode === 'setup' ? 'environment' : 'terminal');
24
+ const tab = picked ?? (mode === 'setup' ? 'environment' : site ? 'browser' : 'terminal');
22
25
  const name = repo.split('/').filter(Boolean).pop() || repo;
23
26
  const lines = shell(events);
24
27
  const bleed = BLEED.includes(tab);
25
- return (_jsxs(YStack, { role: "complementary", "aria-label": "Run details", flex: 1, minW: 0, minH: 0, borderLeftWidth: 1, borderColor: "$borderColor", display: "none", $md: { display: 'flex' }, children: [_jsxs(XStack, { px: "$2", pt: "$2", gap: "$1", borderBottomWidth: 1, borderColor: "$borderColor", items: "center", children: [_jsxs(XStack, { flex: 1, minW: 0, gap: "$1", overflow: "scroll", children: [_jsx(TabButton, { id: "environment", tab: tab, onPick: setTab, children: "Environment" }), _jsx(TabButton, { id: "git", tab: tab, onPick: setTab, children: "Git" }), _jsx(TabButton, { id: "desktop", tab: tab, onPick: setTab, children: "Desktop" }), _jsx(TabButton, { id: "terminal", tab: tab, onPick: setTab, children: "Terminal" }), _jsx(TabButton, { id: "files", tab: tab, onPick: setTab, children: "Files" }), _jsx(TabButton, { id: "subscriptions", tab: tab, onPick: setTab, children: "Subscriptions" })] }), _jsx(XStack, { render: "button", "aria-label": "Hide the side pane", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, onPress: onHide, children: _jsx(PanelRightClose, { size: 16 }) }), _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": "Run actions", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(MoreHorizontal, { size: 16 }) }), items: [...menu, { key: 'details', label: 'Details', icon: _jsx(Info, { size: 16 }), onSelect: () => setTab('environment') }] })] }), _jsxs(YStack, { flex: 1, minH: 0, overflow: bleed ? 'hidden' : 'scroll', px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: [tab === 'environment' ? (_jsxs(YStack, { gap: "$2", children: [_jsx(Environment, { repo: name, busy: live && mode === 'setup' }), _jsx(SizableText, { size: "$2", color: "$ink", pt: "$4", children: "This run" }), _jsx(Fact, { label: "Run", value: id }), _jsx(Fact, { label: "Repository", value: name || '—' }), _jsx(Fact, { label: "Branch", value: branch || '—' }), _jsx(Fact, { label: "Base", value: base || '—' }), _jsx(Fact, { label: "Runs on", value: environment || 'sandbox' }), _jsx(Fact, { label: "Mode", value: mode || 'build' })] })) : null, tab === 'git' ? _jsx(Git, { session: id, title: title, live: live }) : null, tab === 'desktop' ? _jsx(Door, { which: "screen", sandbox: sandbox, live: live, session: id }) : null, tab === 'terminal' ? (_jsx(Terminal, { id: id, sandbox: sandbox, lines: lines, live: live, refused: refused, retry: retry, onRetry: onRetry })) : null, tab === 'files' ? (_jsx(Files, { session: id, repo: repo, branch: branch, sandbox: sandbox, live: live, mode: mode, project: project, pr: pr, onEnvironment: () => setTab('environment') })) : null, tab === 'subscriptions' ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", px: "$6", children: _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center', maxWidth: 320 }, children: "Ask an agent to subscribe to a Slack channel or thread, a pull request, or a timer." }) })) : null] })] }));
28
+ return (_jsxs(YStack, { role: "complementary", "aria-label": "Run details", flex: 1, minW: 0, minH: 0, borderLeftWidth: 1, borderColor: "$borderColor", display: "none", $md: { display: 'flex' }, children: [_jsxs(XStack, { px: "$2", pt: "$2", gap: "$1", borderBottomWidth: 1, borderColor: "$borderColor", items: "center", children: [_jsxs(XStack, { flex: 1, minW: 0, gap: "$1", overflow: "scroll", children: [_jsx(TabButton, { id: "browser", tab: tab, onPick: setTab, children: "Browser" }), _jsx(TabButton, { id: "desktop", tab: tab, onPick: setTab, children: "Desktop" }), _jsx(TabButton, { id: "terminal", tab: tab, onPick: setTab, children: "Terminal" }), _jsx(TabButton, { id: "artifacts", tab: tab, onPick: setTab, children: "Artifacts" }), _jsx(TabButton, { id: "files", tab: tab, onPick: setTab, children: "Files" }), _jsx(TabButton, { id: "git", tab: tab, onPick: setTab, children: "Git" }), _jsx(TabButton, { id: "environment", tab: tab, onPick: setTab, children: "Environment" })] }), _jsx(XStack, { render: "button", "aria-label": "Hide the side pane", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, onPress: onHide, children: _jsx(PanelRightClose, { size: 16 }) }), _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": "Run actions", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(MoreHorizontal, { size: 16 }) }), items: [...menu, { key: 'details', label: 'Details', icon: _jsx(Info, { size: 16 }), onSelect: () => setTab('environment') }] })] }), _jsxs(YStack, { flex: 1, minH: 0, overflow: bleed ? 'hidden' : 'scroll', px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: [tab === 'environment' ? (_jsxs(YStack, { gap: "$2", children: [_jsx(Environment, { repo: name, busy: live && mode === 'setup' }), _jsx(SizableText, { size: "$2", color: "$ink", pt: "$4", children: "This run" }), _jsx(Fact, { label: "Run", value: id }), _jsx(Fact, { label: "Repository", value: name || '—' }), _jsx(Fact, { label: "Branch", value: branch || '—' }), _jsx(Fact, { label: "Base", value: base || '—' }), _jsx(Fact, { label: "Runs on", value: environment || 'sandbox' }), _jsx(Fact, { label: "Mode", value: mode || 'build' })] })) : null, tab === 'browser' ? _jsx(Browser, { site: site, live: live }, published) : null, tab === 'git' ? _jsx(Git, { session: id, title: title, live: live }) : null, tab === 'desktop' ? _jsx(Door, { which: "screen", sandbox: sandbox, live: live, session: id }) : null, tab === 'terminal' ? (_jsx(Terminal, { id: id, sandbox: sandbox, lines: lines, live: live, refused: refused, retry: retry, onRetry: onRetry })) : null, tab === 'artifacts' ? (_jsx(Artifacts, { repo: repo, branch: branch, mode: mode, project: project, site: site, pr: pr, onEnvironment: () => setTab('environment') })) : null, tab === 'files' ? _jsx(Files, { session: id, repo: repo, branch: branch, sandbox: sandbox, live: live }) : null] })] }));
29
+ }
30
+ /**
31
+ * The Browser tab: the page where the run published its work, framed, with its
32
+ * address, a reload and the page in a tab of its own.
33
+ */
34
+ function Browser({ site, live }) {
35
+ const frame = useRef(null);
36
+ return (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsxs(XStack, { px: "$2", py: "$1.5", gap: "$1.5", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(XStack, { render: "button", "aria-label": "Reload the page", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 14 }) }), _jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$soft", numberOfLines: 1, style: mono, children: site || 'Not published yet' }), site ? (_jsx(Out, { href: site, label: "Open in a new tab", children: _jsx(XStack, { p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 14 }) }) })) : null] }), _jsx(PreviewFrame, { ref: frame, src: site, title: "Where this run is published", device: "desktop", empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: live ? 'Not published yet' : 'Nothing was published' }), _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: live ? 'The page appears here once the run publishes its site.' : 'This run published no site. Its work is on its branch.' })] }) })] }));
26
37
  }
27
38
  function TabButton({ id, tab, onPick, children }) {
28
39
  const on = tab === id;
package/lib/files.d.ts CHANGED
@@ -1,11 +1,17 @@
1
- export declare function Files({ session, repo, branch, sandbox, live, mode, project, pr, onEnvironment, }: {
1
+ export declare function Files({ session, repo, branch, sandbox, live, }: {
2
2
  session: string;
3
3
  repo: string;
4
4
  branch: string;
5
5
  sandbox: string;
6
6
  live: boolean;
7
+ }): import("react").JSX.Element;
8
+ export declare function Artifacts({ repo, branch, mode, project, site, pr, onEnvironment, }: {
9
+ repo: string;
10
+ branch: string;
7
11
  mode: string;
8
12
  project: string;
13
+ /** Where the run's work is published, or ''. */
14
+ site: string;
9
15
  pr: {
10
16
  href: string;
11
17
  label: string;
package/lib/files.js CHANGED
@@ -1,12 +1,12 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * A run's Files tab. Files is its working tree: live from the sandbox while the
4
- * run holds one — every edit the agent has made, committed or not — and its
5
- * branch on the forge otherwise. Artifacts is what the run produced: its pull
6
- * request, its branch, its environment proposal and the project it built.
3
+ * A run's Files tab: its working tree, live from the sandbox while the run holds
4
+ * one — every edit the agent has made, committed or not — and its branch on the
5
+ * forge otherwise. And its Artifacts tab: what the run produced — its published
6
+ * site, its pull request, its branch, its environment proposal and its project.
7
7
  */
8
8
  import { SizableText, XStack, YStack } from '@hanzo/gui';
9
- import { ExternalLink, File, Folder, GitBranch, GitPullRequest, Layers, Settings2 } from '@hanzogui/lucide-icons-2';
9
+ import { ExternalLink, File, Folder, Globe, GitBranch, GitPullRequest, Layers, Settings2 } from '@hanzogui/lucide-icons-2';
10
10
  import { useState } from 'react';
11
11
  import { blob, tree } from './api/changes.js';
12
12
  import { read as look } from './api/sandbox.js';
@@ -14,12 +14,11 @@ import { useRead } from './data.js';
14
14
  import { useHost, useTarget } from './host.js';
15
15
  import { Out } from './out.js';
16
16
  const monoWrap = { whiteSpace: 'pre-wrap', fontFamily: 'var(--f-mono, ui-monospace, monospace)' };
17
- export function Files({ session, repo, branch, sandbox, live, mode, project, pr, onEnvironment, }) {
18
- const [pane, setPane] = useState('files');
17
+ export function Files({ session, repo, branch, sandbox, live, }) {
19
18
  const reachable = live && Boolean(sandbox);
20
19
  const [source, setSource] = useState(reachable ? 'live' : 'branch');
21
20
  const from = reachable ? source : 'branch';
22
- return (_jsxs(YStack, { gap: "$3", children: [_jsxs(XStack, { gap: "$1", items: "center", flexWrap: "wrap", children: [_jsx(Pick, { on: pane === 'files', onPress: () => setPane('files'), children: "Files" }), _jsx(Pick, { on: pane === 'artifacts', onPress: () => setPane('artifacts'), children: "Artifacts" }), _jsx(XStack, { flex: 1 }), pane === 'files' && reachable ? (_jsxs(_Fragment, { children: [_jsx(Pick, { on: from === 'live', onPress: () => setSource('live'), children: "Live" }), _jsx(Pick, { on: from === 'branch', onPress: () => setSource('branch'), children: "Branch" })] })) : null] }), pane === 'artifacts' ? (_jsx(Artifacts, { repo: repo, branch: branch, mode: mode, project: project, pr: pr, onEnvironment: onEnvironment })) : from === 'live' ? (_jsx(Live, { sandbox: sandbox })) : (_jsx(Branch, { session: session, label: `${repo}${branch ? `@${branch}` : ''}` }))] }));
21
+ return (_jsxs(YStack, { gap: "$3", children: [reachable ? (_jsxs(XStack, { gap: "$1", items: "center", flexWrap: "wrap", children: [_jsx(Pick, { on: from === 'live', onPress: () => setSource('live'), children: "Live" }), _jsx(Pick, { on: from === 'branch', onPress: () => setSource('branch'), children: "Branch" })] })) : null, from === 'live' ? _jsx(Live, { sandbox: sandbox }) : _jsx(Branch, { session: session, label: `${repo}${branch ? `@${branch}` : ''}` })] }));
23
22
  }
24
23
  /** The sandbox's working tree, as it is right now. */
25
24
  function Live({ sandbox }) {
@@ -85,9 +84,11 @@ function Branch({ session, label }) {
85
84
  function Listing({ where, up, error, loading, rows, file, body, }) {
86
85
  return (_jsxs(YStack, { gap: "$2", children: [_jsxs(XStack, { gap: "$2", items: "center", children: [up ? (_jsx(XStack, { render: "button", "aria-label": "Up one directory", onPress: up, px: "$1", py: "$1", children: _jsx(SizableText, { size: "$1", color: "$soft", children: "Up" }) })) : null, _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: where })] }), error ? (_jsx(SizableText, { size: "$2", color: "$soft", children: error })) : loading ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "Reading\u2026" })) : rows.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "This directory is empty." })) : (rows.map((r) => (_jsxs(XStack, { render: "button", "aria-label": r.dir === false ? `Read ${r.name}` : `Open ${r.name}`, onPress: r.onPress, items: "center", gap: "$2", py: "$1", hoverStyle: { bg: '$hover' }, children: [r.dir === false ? _jsx(File, { size: 14 }) : r.dir ? _jsx(Folder, { size: 14 }) : _jsx(File, { size: 14, opacity: 0.6 }), _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, style: { textAlign: 'left' }, children: r.name })] }, r.key)))), file && body ? (_jsxs(YStack, { gap: "$1", pt: "$2", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: file }), _jsx(SizableText, { size: "$1", color: "$ink", style: monoWrap, children: body })] })) : null] }));
87
86
  }
88
- function Artifacts({ repo, branch, mode, project, pr, onEnvironment, }) {
87
+ export function Artifacts({ repo, branch, mode, project, site, pr, onEnvironment, }) {
89
88
  const host = useHost();
90
89
  const items = [];
90
+ if (site)
91
+ items.push({ key: 'site', icon: _jsx(Globe, { size: 16 }), title: site.replace(/^https:\/\//, ''), note: 'Where this run is published', href: site });
91
92
  if (pr.href)
92
93
  items.push({ key: 'pr', icon: _jsx(GitPullRequest, { size: 16 }), title: `Pull request ${pr.label}`, note: 'Opened on the forge', href: pr.href });
93
94
  if (branch)
package/lib/grip.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A column's free edge, which is also how wide the column is: a vertical
3
+ * separator a person drags, or moves with the arrow keys, to size the column
4
+ * beside it. The project workspace sizes its chat with it, and a host sizes its
5
+ * own column with it (the Hanzo app's sidebar).
6
+ *
7
+ * side the edge of the window the column is pinned to: `left` grows
8
+ * rightwards (a sidebar, the chat), `right` grows leftwards (an
9
+ * aside). One control for both, the arithmetic reflected.
10
+ * span the column's width now, px; `floor` and `ceil` bound it.
11
+ * onSpan the width while it moves, at most once a frame.
12
+ * onKeep the width it settled on: a drag let go, a key, a double-click.
13
+ * onShut when given, a drag pulled well past the floor, or a key pressed
14
+ * at it, puts the column away; absent, the floor holds.
15
+ * reset the width a double-click returns to.
16
+ *
17
+ * Keys: the arrows step 8px (32px with Shift), Home and End go to the floor and
18
+ * the ceiling. Pointer events, so a mouse, a trackpad, a touchscreen and a pen
19
+ * are one path, with the pointer captured: a drag that outruns the handle, or
20
+ * crosses a framed page, keeps sizing. The drag is measured from the column as
21
+ * drawn when it begins, so the edge never jumps to the cursor.
22
+ *
23
+ * It sits inside the column, absolutely, on the column's free edge; the rest of
24
+ * its props place it elsewhere (a gutter beside the column) and win.
25
+ */
26
+ import { YStack } from '@hanzo/gui';
27
+ import { type ComponentProps } from 'react';
28
+ export interface GripProps extends Omit<ComponentProps<typeof YStack>, 'children'> {
29
+ side: 'left' | 'right';
30
+ span: number;
31
+ floor: number;
32
+ ceil: number;
33
+ reset: number;
34
+ onSpan: (n: number) => void;
35
+ onKeep?: (n: number) => void;
36
+ onShut?: () => void;
37
+ /** The separator's accessible name: "Resize sidebar", "Resize the chat". */
38
+ label: string;
39
+ }
40
+ export declare function Grip({ side, span, floor, ceil, reset, onSpan, onKeep, onShut, label, ...rest }: GripProps): import("react").JSX.Element;
package/lib/grip.js ADDED
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * A column's free edge, which is also how wide the column is: a vertical
4
+ * separator a person drags, or moves with the arrow keys, to size the column
5
+ * beside it. The project workspace sizes its chat with it, and a host sizes its
6
+ * own column with it (the Hanzo app's sidebar).
7
+ *
8
+ * side the edge of the window the column is pinned to: `left` grows
9
+ * rightwards (a sidebar, the chat), `right` grows leftwards (an
10
+ * aside). One control for both, the arithmetic reflected.
11
+ * span the column's width now, px; `floor` and `ceil` bound it.
12
+ * onSpan the width while it moves, at most once a frame.
13
+ * onKeep the width it settled on: a drag let go, a key, a double-click.
14
+ * onShut when given, a drag pulled well past the floor, or a key pressed
15
+ * at it, puts the column away; absent, the floor holds.
16
+ * reset the width a double-click returns to.
17
+ *
18
+ * Keys: the arrows step 8px (32px with Shift), Home and End go to the floor and
19
+ * the ceiling. Pointer events, so a mouse, a trackpad, a touchscreen and a pen
20
+ * are one path, with the pointer captured: a drag that outruns the handle, or
21
+ * crosses a framed page, keeps sizing. The drag is measured from the column as
22
+ * drawn when it begins, so the edge never jumps to the cursor.
23
+ *
24
+ * It sits inside the column, absolutely, on the column's free edge; the rest of
25
+ * its props place it elsewhere (a gutter beside the column) and win.
26
+ */
27
+ import { YStack } from '@hanzo/gui';
28
+ import { useEffect, useRef, useState } from 'react';
29
+ /** A key's step, px; with Shift, the long one. */
30
+ const STEP = 8;
31
+ const LONG = 32;
32
+ /** How far past the floor a drag is pulled before it shuts the column, px: a deliberate pull, not a twitch. */
33
+ const SLACK = 48;
34
+ export function Grip({ side, span, floor, ceil, reset, onSpan, onKeep, onShut, label, ...rest }) {
35
+ // The pinned edge in client coordinates, taken as the drag begins.
36
+ const held = useRef(0);
37
+ // Whether a press holds the pointer and whether it has moved since, the width
38
+ // it last asked for, and the frame that will draw it. A press that never moves
39
+ // keeps nothing.
40
+ const drag = useRef('none');
41
+ const want = useRef(span);
42
+ const frame = useRef(0);
43
+ const [hovered, setHovered] = useState(false);
44
+ const [keyed, setKeyed] = useState(false);
45
+ const [moving, setMoving] = useState(false);
46
+ useEffect(() => () => cancelAnimationFrame(frame.current), []);
47
+ const clamp = (n) => Math.round(Math.min(ceil, Math.max(floor, n)));
48
+ const settle = (n) => {
49
+ onSpan(n);
50
+ onKeep?.(n);
51
+ };
52
+ const stop = () => {
53
+ drag.current = 'none';
54
+ cancelAnimationFrame(frame.current);
55
+ frame.current = 0;
56
+ setMoving(false);
57
+ };
58
+ const wider = side === 'left' ? 'ArrowRight' : 'ArrowLeft';
59
+ const narrower = side === 'left' ? 'ArrowLeft' : 'ArrowRight';
60
+ return (_jsx(YStack, { role: "separator", "aria-orientation": "vertical", "aria-label": label, "aria-valuenow": Math.round(span), "aria-valuemin": floor, "aria-valuemax": ceil, tabIndex: 0, "data-slot": "grip", "data-moving": moving ? '' : undefined, position: "absolute", t: 0, b: 0, ...(side === 'left' ? { r: 0 } : { l: 0 }), width: 8, z: 10, items: "center", cursor: "col-resize", outlineWidth: 0, style: { touchAction: 'none' }, onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false),
61
+ // The line is the focus ring, drawn for a keyboard's focus and not a pointer's.
62
+ onFocus: (e) => setKeyed(e.currentTarget.matches(':focus-visible')), onBlur: () => setKeyed(false), onPointerDown: (e) => {
63
+ if (e.button !== 0)
64
+ return;
65
+ // No text selection starts under a drag.
66
+ e.preventDefault();
67
+ e.currentTarget.setPointerCapture(e.pointerId);
68
+ const drawn = e.currentTarget.parentElement?.getBoundingClientRect().width ?? span;
69
+ held.current = side === 'left' ? e.clientX - drawn : e.clientX + drawn;
70
+ want.current = clamp(drawn);
71
+ drag.current = 'held';
72
+ setMoving(true);
73
+ }, onPointerMove: (e) => {
74
+ if (drag.current === 'none')
75
+ return;
76
+ drag.current = 'moved';
77
+ const pulled = side === 'left' ? e.clientX - held.current : held.current - e.clientX;
78
+ if (onShut && pulled < floor - SLACK) {
79
+ stop();
80
+ e.currentTarget.releasePointerCapture(e.pointerId);
81
+ onShut();
82
+ return;
83
+ }
84
+ want.current = clamp(pulled);
85
+ if (frame.current)
86
+ return;
87
+ frame.current = requestAnimationFrame(() => {
88
+ frame.current = 0;
89
+ onSpan(want.current);
90
+ });
91
+ },
92
+ // A drag ends when the pointer is let go: lifted, or taken by the browser.
93
+ onLostPointerCapture: () => {
94
+ const moved = drag.current === 'moved';
95
+ stop();
96
+ if (moved)
97
+ settle(want.current);
98
+ }, onDoubleClick: () => settle(clamp(reset)), onKeyDown: (e) => {
99
+ const step = e.shiftKey ? LONG : STEP;
100
+ const to = e.key === wider ? span + step : e.key === narrower ? span - step : e.key === 'Home' ? floor : e.key === 'End' ? ceil : null;
101
+ if (to === null)
102
+ return;
103
+ e.preventDefault?.();
104
+ if (onShut && to < floor)
105
+ return onShut();
106
+ settle(clamp(to));
107
+ }, ...rest, children: _jsx(YStack, { width: 2, height: "100%", rounded: 1, bg: keyed || moving ? '$outlineColor' : hovered ? '$rim' : 'transparent', pointerEvents: "none" }) }));
108
+ }
package/lib/index.d.ts CHANGED
@@ -7,12 +7,14 @@
7
7
  * of its own mounts it with `rail={false}` and draws the builder's parts in that
8
8
  * rail: its places (`nav`), its runs (`useSessions`, or `DevSection` for both),
9
9
  * and at the foot the Slack card and the account menu (`Slack`, `Who`), under
10
- * `HostProvider`.
10
+ * `HostProvider`. `Grip` sizes that rail: the edge the builder sizes its own
11
+ * chat with.
11
12
  */
12
13
  export { Builder, nav } from './builder.tsx';
13
14
  export { DevSection, DOTS, useSessions } from './section.tsx';
14
15
  export { HostProvider } from './host.tsx';
15
16
  export { Slack, useWho, Who } from './foot.tsx';
17
+ export { Grip, type GripProps } from './grip.tsx';
16
18
  export { administers } from './claims.ts';
17
19
  export type { Host, Person } from './host.tsx';
18
20
  export type { Session, Status } from './api/sessions.ts';
package/lib/index.js CHANGED
@@ -7,11 +7,13 @@
7
7
  * of its own mounts it with `rail={false}` and draws the builder's parts in that
8
8
  * rail: its places (`nav`), its runs (`useSessions`, or `DevSection` for both),
9
9
  * and at the foot the Slack card and the account menu (`Slack`, `Who`), under
10
- * `HostProvider`.
10
+ * `HostProvider`. `Grip` sizes that rail: the edge the builder sizes its own
11
+ * chat with.
11
12
  */
12
13
  export { Builder, nav } from './builder.js';
13
14
  export { DevSection, DOTS, useSessions } from './section.js';
14
15
  export { HostProvider } from './host.js';
15
16
  export { Slack, useWho, Who } from './foot.js';
17
+ export { Grip } from './grip.js';
16
18
  export { administers } from './claims.js';
17
19
  export { path, route, SESSION, SLUG } from './route.js';
package/lib/project.js CHANGED
@@ -3,13 +3,19 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  * A project's workspace, in the same window: the chat on the left, what it
4
4
  * built on the right.
5
5
  *
6
- * bar mark · project · history · chat toggle | Preview Files Code Layers ·
7
- * device · reload · page · open | Share · Publish
6
+ * bar mark · project · history · chat toggle | Preview Files Code Layers |
7
+ * Share · Publish
8
8
  * left the project's runs as a conversation, suggestions, and the ask
9
- * right the deployed page, framed; the repository at the run's branch as a
10
- * tree and as read-only code; the element a person picked
9
+ * right the deployed page, framed under its own bar (reload · page · device
10
+ * · open), and kept loaded while another view is open; the repository
11
+ * at the run's branch as a tree and as read-only code; the element a
12
+ * person picked
11
13
  * dock the run's console — its steps and its log, as they stream
12
14
  *
15
+ * The edge between left and right is dragged, or moved with the arrow keys, to
16
+ * size the chat, and the width is kept per browser; the right side keeps room
17
+ * for a page whatever the chat asked for.
18
+ *
13
19
  * A chat turn IS a coding run: `POST /v1/agent/coding` with the project, the
14
20
  * mode, and the previous run as `after`, so the next ask builds on the branch
15
21
  * the last one pushed. Files and Code read native git at that branch. The
@@ -41,6 +47,7 @@ import { outcome, pull, said, who } from './api/turn.js';
41
47
  import { verdict as record } from './api/verdict.js';
42
48
  import { useKept, useProjects, useRead, useRun } from './data.js';
43
49
  import { Merge } from './git.js';
50
+ import { Grip } from './grip.js';
44
51
  import { useHost, useTarget } from './host.js';
45
52
  import { MODES } from './landing.js';
46
53
  import { Out } from './out.js';
@@ -49,6 +56,14 @@ import { Attach, compose, Dictate, Files } from './tools.js';
49
56
  const LIVE = new Set(['running', 'paused']);
50
57
  /** How often a project being published is read again, ms. */
51
58
  const AGAIN = 5000;
59
+ /** The chat's width beside the work, px: its default, and the two it is kept between. */
60
+ const CHAT_WIDTH = 360;
61
+ const CHAT_FLOOR = 300;
62
+ const CHAT_CEIL = 640;
63
+ /** The Workspace's gutter between the chat and the work, and its margin right of the work, px ($3). */
64
+ const GUTTER = 12;
65
+ /** What the work keeps beside the chat however wide the chat was asked to be, px. */
66
+ const WORK = 400 + 2 * GUTTER;
52
67
  /** One run in the conversation: what was asked, and what it came to. */
53
68
  function Turn({ run, open, onOpen, project }) {
54
69
  const t = useTarget();
@@ -95,6 +110,11 @@ export function Project({ slug }) {
95
110
  const [view, setView] = useKept(`hanzo.build.view.${slug}`, 'preview');
96
111
  const [device, setDevice] = useState('desktop');
97
112
  const [collapsed, setCollapsed] = useKept('hanzo.build.chat', false);
113
+ const [split, setSplit] = useKept('hanzo.build.split', CHAT_WIDTH);
114
+ // The workspace's width, which bounds the chat so the work keeps its room.
115
+ const [room, setRoom] = useState(0);
116
+ const most = room ? Math.max(CHAT_FLOOR, Math.min(CHAT_CEIL, room - WORK)) : CHAT_CEIL;
117
+ const across = Math.min(most, Math.max(CHAT_FLOOR, split));
98
118
  // Below md the chat and the work take turns, and Chat is one more view to switch back to.
99
119
  const [pane, setPane] = useState('chat');
100
120
  const wide = useMedia().md;
@@ -284,7 +304,7 @@ export function Project({ slug }) {
284
304
  if (!all.loading && !project && !all.error) {
285
305
  return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", px: "$4", children: [_jsx(SizableText, { size: "$4", color: "$ink", children: host.org ? `${host.org} has no project named ${slug}.` : `There is no project named ${slug}.` }), _jsx(Button, { variant: "outline", onPress: () => host.go('-/artifacts'), children: "See what this organization has built" })] }));
286
306
  }
287
- const chat = (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$3", py: "$4", gap: "$4", children: ordered.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$ink", px: "$2", children: runs.loading
307
+ const chat = (_jsxs(YStack, { flex: 1, minH: 0, position: "relative", children: [_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$3", py: "$4", gap: "$4", children: ordered.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$ink", px: "$2", children: runs.loading
288
308
  ? 'Reading this project’s runs…'
289
309
  : project?.live
290
310
  ? `${project.name} is loaded — it is in the preview, and every run on it is under the clock above. Say what to change and it gets built.`
@@ -296,25 +316,28 @@ export function Project({ slug }) {
296
316
  ? `${project.name} is loaded and nothing is published yet. Say what to change and it gets built.`
297
317
  : `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] }), _jsx(Merge, { session: r.id, open: proposing, onMerged: merged })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { px: "$3", pb: "$3", gap: "$2", children: [!dismissed && !running ? _jsx(Suggestions, { items: SUGGESTIONS, onPick: (s) => void send(s), onDismiss: () => setDismissed(true) }) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsxs(Composer, { value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
298
318
  ? () => void stop(t, current).then(() => setNote('Stop requested — the run keeps its work on its branch'), (e) => setNote(e.message))
299
- : undefined, busy: busy || (running && !draft.trim()), rows: 3, placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", children: [_jsx(Attach, { files: attached, onFiles: setAttached, onNote: setNote }), _jsx(XStack, { flex: 1 }), _jsx(ModeSelect, { modes: MODES, value: mode, onChange: (m) => setMode(m), self: "center" }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote })] })] })] }));
300
- const body = view === 'preview' ? (_jsx(PreviewFrame, { ref: frame, src: src, title: `${project?.name ?? slug} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: building ? (project?.forked ? 'Publishing your copy…' : 'Publishing…') : failed ? 'Publishing failed' : 'Nothing deployed yet' }), _jsx(SizableText, { size: "$2", color: "$soft", text: "center", children: building ? 'Its page appears here when the build finishes.' : failed ? why : 'Publish this project and its page appears here.' })] }), toolbar: bridge ? (_jsx(XStack, { render: "button", "aria-pressed": editing, "aria-label": editing ? 'Stop picking elements' : 'Pick an element to edit', onPress: () => {
301
- const next = !editing;
302
- setEditing(next);
303
- frame.current?.post({ type: 'preview:editable', active: next });
304
- }, p: "$2", rounded: "$10", children: _jsx(MousePointerClick, { size: 16 }) })) : undefined })) : view === 'files' ? (repo ? (_jsx(FileTree, { load: async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? 'dir' : 'file' })), value: file, onSelect: (p) => void openFile(p), label: `${repo} at ${ref}` }, round)) : (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", children: "This project has no repository yet. Its first run creates one." }))) : view === 'code' ? (_jsx(FileTabs, { files: files, value: file, onSelect: setFile, onClose: (p) => {
319
+ : undefined, busy: busy || (running && !draft.trim()), rows: 3, placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", children: [_jsx(Attach, { files: attached, onFiles: setAttached, onNote: setNote }), _jsx(XStack, { flex: 1 }), _jsx(ModeSelect, { modes: MODES, value: mode, onChange: (m) => setMode(m), self: "center" }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote })] })] }), wide && !collapsed ? (_jsx(Grip, { side: "left", span: across, floor: CHAT_FLOOR, ceil: most, reset: CHAT_WIDTH, onSpan: setSplit, label: "Resize the chat", r: -GUTTER, width: GUTTER })) : null] }));
320
+ // The preview stays loaded while another view is open, so coming back to it
321
+ // is the page as it was left.
322
+ const preview = (_jsxs(YStack, { "data-slot": "preview", flex: 1, minH: 0, minW: 0, gap: "$2", display: view === 'preview' ? 'flex' : 'none', children: [_jsxs(XStack, { "data-slot": "preview-bar", items: "center", gap: "$1.5", minW: 0, shrink: 0, "$max-md": { px: '$2' }, children: [_jsx(XStack, { render: "button", "aria-label": "Reload the preview", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 16 }) }), _jsx(PageSelect, { pages: pages, value: page, onChange: setPage, shrink: 1 }), _jsx(XStack, { flex: 1 }), _jsx(Views, { views: DEVICES, value: device, onChange: (d) => setDevice(d), label: "Device", labels: "none" }), src ? (_jsx(Out, { href: src, label: "Open in a new tab", children: _jsx(XStack, { p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 16 }) }) })) : null] }), _jsx(PreviewFrame, { ref: frame, src: src, title: `${project?.name ?? slug} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: building ? (project?.forked ? 'Publishing your copy…' : 'Publishing…') : failed ? 'Publishing failed' : 'Nothing deployed yet' }), _jsx(SizableText, { size: "$2", color: "$soft", text: "center", children: building ? 'Its page appears here when the build finishes.' : failed ? why : 'Publish this project and its page appears here.' })] }), toolbar: bridge ? (_jsx(XStack, { render: "button", "aria-pressed": editing, "aria-label": editing ? 'Stop picking elements' : 'Pick an element to edit', onPress: () => {
323
+ const next = !editing;
324
+ setEditing(next);
325
+ frame.current?.post({ type: 'preview:editable', active: next });
326
+ }, p: "$2", rounded: "$10", children: _jsx(MousePointerClick, { size: 16 }) })) : undefined })] }));
327
+ const body = view === 'preview' ? null : view === 'files' ? (repo ? (_jsx(FileTree, { load: async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? 'dir' : 'file' })), value: file, onSelect: (p) => void openFile(p), label: `${repo} at ${ref}` }, round)) : (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", children: "This project has no repository yet. Its first run creates one." }))) : view === 'code' ? (_jsx(FileTabs, { files: files, value: file, onSelect: setFile, onClose: (p) => {
305
328
  setFiles((was) => was.filter((f) => f.path !== p));
306
329
  if (file === p)
307
330
  setFile(null);
308
331
  }, empty: _jsxs(SizableText, { size: "$2", color: "$soft", p: "$4", children: ["Open a file from Files. Code here is read at ", ref, "; ask in the chat to change it."] }) })) : (_jsx(YStack, { p: "$4", gap: "$2", children: picked.length ? (picked.map((p) => (_jsxs(SizableText, { size: "$2", color: "$ink", children: [p.label, " \u2014 ", p.id] }, p.id)))) : (_jsx(SizableText, { size: "$2", color: "$soft", children: bridge
309
332
  ? 'Pick an element in the preview to see it here and attach it to your next ask.'
310
333
  : 'Layers are read through the preview bridge, and this page does not run it. Its elements cannot be listed from here.' })) }));
311
- return (_jsxs(_Fragment, { children: [_jsx(Workspace, { start: _jsxs(XStack, { items: "center", gap: "$2", minW: 0, shrink: 1, children: [_jsx(XStack, { render: "button", "aria-label": "All runs", onPress: () => host.go(''), p: "$1", shrink: 0, children: _jsx(HanzoMark, { size: 20 }) }), _jsx(ProjectChip, { name: project?.name ?? slug, onPress: () => setSwitcher(true) }), _jsx(XStack, { render: "button", "aria-label": "History", onPress: () => setHistory(true), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, children: _jsx(Clock, { size: 16 }) }), _jsx(XStack, { render: "button", "aria-label": collapsed ? 'Show the chat' : 'Hide the chat', "aria-pressed": !collapsed, onPress: () => setCollapsed(!collapsed), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, "$max-md": { display: 'none' }, children: _jsx(PanelLeft, { size: 16 }) })] }), middle: _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(Views, { views: [...VIEWS, CHAT], value: pane === 'chat' && !wide ? 'chat' : view, onChange: (v) => {
312
- if (v === 'chat')
313
- return setPane('chat');
314
- setView(v);
315
- setPane('view');
316
- }, label: "View" }), _jsx(Views, { views: DEVICES, value: device, onChange: (d) => setDevice(d), label: "Device", labels: "none" }), _jsx(XStack, { render: "button", "aria-label": "Reload the preview", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 16 }) }), _jsx(PageSelect, { pages: pages, value: page, onChange: setPage }), src ? (_jsx(Out, { href: src, label: "Open in a new tab", children: _jsx(XStack, { p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 16 }) }) })) : null] }), end: _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(XStack, { display: "none", $md: { display: 'flex' }, children: _jsxs(Button, { variant: "outline", size: "sm", onPress: () => void share(), children: [_jsx(Share2, { size: 14 }), " Share"] }) }), _jsxs(Button, { size: "sm", disabled: !project?.repo, onPress: () => project &&
317
- setPublish({ repo: project.repo, title: project.name, ref, name: project.slug, project: project.slug }), children: [_jsx(Upload, { size: 14 }), " Publish"] })] }), chat: chat, dock: _jsx(Console, { lines: lines, height: dock, onHeight: setDock, onClear: () => setPageLines([]), status: run.status || end.status || undefined, label: "Console" }), collapsed: collapsed, pane: pane, children: body }), _jsx(Dialog, { open: history, onOpenChange: setHistory, children: _jsxs(DialogContent, { maxW: 480, children: [_jsxs(DialogTitle, { children: ["Runs on ", project?.name ?? slug] }), _jsx(YStack, { gap: "$1", children: runs.value.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "No runs yet." })) : (runs.value.map((r) => (_jsxs(XStack, { render: "button", onPress: () => {
334
+ return (_jsxs(_Fragment, { children: [_jsxs(Workspace, { start: _jsxs(XStack, { items: "center", gap: "$2", minW: 0, shrink: 1, children: [_jsx(XStack, { render: "button", "aria-label": "All runs", onPress: () => host.go(''), p: "$1", shrink: 0, children: _jsx(HanzoMark, { size: 20 }) }), _jsx(ProjectChip, { name: project?.name ?? slug, onPress: () => setSwitcher(true) }), _jsx(XStack, { render: "button", "aria-label": "History", onPress: () => setHistory(true), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, children: _jsx(Clock, { size: 16 }) }), _jsx(XStack, { render: "button", "aria-label": collapsed ? 'Show the chat' : 'Hide the chat', "aria-pressed": !collapsed, onPress: () => setCollapsed(!collapsed), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, "$max-md": { display: 'none' }, children: _jsx(PanelLeft, { size: 16 }) })] }), middle: _jsx(Views, { views: [...VIEWS, CHAT], value: pane === 'chat' && !wide ? 'chat' : view, onChange: (v) => {
335
+ if (v === 'chat')
336
+ return setPane('chat');
337
+ setView(v);
338
+ setPane('view');
339
+ }, label: "View" }), end: _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(XStack, { display: "none", $md: { display: 'flex' }, children: _jsxs(Button, { variant: "outline", size: "sm", onPress: () => void share(), children: [_jsx(Share2, { size: 14 }), " Share"] }) }), _jsxs(Button, { size: "sm", disabled: !project?.repo, onPress: () => project &&
340
+ setPublish({ repo: project.repo, title: project.name, ref, name: project.slug, project: project.slug }), children: [_jsx(Upload, { size: 14 }), " Publish"] })] }), chat: chat, dock: _jsx(Console, { lines: lines, height: dock, onHeight: setDock, onClear: () => setPageLines([]), status: run.status || end.status || undefined, label: "Console" }), collapsed: collapsed, pane: pane, width: across, onLayout: (e) => setRoom(e.nativeEvent.layout.width), children: [preview, body] }), _jsx(Dialog, { open: history, onOpenChange: setHistory, children: _jsxs(DialogContent, { maxW: 480, children: [_jsxs(DialogTitle, { children: ["Runs on ", project?.name ?? slug] }), _jsx(YStack, { gap: "$1", children: runs.value.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "No runs yet." })) : (runs.value.map((r) => (_jsxs(XStack, { render: "button", onPress: () => {
318
341
  setChosen(r.id);
319
342
  setHistory(false);
320
343
  }, justify: "space-between", px: "$2", py: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, children: r.title || 'Untitled run' }), _jsx(SizableText, { size: "$1", color: "$soft", children: r.status })] }, r.id)))) })] }) }), _jsx(Dialog, { open: switcher, onOpenChange: setSwitcher, children: _jsxs(DialogContent, { maxW: 420, children: [_jsx(DialogTitle, { children: "Projects" }), _jsx(YStack, { gap: "$1", maxH: 420, overflow: "scroll", children: all.value.map((p) => (_jsx(XStack, { render: "button", onPress: () => {
package/lib/run.js CHANGED
@@ -35,7 +35,7 @@ import { ENSO, label as named, models } from './api/models.js';
35
35
  import { list, message, pause, publish, rename, resume, stop, story, took } from './api/sessions.js';
36
36
  import { answer, cards, outcome, pull, settled, steps } from './api/turn.js';
37
37
  import { verdict } from './api/verdict.js';
38
- import { useKept, useRead, useRun } from './data.js';
38
+ import { useKept, useProjects, useRead, useRun } from './data.js';
39
39
  import { Desk } from './desk.js';
40
40
  import { useHost, useTarget } from './host.js';
41
41
  import { EFFORTS } from './landing.js';
@@ -74,6 +74,15 @@ export function Run({ id }) {
74
74
  const plan = useMemo(() => steps(events), [events]);
75
75
  const kept = useMemo(() => settled(events), [events]);
76
76
  const state = status || end.status;
77
+ // Where the run's work is published: its project's own address, read again when
78
+ // the run says it went live, never an address an event names.
79
+ const built = useProjects(t, signed && Boolean(record?.project));
80
+ const rebuilt = built.reload;
81
+ useEffect(() => {
82
+ if (end.published)
83
+ rebuilt();
84
+ }, [end.published, rebuilt]);
85
+ const site = built.value.find((p) => p.slug === record?.project)?.live ?? '';
77
86
  const pr = pull(record?.pr ?? '', record?.repo ?? '');
78
87
  const live = LIVE.has(state);
79
88
  const running = signed && live && !detail.error;
@@ -290,7 +299,7 @@ export function Run({ id }) {
290
299
  : 'This run is still working.' }), _jsx(Steer, { onCommand: command, withhold: [
291
300
  ...(paused ? ['pause'] : ['resume']),
292
301
  ...(busy || waiting ? ['pause', 'resume', 'stop'] : []),
293
- ] }), _jsx(Button, { size: "sm", disabled: notify, onPress: () => void ask(), children: notify ? 'You will be notified' : 'Notify me' })] })) : null, _jsx(Composer, { inline: true, value: draft, onChange: setDraft, onSend: send, disabled: !signed || busy || waiting || !(running || finished), placeholder: placeholder, label: steering ? 'Steer this run' : 'Follow up on this run', foot: tuning }), _jsx(SizableText, { size: "$1", color: "$soft", style: { textAlign: 'center' }, children: "Hanzo is AI and can make mistakes." })] })] }), desk ? (_jsx(Desk, { id: id, repo: record?.repo ?? '', branch: record?.branch ?? '', base: record?.base ?? '', environment: record?.environment ?? '', mode: mode, pr: pr, title: title, project: record?.project ?? '', sandbox: record?.sandbox ?? '', events: events, live: running, menu: [...manage, ...sharing], refused: signed ? refused || (detail.error ? detail.error.message : '') : 'Sign in to follow this run.', retry: signed ? 'Retry' : 'Sign in', onRetry: signed ? detail.reload : () => host.signIn?.(), onHide: () => setDesk(false) })) : null, _jsx(Dialog, { open: naming, onOpenChange: setNaming, children: _jsxs(DialogContent, { maxW: 480, children: [_jsx(DialogTitle, { children: "Rename this run" }), _jsx(Input, { autoFocus: true, value: name, onChangeText: setName, "aria-label": "The run\u2019s name", maxLength: 512, onKeyDown: (e) => {
302
+ ] }), _jsx(Button, { size: "sm", disabled: notify, onPress: () => void ask(), children: notify ? 'You will be notified' : 'Notify me' })] })) : null, _jsx(Composer, { inline: true, value: draft, onChange: setDraft, onSend: send, disabled: !signed || busy || waiting || !(running || finished), placeholder: placeholder, label: steering ? 'Steer this run' : 'Follow up on this run', foot: tuning }), _jsx(SizableText, { size: "$1", color: "$soft", style: { textAlign: 'center' }, children: "Hanzo is AI and can make mistakes." })] })] }), desk ? (_jsx(Desk, { id: id, repo: record?.repo ?? '', branch: record?.branch ?? '', base: record?.base ?? '', environment: record?.environment ?? '', mode: mode, pr: pr, title: title, project: record?.project ?? '', sandbox: record?.sandbox ?? '', site: site, published: end.published, events: events, live: running, menu: [...manage, ...sharing], refused: signed ? refused || (detail.error ? detail.error.message : '') : 'Sign in to follow this run.', retry: signed ? 'Retry' : 'Sign in', onRetry: signed ? detail.reload : () => host.signIn?.(), onHide: () => setDesk(false) })) : null, _jsx(Dialog, { open: naming, onOpenChange: setNaming, children: _jsxs(DialogContent, { maxW: 480, children: [_jsx(DialogTitle, { children: "Rename this run" }), _jsx(Input, { autoFocus: true, value: name, onChangeText: setName, "aria-label": "The run\u2019s name", maxLength: 512, onKeyDown: (e) => {
294
303
  if (e.key === 'Enter')
295
304
  save();
296
305
  } }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", onPress: () => setNaming(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: busy || !name.trim(), onPress: save, children: "Save" })] })] }) })] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",