@hanzo/build 0.2.60 → 0.2.62

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/run.js CHANGED
@@ -25,12 +25,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
25
25
  */
26
26
  import { SizableText, useMedia, XStack, YStack } from '@hanzo/gui';
27
27
  import { ChevronDown, ChevronRight, Clock, Copy, ExternalLink, GitPullRequest, Globe, Pencil, Share } from '@hanzogui/lucide-icons-2';
28
- import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, Input } from '@hanzo/ui';
28
+ import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, Grip, Input } from '@hanzo/ui';
29
29
  import { Steer } from '@hanzo/ui/agents';
30
30
  import { Failure } from '@hanzo/ui/chat';
31
31
  import { useLimits } from '@hanzo/ui/product/useLimits';
32
32
  import { useDictation, useVoice } from '@hanzo/voice';
33
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
33
+ import { useEffect, useMemo, useState } from 'react';
34
34
  import { approve, followUp, headline, retry, start } from './api/coding.js';
35
35
  import { limits as readLimits } from './api/models.js';
36
36
  import { list, message, pause, publish, rename, resume, stop, story, took } from './api/sessions.js';
@@ -38,7 +38,6 @@ import { answer, cards, outcome, pull, settled, steps } from './api/turn.js';
38
38
  import { verdict } from './api/verdict.js';
39
39
  import { useKept, useProjects, useRead, useRun } from './data.js';
40
40
  import { Desk, SEED } from './desk.js';
41
- import { Grip } from './grip.js';
42
41
  import { useHost, useTarget } from './host.js';
43
42
  import { useMind } from './mind.js';
44
43
  import { Out } from './out.js';
@@ -48,35 +47,11 @@ import { useSpoken } from './voice.js';
48
47
  import { speaker } from './reply.js';
49
48
  import { PanelToggle } from './panel.js';
50
49
  import { schedule } from './scheduled.js';
50
+ import { SideProvider, useSplit } from './side.js';
51
51
  import { PAGE, useDeck, useKey, usePanel } from './tabs.js';
52
52
  import { compose, Files, pick } from './tools.js';
53
53
  import { Transcript } from './transcript.js';
54
54
  const LIVE = new Set(['running', 'paused', '']);
