@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.
- package/lib/api/harness.d.ts +11 -0
- package/lib/api/harness.js +182 -2
- package/lib/api/turn.d.ts +5 -0
- package/lib/api/turn.js +17 -1
- package/lib/desk.d.ts +5 -1
- package/lib/desk.js +22 -11
- package/lib/files.d.ts +7 -1
- package/lib/files.js +11 -10
- package/lib/grip.d.ts +40 -0
- package/lib/grip.js +108 -0
- package/lib/index.d.ts +3 -1
- package/lib/index.js +3 -1
- package/lib/project.js +41 -18
- package/lib/run.js +11 -2
- package/package.json +1 -1
package/lib/api/harness.d.ts
CHANGED
|
@@ -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
|
package/lib/api/harness.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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:
|
|
4
|
-
*
|
|
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 {
|
|
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
|
|
19
|
-
//
|
|
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: "
|
|
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,
|
|
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 {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* A run's Files tab
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* request, its branch, its environment proposal and
|
|
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,
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
10
|
-
*
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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: [
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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.
|
|
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.",
|