55
- /** The side panel's width, px: its default, the floor a drag holds, and what the transcript keeps beside it. */
56
- const DESK_WIDTH = 480;
57
- const DESK_FLOOR = 240;
58
- const TALK_FLOOR = 300;
59
- /** The most the side panel is drawn at before its row is measured, px. */
60
- const DESK_CEIL = 960;
61
- /**
62
- * How wide an element is drawn, px, read before the first paint and again as it
63
- * changes, so what depends on it is right on the first frame. 0 until it is drawn.
64
- */
65
- function useWidth() {
66
- const [width, setWidth] = useState(0);
67
- const [el, setEl] = useState(null);
68
- useLayoutEffect(() => {
69
- if (!el)
70
- return;
71
- setWidth(el.getBoundingClientRect().width);
72
- if (typeof ResizeObserver === 'undefined')
73
- return;
74
- const watch = new ResizeObserver(([entry]) => setWidth(entry?.contentRect.width ?? 0));
75
- watch.observe(el);
76
- return () => watch.disconnect();
77
- }, [el]);
78
- return [width, (node) => setEl(node instanceof HTMLElement ? node : null)];
79
- }
80
55
  export function Run({ id }) {
81
56
  const host = useHost();
82
57
  const t = useTarget();
@@ -93,15 +68,8 @@ export function Run({ id }) {
93
68
  const [desk, setDesk] = usePanel('dev', true, wide);
94
69
  const side = useDeck('dev', SEED);
95
70
  useKey(() => setDesk(!desk));
96
- // The side panel's width is the reader's, kept, and gives way so the
97
- // transcript keeps its floor: bounded by the row it is drawn in once that is
98
- // measured, and by its own `maxWidth` until then.
99
- const [reach, setReach] = useKept('hanzo.build.desk.span', DESK_WIDTH);
100
- const [room, size] = useWidth();
101
- const most = room ? Math.max(DESK_FLOOR, room - TALK_FLOOR) : DESK_CEIL;
102
- const across = Math.round(Math.min(most, Math.max(DESK_FLOOR, typeof reach === 'number' ? reach : DESK_WIDTH)));
103
- // A drag repaints the pane's width directly and tells React once, on letting go.
104
- const deskBox = useRef(null);
71
+ // The side panel's column: the person's width, the run hidden behind it, full screen (side.tsx).
72
+ const split = useSplit(desk && wide, 'run', () => setDesk(false));
105
73
  // Stop was pressed here: the run keeps its work after it answers, and says so with its status.
106
74
  const [stopping, setStopping] = useState(false);
107
75
  const [naming, setNaming] = useState(false);
@@ -373,7 +341,7 @@ export function Run({ id }) {
373
341
  ? 'Follow up — continues in a new run'
374
342
  : 'Reading this run…';
375
343
  const desk_ = (sheet) => (_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?.(), side: side, sheet: sheet, onHide: () => setDesk(false) }));
376
- return (_jsxs(XStack, { ref: size, "data-slot": "run-split", flex: 1, minH: 0, minW: 0, width: "100%", children: [_jsxs(YStack, { "data-slot": "run", ...pane, flex: 1, minH: 0, minW: 0, width: "100%", children: [_jsxs(XStack, { px: "$4", pt: "$3", pb: "$2", gap: "$2", items: "center", minH: 44, children: [_jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", children: [_jsx(DropdownMenu, { trigger: _jsxs(XStack, { render: "button", "aria-label": "Manage this run", items: "center", gap: "$1.5", maxW: "100%", px: "$1.5", mx: "$-1.5", py: "$0.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { render: "h1", size: "$5", color: "$ink", numberOfLines: 1, minW: 0, children: title }), _jsx(ChevronDown, { size: 14 })] }), items: manage }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, maxW: "100%", children: [state || (detail.loading ? 'reading' : ''), record?.repo, record?.branch, record?.published ? 'shared' : ''].filter(Boolean).join(' · ') })] }), pr.href ? (_jsx(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: _jsxs(XStack, { items: "center", gap: "$1.5", px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsx(GitPullRequest, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: pr.label }), _jsx(ExternalLink, { size: 12, opacity: 0.6 })] }) })) : null, sharing.length ? (_jsx(DropdownMenu, { trigger: _jsxs(XStack, { render: "button", "aria-label": "Share", items: "center", gap: "$1.5", px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsx(Share, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Share" })] }), items: sharing })) : null, _jsx(PanelToggle, { open: desk, onToggle: () => setDesk(!desk) })] }), _jsx(Transcript, { cards: shown, onOpen: look, onDiff: () => {
344
+ return (_jsxs(XStack, { "data-slot": "run-split", flex: 1, minH: 0, minW: 0, width: "100%", children: [_jsxs(YStack, { "data-slot": "run", ref: split.main, style: split.mainStyle, ...pane, flex: 1, minH: 0, minW: 0, width: "100%", children: [_jsxs(XStack, { px: "$4", pt: "$3", pb: "$2", gap: "$2", items: "center", minH: 44, children: [_jsxs(YStack, { flex: 1, minW: 0, items: "flex-start", children: [_jsx(DropdownMenu, { trigger: _jsxs(XStack, { render: "button", "aria-label": "Manage this run", items: "center", gap: "$1.5", maxW: "100%", px: "$1.5", mx: "$-1.5", py: "$0.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { render: "h1", size: "$5", color: "$ink", numberOfLines: 1, minW: 0, children: title }), _jsx(ChevronDown, { size: 14 })] }), items: manage }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, maxW: "100%", children: [state || (detail.loading ? 'reading' : ''), record?.repo, record?.branch, record?.published ? 'shared' : ''].filter(Boolean).join(' · ') })] }), pr.href ? (_jsx(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: _jsxs(XStack, { items: "center", gap: "$1.5", px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsx(GitPullRequest, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: pr.label }), _jsx(ExternalLink, { size: 12, opacity: 0.6 })] }) })) : null, sharing.length ? (_jsx(DropdownMenu, { trigger: _jsxs(XStack, { render: "button", "aria-label": "Share", items: "center", gap: "$1.5", px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsx(Share, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Share" })] }), items: sharing })) : null, _jsx(PanelToggle, { open: desk, onToggle: () => setDesk(!desk) })] }), _jsx(Transcript, { cards: shown, onOpen: look, onDiff: () => {
377
345
  side.open({ kind: 'diff', title: 'Diff' });
378
346
  setDesk(true);
379
347
  }, listen: signed ? listen : undefined, end: failed ? (_jsx(Failure, { onRetry: busy ? undefined : () => void act(() => carry(again, 'retry')), children: end.problem ? 'This run stopped before it finished.' : 'This run stopped with an error. Its work so far is on its branch.' })) : null,
@@ -402,15 +370,17 @@ export function Run({ id }) {
402
370
  : 'This run is still working.' }), _jsx(Steer, { onCommand: command, withhold: [
403
371
  ...(paused ? ['pause'] : ['resume']),
404
372
  ...(busy || waiting ? ['pause', 'resume', 'stop'] : []),
405
- ] }), _jsx(Button, { size: "sm", disabled: notify, onPress: () => void ask(), children: notify ? 'You will be notified' : 'Notify me' })] })) : null] }), _jsx(Prompt, { framed: true, value: draft, onChange: setDraft, onSend: send, ready: Boolean(draft.trim()), disabled: !signed || busy || waiting || !(running || finished), placeholder: placeholder, label: steering ? 'Steer this run' : 'Follow up on this run', head: files.length ? _jsx(XStack, { gap: "$1.5", flexWrap: "wrap", children: _jsx(Files, { files: files, onFiles: setFiles }) }) : undefined, onAttach: () => pick(files, setFiles, setNote),
373
+ ] }), _jsx(Button, { size: "sm", disabled: notify, onPress: () => void ask(), children: notify ? 'You will be notified' : 'Notify me' })] })) : null] }), _jsx(Prompt, { framed: true, value: draft, onChange: setDraft, onSend: send, ready: Boolean(draft.trim()), disabled: !signed || busy || waiting || !(running || finished), placeholder: placeholder, label: steering ? 'Steer this run' : 'Follow up on this run', head: files.length ? _jsx(XStack, { gap: "$1.5", flexWrap: "wrap", children: _jsx(Files, { files: files, onFiles: setFiles }) }) : undefined, plus: { upload: () => pick(files, setFiles, setNote) },
406
374
  // What a new run from here is made with; words that steer this run use neither.
407
- mind: signed && !steering ? mind : null, limits: limits, voice: voice, note: "Hanzo is AI and can make mistakes." })] })] }), desk && !wide ? desk_(true) : null, desk && wide ? (_jsxs(YStack, { "data-slot": "desk", ref: (el) => {
408
- deskBox.current = el instanceof HTMLElement ? el : null;
409
- }, ...pane,
375
+ mind: signed && !steering ? mind : null, limits: limits, voice: voice, note: "Hanzo is AI and can make mistakes." })] })] }), desk && !wide ? desk_(true) : null, desk && wide ? (_jsxs(YStack, { "data-slot": "desk", ref: split.aside, ...pane,
410
376
  // A pane of its own beside the run's, the gutter between them.
411
- ml: gap, position: "relative", shrink: 0, minH: 0, style: { width: across, maxWidth: `calc(100% - ${TALK_FLOOR}px)`, minWidth: DESK_FLOOR }, children: [_jsx(Grip, { side: "right", span: across, floor: DESK_FLOOR, ceil: most, reset: Math.min(most, DESK_WIDTH), onSpan: (n) => deskBox.current?.style.setProperty('width', `${n}px`), onKeep: setReach, onShut: () => setDesk(false), label: "Resize the side panel",
377
+ ml: gap, position: "relative", shrink: 0, minH: 0, style: split.asideStyle, children: [split.grip ? (_jsx(Grip, { ...split.grip, label: "Resize the side panel", bar: "inline",
378
+ // The handle halfway down the edge, where a hand looks for it.
379
+ justify: "center",
412
380
  // In the gutter between the two panes; `left` is measured inside the pane's 1px edge.
413
- l: `calc(-1 * ${gap} - ${pane.borderWidth}px)`, width: gap }), desk_(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) => {
381
+ l: `calc(-1 * ${gap} - ${pane.borderWidth}px)`, width: gap,
382
+ // A finger's band, centred on the gutter.
383
+ $touchable: { l: `calc(-22px - ${gap} / 2 - ${pane.borderWidth}px)`, width: 44 } })) : null, _jsx(SideProvider, { value: split.side, children: desk_(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) => {
414
384
  if (e.key === 'Enter')
415
385
  save();
416
386
  } }), _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/lib/side.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The side panel's column beside a conversation or a run, once for Chat and
3
+ * Dev: how wide it is, the middle column it can take the room from, and full
4
+ * screen. The host draws the two columns and spreads what `useSplit` hands it
5
+ * on them and on the kit's `Grip`; the `Panel` inside reads `useSide` for its
6
+ * full-screen button and its way back to the middle column.
7
+ *
8
+ * width the person's, kept in their preferences (`side.width`, /v1/pref),
9
+ * so it follows them to every device and to both modes. Where no
10
+ * PrefsProvider holds them (hanzo.team's rooms) it lasts the page.
11
+ * Saved once a drag lets go, after a burst of keys has stopped.
12
+ * main the middle column keeps MAIN px. Pulled under half of that it is
13
+ * hidden, never unmounted, so the transcript and the composer keep
14
+ * their state, and the panel takes the room. Pulled back out, or the
15
+ * strip's Show button, brings it back at the width kept.
16
+ * full the panel over the whole window. Escape or the button returns.
17
+ *
18
+ * A DRAG NEVER DRAWS REACT. Each frame writes the panel's width and the middle
19
+ * column's display on the two elements (`paint`), the same properties React
20
+ * writes from the settled state (`look`), so the two never disagree; React
21
+ * learns the width once, when the drag lets go. The room the two share is
22
+ * measured by a ResizeObserver, after layout, so a frame never reads layout
23
+ * it has just dirtied. A window narrowed under the panel is painted the same
24
+ * way: the panel gives way, and the person's width comes back with the room.
25
+ */
26
+ import { type CSSProperties } from 'react';
27
+ /** What the panel's strip reads of the column it stands in. */
28
+ export interface Side {
29
+ /** What the middle column is called, in the button that brings it back: "chat", "run". */
30
+ main: string;
31
+ /** The middle column is hidden and the panel has the room. */
32
+ hidden: boolean;
33
+ show: () => void;
34
+ /** The panel is over the whole window. */
35
+ full: boolean;
36
+ fill: (on: boolean) => void;
37
+ }
38
+ /** Around the middle column's room and the panel: the panel's strip reads it. */
39
+ export declare const SideProvider: import("react").Provider<Side | null>;
40
+ /** The column the panel stands in, or null where it stands in none (a phone's sheet, a host's own column). */
41
+ export declare const useSide: () => Side | null;
42
+ export interface Split {
43
+ /** For `SideProvider`, around the middle column and the panel. */
44
+ side: Side;
45
+ /** The middle column's element and style. */
46
+ main: (el: unknown) => void;
47
+ mainStyle: CSSProperties;
48
+ /** The panel's column's element and style. */
49
+ aside: (el: unknown) => void;
50
+ asideStyle: CSSProperties;
51
+ /** The kit `Grip`'s props, but its label and its place; none while the panel is full screen. */
52
+ grip: {
53
+ side: 'right';
54
+ span: number;
55
+ floor: number;
56
+ ceil: number;
57
+ reset: number;
58
+ onSpan: (n: number) => void;
59
+ onKeep: (n: number) => void;
60
+ onShut: () => void;
61
+ } | null;
62
+ }
63
+ /**
64
+ * The column, for a host that draws it: `open` is whether the panel is drawn
65
+ * beside the middle column, `main` what that column is called, `onShut` puts
66
+ * the panel away (its edge pulled past its floor).
67
+ */
68
+ export declare function useSplit(open: boolean, main: string, onShut: () => void): Split;
package/lib/side.js ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The side panel's column beside a conversation or a run, once for Chat and
3
+ * Dev: how wide it is, the middle column it can take the room from, and full
4
+ * screen. The host draws the two columns and spreads what `useSplit` hands it
5
+ * on them and on the kit's `Grip`; the `Panel` inside reads `useSide` for its
6
+ * full-screen button and its way back to the middle column.
7
+ *
8
+ * width the person's, kept in their preferences (`side.width`, /v1/pref),
9
+ * so it follows them to every device and to both modes. Where no
10
+ * PrefsProvider holds them (hanzo.team's rooms) it lasts the page.
11
+ * Saved once a drag lets go, after a burst of keys has stopped.
12
+ * main the middle column keeps MAIN px. Pulled under half of that it is
13
+ * hidden, never unmounted, so the transcript and the composer keep
14
+ * their state, and the panel takes the room. Pulled back out, or the
15
+ * strip's Show button, brings it back at the width kept.
16
+ * full the panel over the whole window. Escape or the button returns.
17
+ *
18
+ * A DRAG NEVER DRAWS REACT. Each frame writes the panel's width and the middle
19
+ * column's display on the two elements (`paint`), the same properties React
20
+ * writes from the settled state (`look`), so the two never disagree; React
21
+ * learns the width once, when the drag lets go. The room the two share is
22
+ * measured by a ResizeObserver, after layout, so a frame never reads layout
23
+ * it has just dirtied. A window narrowed under the panel is painted the same
24
+ * way: the panel gives way, and the person's width comes back with the room.
25
+ */
26
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
27
+ import { usePrefs } from './prefs.js';
28
+ /** The panel's least width, px, and the width it opens at before the person has chosen one. */
29
+ const FLOOR = 240;
30
+ const WIDTH = 480;
31
+ /** The middle column's least width, px: under half of it, it is hidden and the panel takes the room. */
32
+ const MAIN = 360;
33
+ /** How long a burst of keys is waited out before its width is saved, ms. */
34
+ const QUIET = 400;
35
+ const Context = createContext(null);
36
+ /** Around the middle column's room and the panel: the panel's strip reads it. */
37
+ export const SideProvider = Context.Provider;
38
+ /** The column the panel stands in, or null where it stands in none (a phone's sheet, a host's own column). */
39
+ export const useSide = () => useContext(Context);
40
+ /** The panel's own properties, at a width or with the room. Every key every time, so React and a drag write one set. */
41
+ function look(width, hidden) {
42
+ return hidden
43
+ ? { width: 'auto', flexGrow: '1', flexShrink: '1', minWidth: '0px', maxWidth: 'none', marginLeft: '0px', gridColumnStart: 'pane' }
44
+ : { width: `${width}px`, flexGrow: '', flexShrink: '0', minWidth: `${FLOOR}px`, maxWidth: `calc(100vw - ${MAIN}px)`, marginLeft: '', gridColumnStart: '' };
45
+ }
46
+ /** Over the whole window, opaque, above the frame and under the kit's menus. */
47
+ const FULL = {
48
+ position: 'fixed',
49
+ inset: '0px',
50
+ zIndex: '60',
51
+ width: 'auto',
52
+ maxWidth: 'none',
53
+ minWidth: '0px',
54
+ marginLeft: '0px',
55
+ borderRadius: '0px',
56
+ backgroundColor: 'var(--background)',
57
+ backdropFilter: 'none',
58
+ };
59
+ const write = (el, style) => {
60
+ for (const [k, v] of Object.entries(style))
61
+ el?.style.setProperty(k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`), v);
62
+ };
63
+ /**
64
+ * The column, for a host that draws it: `open` is whether the panel is drawn
65
+ * beside the middle column, `main` what that column is called, `onShut` puts
66
+ * the panel away (its edge pulled past its floor).
67
+ */
68
+ export function useSplit(open, main, onShut) {
69
+ const prefs = usePrefs(false);
70
+ const kept = prefs?.prefs.side?.width;
71
+ const [width, setWidth] = useState(() => Math.max(FLOOR, kept ?? WIDTH));
72
+ const [hidden, setHidden] = useState(false);
73
+ const [full, setFull] = useState(false);
74
+ const [room, setRoom] = useState(0);
75
+ const [els, setEls] = useState({ main: null, aside: null });
76
+ // What a frame paints from, current without a render: the person's width,
77
+ // the room the two columns share, and the gutter between them.
78
+ const now = useRef({ width, room: 0, gap: 0, hidden });
79
+ now.current.width = width;
80
+ now.current.hidden = hidden;
81
+ const saving = useRef({ timer: undefined, width: 0 });
82
+ /**
83
+ * Draws the panel at `n`, or, for a person's pull (`pulled`), with the room;
84
+ * says which, and the width drawn. A window that narrows only squeezes it.
85
+ */
86
+ const paint = useCallback((n, pulled = true) => {
87
+ const { room, gap } = now.current;
88
+ if (!room)
89
+ return { hide: false, drawn: n };
90
+ const hide = pulled && room - gap - n < MAIN / 2;
91
+ const drawn = hide ? room : Math.max(FLOOR, Math.min(n, room - gap - MAIN));
92
+ write(els.aside, look(drawn, hide));
93
+ if (els.main)
94
+ els.main.style.display = hide ? 'none' : '';
95
+ return { hide, drawn };
96
+ }, [els]);
97
+ // The room, measured after layout: both columns' widths and the gutter, which
98
+ // a hidden middle column gives to the panel too. A change of window paints
99
+ // the person's width into it again.
100
+ useEffect(() => {
101
+ const { main, aside } = els;
102
+ if (!main || !aside || typeof ResizeObserver === 'undefined')
103
+ return;
104
+ const measure = () => {
105
+ // Full screen is the window's, not the room's.
106
+ if (aside.style.position === 'fixed')
107
+ return;
108
+ const shown = main.style.display !== 'none';
109
+ if (shown)
110
+ now.current.gap = parseFloat(getComputedStyle(aside).marginLeft) || 0;
111
+ const total = Math.round((shown ? main.getBoundingClientRect().width + now.current.gap : 0) + aside.getBoundingClientRect().width);
112
+ // A drag moves width between the two and leaves their sum; only a new window is news.
113
+ if (Math.abs(total - now.current.room) < 2)
114
+ return;
115
+ now.current.room = total;
116
+ setRoom(total);
117
+ if (shown)
118
+ paint(now.current.width, false);
119
+ };
120
+ const watch = new ResizeObserver(measure);
121
+ watch.observe(main);
122
+ watch.observe(aside);
123
+ return () => watch.disconnect();
124
+ }, [els, paint]);
125
+ // The person's width, once their preferences answer with one.
126
+ useEffect(() => {
127
+ if (kept && kept !== now.current.width)
128
+ setWidth(Math.max(FLOOR, kept));
129
+ }, [kept]);
130
+ // The preferences change with every save they answer; the save in waiting reads them current.
131
+ const held = useRef(prefs);
132
+ held.current = prefs;
133
+ const flush = useCallback(() => {
134
+ const s = saving.current;
135
+ if (s.timer === undefined)
136
+ return;
137
+ clearTimeout(s.timer);
138
+ s.timer = undefined;
139
+ // A refusal takes the width back (PrefsProvider), which is the answer.
140
+ held.current?.save({ side: { width: s.width } }).catch(() => { });
141
+ }, []);
142
+ const save = useCallback((n) => {
143
+ clearTimeout(saving.current.timer);
144
+ saving.current = { width: n, timer: held.current ? setTimeout(flush, QUIET) : undefined };
145
+ }, [flush]);
146
+ // A save still waiting goes now when the column goes.
147
+ useEffect(() => flush, [flush]);
148
+ const keep = useCallback((n) => {
149
+ const { hide, drawn } = paint(n);
150
+ setHidden(hide);
151
+ if (hide)
152
+ return;
153
+ setWidth(drawn);
154
+ save(drawn);
155
+ }, [paint, save]);
156
+ // Shut, the panel takes nothing with it: the middle column is back and the window is the frame's.
157
+ useEffect(() => {
158
+ if (open)
159
+ return;
160
+ setHidden(false);
161
+ setFull(false);
162
+ }, [open]);
163
+ // Escape leaves full screen, unless something open in it (a menu) took the key first.
164
+ useEffect(() => {
165
+ if (!full)
166
+ return;
167
+ const leave = (e) => {
168
+ if (e.key !== 'Escape' || e.defaultPrevented)
169
+ return;
170
+ setFull(false);
171
+ };
172
+ window.addEventListener('keydown', leave);
173
+ return () => window.removeEventListener('keydown', leave);
174
+ }, [full]);
175
+ const show = useCallback(() => {
176
+ setHidden(false);
177
+ const room = now.current.room;
178
+ if (room)
179
+ paint(Math.min(now.current.width, room - now.current.gap - MAIN));
180
+ }, [paint]);
181
+ const side = useMemo(() => ({ main, hidden: open && hidden, show, full: open && full, fill: setFull }), [main, open, hidden, full, show]);
182
+ const mainRef = useCallback((el) => setEls((was) => (was.main === el ? was : { ...was, main: el instanceof HTMLElement ? el : null })), []);
183
+ const asideRef = useCallback((el) => setEls((was) => (was.aside === el ? was : { ...was, aside: el instanceof HTMLElement ? el : null })), []);
184
+ const across = room ? Math.max(FLOOR, Math.min(width, room - now.current.gap - MAIN)) : width;
185
+ return {
186
+ side,
187
+ main: mainRef,
188
+ mainStyle: { display: side.hidden ? 'none' : '' },
189
+ aside: asideRef,
190
+ asideStyle: (side.full ? { ...look(across, side.hidden), ...FULL } : look(across, side.hidden)),
191
+ grip: side.full
192
+ ? null
193
+ : { side: 'right', span: side.hidden ? room : across, floor: FLOOR, ceil: Math.max(FLOOR, room), reset: WIDTH, onSpan: (n) => void paint(n), onKeep: keep, onShut },
194
+ };
195
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Up to three suggestions under the composer, each one person's own: what
3
+ * happened in their recent work, linked to where it happened, and the next step
4
+ * as a prompt the composer takes.
5
+ *
6
+ * Nothing here is written by hand. The rows are generated by Hanzo's own model
7
+ * (`MODEL`, POST /v1/chat/completions with the person's bearer, so the call is
8
+ * theirs like any other) from that person's activity alone: their recent
9
+ * conversations, their Dev runs, the org's issues and pull requests, their
10
+ * automations and their files. An account with no activity yet is given rows
11
+ * from what signup learned (its role, its use, what it said about its work),
12
+ * until there is activity to read. No input and no answer is no row: the model
13
+ * may answer none, and nothing pads it.
14
+ *
15
+ * Kept in the account (`suggest` in /v1/pref), so the rows follow the person:
16
+ *
17
+ * {at, sig, rows, dismissed} when they were made, the fingerprint of what
18
+ * they were made from, the rows, and the rows
19
+ * the person dismissed (by id)
20
+ *
21
+ * They are made again a day later, or once the activity's fingerprint has moved
22
+ * and the rows are at least `QUIET` old, so a busy hour asks the model once.
23
+ *
24
+ * This file is the pure half: inputs to a request, an answer to validated rows,
25
+ * the fingerprint and the policy. suggestions.tsx reads the sources and draws them.
26
+ */
27
+ import type { Automation } from './api/auto.ts';
28
+ import type { Conversation } from './api/conversations.ts';
29
+ import type { Item } from './api/library.ts';
30
+ import type { Session } from './api/sessions.ts';
31
+ import type { Issue } from './merge.ts';
32
+ export type Icon = 'github' | 'agent' | 'chat' | 'file' | 'task';
33
+ export declare const ICONS: readonly Icon[];
34
+ /** One row: where it came from, what happened, and what to do next. */
35
+ export interface Suggestion {
36
+ /** Stable across generations: the source's address, or the title for a row with none. */
37
+ id: string;
38
+ icon: Icon;
39
+ /** What happened, a few words: "Hanzo app login fixes landed". */
40
+ title: string;
41
+ /** The source's own https address, copied from an input, or '' for none. */
42
+ href: string;
43
+ /** What the source says, a sentence or three: the hover card. */
44
+ summary: string;
45
+ /** The next step, a few words: "Verify the user flow". */
46
+ action: string;
47
+ /** What the composer is given when the row is chosen. */
48
+ prompt: string;
49
+ }
50
+ /** One piece of the person's activity, as the model is shown it. */
51
+ export interface Input {
52
+ kind: 'conversation' | 'issue' | 'pull' | 'run' | 'automation' | 'file';
53
+ /** Its identity: an id, or its address. */
54
+ key: string;
55
+ title: string;
56
+ /** open or closed, a run's status, an automation's last result. */
57
+ state: string;
58
+ /** One line of detail: an issue's opening line, a run's repository, a schedule. */
59
+ text: string;
60
+ /** Its own https address, or ''. */
61
+ href: string;
62
+ /** RFC 3339, or ''. */
63
+ updated: string;
64
+ }
65
+ /** What signup and Settings learned about the person: the seed when there is no activity. */
66
+ export interface Seed {
67
+ /** What Hanzo calls them. */
68
+ name?: string;
69
+ /** What best describes their work (Settings). */
70
+ work?: string;
71
+ /** The role chosen at signup (IAM `onboarding.role`). */
72
+ role?: string;
73
+ /** personal, team or enterprise (IAM `onboarding.use`). */
74
+ use?: string;
75
+ /** What every run is told about how they like to work. */
76
+ instructions?: string;
77
+ }
78
+ /** What the account keeps under `suggest`. */
79
+ export interface Kept {
80
+ /** RFC 3339: when the rows were made. */
81
+ at: string;
82
+ /** The fingerprint of the inputs they were made from. */
83
+ sig: string;
84
+ rows: Suggestion[];
85
+ /** Ids of the rows the person dismissed, newest first. */
86
+ dismissed: string[];
87
+ }
88
+ /** Hanzo's model the rows are asked of: it decodes under a strict JSON schema at api.hanzo.ai. */
89
+ export declare const MODEL = "zen5-flash";
90
+ /** At most this many rows. */
91
+ export declare const MOST = 3;
92
+ /** Rows this old are made again whatever happened. */
93
+ export declare const DAY: number;
94
+ /** Rows younger than this are kept even when the activity has moved. */
95
+ export declare const QUIET = 3600000;
96
+ /** How many dismissals are remembered. */
97
+ export declare const REMEMBER = 100;
98
+ /** How many of each source the model reads. */
99
+ export declare const TAKE: Record<Input['kind'], number>;
100
+ /** One line, at most `n` characters. */
101
+ export declare function line(v: unknown, n: number): string;
102
+ /** An https address, or ''. A row links to nothing else. */
103
+ export declare function https(v: unknown): string;
104
+ /** cyrb53: a 53-bit string hash, as 14 hex digits. A fingerprint, never a secret. */
105
+ export declare function hash(s: string): string;
106
+ /** What the sources answered, read by suggestions.tsx. */
107
+ export interface Activity {
108
+ conversations: Conversation[];
109
+ /** The org's issues and pull requests (GET /v1/task/board). */
110
+ issues: Issue[];
111
+ /** The org's Dev runs; only the person's own are read. */
112
+ runs: Session[];
113
+ automations: Automation[];
114
+ /** The person's own Library items. */
115
+ files: Item[];
116
+ /** The person as a run's `actor` names them: `<owner>/<sub>`. */
117
+ me: string;
118
+ }
119
+ /** The person's activity as inputs: their own runs only, each kind's newest `TAKE`. */
120
+ export declare function inputs(a: Activity): Input[];
121
+ /**
122
+ * The seed: what the account's preferences say (`callName`, `work`,
123
+ * `instructions` in /v1/pref) and what signup kept on the IAM user (`onboarding`
124
+ * in /v1/iam/preferences: the role picked, and personal, team or enterprise).
125
+ */
126
+ export declare function seed(p: {
127
+ callName?: string;
128
+ work?: string;
129
+ instructions?: string;
130
+ }, iam: unknown): Seed;
131
+ /** Each kind's newest `TAKE`, newest first overall. */
132
+ export declare function pick(all: Input[]): Input[];
133
+ /**
134
+ * The fingerprint of what the rows are made from: each input's identity and
135
+ * state, and the seed. A new conversation, a closed issue or a finished run
136
+ * moves it; another turn in a conversation already read does not.
137
+ */
138
+ export declare function sig(inputs: Input[], seed: Seed): string;
139
+ /** Whether rows kept at `kept` are to be made again, now that the inputs read as `now`. */
140
+ export declare function due(kept: Kept | null, fingerprint: string, now: number): boolean;
141
+ /** Whether anything newer could change kept rows yet: false while they are younger than `QUIET`. */
142
+ export declare function stale(kept: Kept | null, now: number): boolean;
143
+ /** The icon a row about `i` carries: GitHub's mark only for GitHub's own address. */
144
+ export declare function iconOf(i: Input): Icon;
145
+ /** The answer's shape, decoded strictly by the model. */
146
+ export declare const SCHEMA: {
147
+ readonly type: 'object';
148
+ readonly additionalProperties: false;
149
+ readonly required: readonly ['rows'];
150
+ readonly properties: {
151
+ readonly rows: {
152
+ readonly type: 'array';
153
+ readonly maxItems: 3;
154
+ readonly items: {
155
+ readonly type: 'object';
156
+ readonly additionalProperties: false;
157
+ readonly required: readonly ['icon', 'title', 'href', 'summary', 'action', 'prompt'];
158
+ readonly properties: {
159
+ readonly icon: {
160
+ readonly type: 'string';
161
+ readonly enum: readonly Icon[];
162
+ };
163
+ readonly title: {
164
+ readonly type: 'string';
165
+ };
166
+ readonly href: {
167
+ readonly type: 'string';
168
+ };
169
+ readonly summary: {
170
+ readonly type: 'string';
171
+ };
172
+ readonly action: {
173
+ readonly type: 'string';
174
+ };
175
+ readonly prompt: {
176
+ readonly type: 'string';
177
+ };
178
+ };
179
+ };
180
+ };
181
+ };
182
+ };
183
+ /** The chat completion that asks for rows, or null when there is nothing to make them from. */
184
+ export declare function request(inputs: Input[], seed: Seed, skip: string[], now: Date): Record<string, unknown> | null;
185
+ /** The text of a chat completion's first choice, or ''. */
186
+ export declare function content(raw: unknown): string;
187
+ /** A row's id: its address, or for a row without one, its title. */
188
+ export declare const idOf: (href: string, title: string) => string;
189
+ /**
190
+ * The rows of a chat completion, validated: each with every field, an address
191
+ * that is one of the inputs' own (or none), its icon from that input, one row
192
+ * per source, at most `MOST`. Anything else is dropped, never repaired.
193
+ */
194
+ export declare function answer(raw: unknown, inputs: Input[]): Suggestion[];
195
+ /** The `suggest` key of the preference document, or undefined when it holds none. */
196
+ export declare function kept(raw: unknown): Kept | undefined;
197
+ /** The rows to draw: those not dismissed. */
198
+ export declare const shown: (k: Kept | null | undefined) => Suggestion[];
199
+ /** `k` with `id` dismissed: remembered first, the oldest forgotten past `REMEMBER`. */
200
+ export declare function dismissed(k: Kept, id: string): Kept;
201
+ /** The addresses and titles of dismissed rows the model is told to skip. */
202
+ export declare function skip(k: Kept | null | undefined, inputs: Input[]): string[];