@hanzo/build 0.2.54 → 0.2.55

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/desk.js CHANGED
@@ -67,7 +67,9 @@ export function Desk({ id, repo, branch, base, environment, mode, pr, title, pro
67
67
  render: () => (_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: "Sandbox", value: sandbox || '—' }), _jsx(Fact, { label: "Mode", value: mode || 'build' })] })),
68
68
  },
69
69
  ];
70
- return (_jsx(Panel, { side: side, kinds: kinds, sheet: sheet, onHide: onHide, label: "Run details", bar: sandbox ? _jsx(Held, { box: box, live: live }) : null, menu: _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": "Run actions", p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(MoreHorizontal, { size: 16 }) }), items: [...menu, { key: 'details', label: 'Details', icon: _jsx(Info, { size: 16 }), onSelect: environ }] }) }));
70
+ return (_jsx(Panel, { side: side, kinds: kinds, sheet: sheet, onHide: onHide, label: "Run details",
71
+ // An empty tab offers what this run made: its site and its pull request.
72
+ suggested: [...(site ? [{ title: name || site, url: site }] : []), ...(pr.href ? [{ title: `Pull request ${pr.label}`, url: pr.href }] : [])], bar: sandbox ? _jsx(Held, { box: box, live: live }) : null, menu: _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": "Run actions", p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(MoreHorizontal, { size: 16 }) }), items: [...menu, { key: 'details', label: 'Details', icon: _jsx(Info, { size: 16 }), onSelect: environ }] }) }));
71
73
  }
72
74
  /**
73
75
  * The run's sandbox as it stands, and what moves it. It is read while the pane is
package/lib/host.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * session, a router and a rail). Everything that differs between them is here,
8
8
  * as values, so no host forks the builder and the builder imports no host.
9
9
  */
10
- import { type ReactNode } from 'react';
10
+ import { type ComponentType, type ReactNode } from 'react';
11
11
  import type { Target } from './api/call.ts';
12
12
  import type { Theme } from './api/pref.ts';
13
13
  export interface Person {
@@ -16,6 +16,46 @@ export interface Person {
16
16
  /** An https image, or '' for the initial. */
17
17
  avatar: string;
18
18
  }
19
+ /**
20
+ * A page tab's address, drawn by the host rather than in a frame: what the
21
+ * window can show of any site, where a browser page can frame only the sites
22
+ * that allow it. `onGo` is a move the page made itself (a link followed), and
23
+ * `onTitle` the title it gave itself.
24
+ */
25
+ export type Browse = ComponentType<{
26
+ url: string;
27
+ title: string;
28
+ onGo: (url: string) => void;
29
+ onTitle?: (title: string) => void;
30
+ }>;
31
+ /** A page as the host's browser is asked about it: the tab, and what it shows. */
32
+ export interface Shown {
33
+ id: string;
34
+ url: string;
35
+ title: string;
36
+ }
37
+ /**
38
+ * What the host's browser can do to the page a tab shows, each the row of the
39
+ * same name in the tab's menu. A host draws only the rows it supplies, so the
40
+ * menu offers nothing the window cannot do; a browser page supplies none and
41
+ * draws what a page itself can (panel.tsx).
42
+ */
43
+ export interface Browser {
44
+ find?: (page: Shown) => void;
45
+ print?: (page: Shown) => void;
46
+ /** Zoom the page to `percent` (100 is its own size). */
47
+ zoom?: (page: Shown, percent: number) => void;
48
+ devices?: (page: Shown) => void;
49
+ /** The page's pixels, as an image. */
50
+ capture?: (page: Shown) => Promise<Blob>;
51
+ /** What the reader has selected in the page. */
52
+ selection?: (page: Shown) => Promise<string>;
53
+ passwords?: () => void;
54
+ downloads?: () => void;
55
+ history?: () => void;
56
+ clear?: () => void;
57
+ settings?: () => void;
58
+ }
19
59
  export interface Host {
20
60
  /** The product's name, drawn at the top left: `Hanzo Build` on hanzo.build, `Hanzo` on hanzo.ai. */
21
61
  name: string;
@@ -60,11 +100,19 @@ export interface Host {
60
100
  open: (href: string) => void;
61
101
  signIn?: () => void;
62
102
  signOut?: () => void;
103
+ /** A page tab's remote address, shown by the host (Hanzo Desktop: a native webview). Absent, a browser frames what it may. */
104
+ browse?: Browse;
105
+ /** What the host's browser does to a page, row by row in the tab's menu. */
106
+ browser?: Browser;
63
107
  }
64
108
  export declare function HostProvider({ host, children }: {
65
109
  host: Host;
66
110
  children: ReactNode;
67
111
  }): import("react").JSX.Element;
68
112
  export declare function useHost(): Host;
113
+ /** The host's way of showing a page, where one is mounted and has one. The side panel is drawn without a host too (hanzo.team's rooms). */
114
+ export declare function useBrowse(): Browse | null;
115
+ /** The host's browser, where one is mounted and has one. */
116
+ export declare function useBrowser(): Browser | null;
69
117
  /** The call target for this host — stable while the host's api, token reader and org are. */
70
118
  export declare function useTarget(): Target;
package/lib/host.js CHANGED
@@ -19,6 +19,14 @@ export function useHost() {
19
19
  throw new Error('The builder was rendered without a host');
20
20
  return h;
21
21
  }
22
+ /** The host's way of showing a page, where one is mounted and has one. The side panel is drawn without a host too (hanzo.team's rooms). */
23
+ export function useBrowse() {
24
+ return useContext(Context)?.browse ?? null;
25
+ }
26
+ /** The host's browser, where one is mounted and has one. */
27
+ export function useBrowser() {
28
+ return useContext(Context)?.browser ?? null;
29
+ }
22
30
  /** The call target for this host — stable while the host's api, token reader and org are. */
23
31
  export function useTarget() {
24
32
  const { api, token, org } = useHost();
package/lib/index.d.ts CHANGED
@@ -31,7 +31,7 @@ export { useStanding, rows, left, type Standing, type Row } from './standing.ts'
31
31
  export { kind, label, said, share, spent, ways, when, type Label, type Share, type State } from './plan.ts';
32
32
  export type { Limits } from './api/limits.ts';
33
33
  export type { Target } from './api/call.ts';
34
- export type { Host, Person } from './host.tsx';
34
+ export type { Browse, Browser, Host, Person, Shown } from './host.tsx';
35
35
  export type { Session, Status } from './api/sessions.ts';
36
36
  export type { Read } from './data.ts';
37
37
  export { href, parse, path, PLACES, route, SESSION, SLUG, type Route, type Screen } from './route.ts';
@@ -44,5 +44,5 @@ export { href, parse, path, PLACES, route, SESSION, SLUG, type Route, type Scree
44
44
  export { catalog, EFFORTS, FIRST, keep, kept, nameOf, reasons, useCatalog, useChoice, useMind, type Choice, type Effort, type Mind } from './mind.ts';
45
45
  export { cut, GAP, MEASURE, measure, Prompt, prompt, ROUND, Tune, useEar, type PromptOptions } from './prompt.tsx';
46
46
  export { artifacts, blob, lead, renders, Reply, Served, speakable, speaker, type Artifact, type Listen } from './reply.tsx';
47
- export { Page, Panel, PanelToggle, type Kind, type PanelProps } from './panel.tsx';
48
- export { address, name, PAGE, useDeck, useKey, usePanel, useShown, type Deck, type Tab, type Tabs } from './tabs.ts';
47
+ export { frames, Page, Panel, PanelToggle, type Kind, type PageProps, type PanelProps, type Suggestion } from './panel.tsx';
48
+ export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, usePanel, useShown, type Deck, type Tab, type Tabs } from './tabs.ts';
package/lib/index.js CHANGED
@@ -39,5 +39,5 @@ export { href, parse, path, PLACES, route, SESSION, SLUG } from './route.js';
39
39
  export { catalog, EFFORTS, FIRST, keep, kept, nameOf, reasons, useCatalog, useChoice, useMind } from './mind.js';
40
40
  export { cut, GAP, MEASURE, measure, Prompt, prompt, ROUND, Tune, useEar } from './prompt.js';
41
41
  export { artifacts, blob, lead, renders, Reply, Served, speakable, speaker } from './reply.js';
42
- export { Page, Panel, PanelToggle } from './panel.js';
43
- export { address, name, PAGE, useDeck, useKey, usePanel, useShown } from './tabs.js';
42
+ export { frames, Page, Panel, PanelToggle } from './panel.js';
43
+ export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, usePanel, useShown } from './tabs.js';
package/lib/panel.d.ts CHANGED
@@ -7,40 +7,86 @@ export interface Kind {
7
7
  render: () => ReactNode;
8
8
  /** Drawn edge to edge, scrolling itself: a terminal, a frame. */
9
9
  bleed?: boolean;
10
+ /** Its mark in the strip. */
11
+ icon?: ReactNode;
12
+ }
13
+ /** A place an empty tab offers. */
14
+ export interface Suggestion {
15
+ title: string;
16
+ url: string;
17
+ /** An image of the place's own mark; its initial without one. */
18
+ icon?: string;
10
19
  }
11
20
  /**
12
- * The header's Panel button: an icon among the header's icons, the summary's
13
- * mark rather than a sidebar's, because the window's own sidebar toggle is the
14
- * one at its top-left and two of that glyph read as one control drawn twice.
15
- * Its name and its keys say what it does; lit while the panel is open. Below
16
- * `lg`, open, it steps aside: the panel and its own close are in view.
21
+ * The header's control for the tabs, while they are hidden: an icon among the
22
+ * header's icons. Open, the strip's own toggle stands at the panel's end and
23
+ * this one is not drawn, so the page carries one toggle at a time.
17
24
  */
18
25
  export declare function PanelToggle({ open, onToggle }: {
19
26
  open: boolean;
20
27
  onToggle: () => void;
21
- }): import("react").JSX.Element;
28
+ }): import("react").JSX.Element | null;
29
+ /** Whether a browser page may frame an address: this page's bytes, this origin, and our own hosts. */
30
+ export declare function frames(url: string): boolean;
31
+ /** A site or a preview we host: a page under one of our hosts, not this page's own origin or our apexes. */
32
+ export declare function hosted(url: string): boolean;
33
+ /** A page the menu acts on. */
34
+ export interface Here {
35
+ url: string;
36
+ title: string;
37
+ }
38
+ /** A page asked about, with what the reader had selected in it where a page can read that. */
39
+ export interface Asked extends Here {
40
+ selection?: string;
41
+ }
42
+ /** Something the surface can do with a page: a row of the menu's Skills. */
43
+ export interface Skill {
44
+ id: string;
45
+ name: string;
46
+ run: (page: Here) => void;
47
+ }
48
+ /** What the surface does with a page from its menu: each one draws its row. */
49
+ export interface Acts {
50
+ /** Ask about this page: the conversation takes it, with what was selected. */
51
+ onAsk?: (page: Asked) => void;
52
+ /** Open in Dev: a preview or a site we host (`hosted`), opened where it is built. */
53
+ onDev?: (page: Here) => void;
54
+ /** Take a screenshot: the picture, handed to the surface (Chat attaches it). */
55
+ onCapture?: (file: File, page: Here) => void;
56
+ skills?: readonly Skill[];
57
+ }
58
+ export interface PageProps extends Acts {
59
+ tab: Tab;
60
+ /** Point the page at an address. */
61
+ onGo: (url: string) => void;
62
+ onBack?: () => void;
63
+ onForward?: () => void;
64
+ /** The title the page gave itself. */
65
+ onTitle?: (title: string) => void;
66
+ suggested?: readonly Suggestion[];
67
+ }
22
68
  /**
23
- * A page tab: an address with its bar, or bytes rendered as their type.
69
+ * A page tab: its bar, then an address, bytes rendered as their type, or the
70
+ * suggestions of an empty tab.
24
71
  *
25
72
  * Two sandboxes. Bytes and an object URL take THIS page's origin, so a script
26
73
  * an answer wrote runs without `allow-same-origin`, never with our storage. A
27
74
  * remote page takes its own origin, and withholding same-origin breaks it.
28
75
  */
29
- export declare function Page({ tab, onGo }: {
30
- tab: Tab;
31
- onGo: (url: string) => void;
32
- }): import("react").JSX.Element;
33
- export interface PanelProps {
76
+ export declare function Page({ tab, onGo, onBack, onForward, onTitle, suggested, ...acts }: PageProps): import("react").JSX.Element;
77
+ export interface PanelProps extends Acts {
34
78
  side: Deck;
35
79
  kinds: readonly Kind[];
36
80
  onHide: () => void;
37
81
  /** A sheet over the page (a phone) rather than the host's column. */
38
82
  sheet?: boolean;
39
- /** Under the tabs, over every tab: a line about what they all read (Dev's sandbox). */
83
+ /** Under the strip, over every tab: a line about what they all read (Dev's sandbox). */
40
84
  bar?: ReactNode;
41
- /** At the strip's end, before hide: the surface's own menu. */
85
+ /** At the strip's end, before pop-out and hide: the surface's own menu. */
42
86
  menu?: ReactNode;
43
87
  label?: string;
88
+ /** What an empty tab offers. */
89
+ suggested?: readonly Suggestion[];
44
90
  }
45
91
  /** The panel: the strip, and the chosen tab. */
46
- export declare function Panel({ side, kinds, onHide, sheet, bar, menu, label }: PanelProps): import("react").JSX.Element;
92
+ export declare function Panel({ side, kinds, onHide, sheet, bar, menu, label, suggested, ...acts }: PanelProps): import("react").JSX.Element;
package/lib/panel.js CHANGED
@@ -1,29 +1,97 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * The side panel, once, for Chat and Dev: tabs over what a conversation or a
4
- * run made, read and served (tabs.ts), opened and shut in one press — the
5
- * header's Panel button (`PanelToggle`), or ⌘. / Ctrl+. (`useKey`).
4
+ * run made, read and served (tabs.ts) — a browser's tabs beside the
5
+ * conversation. The header shows them (`PanelToggle`, while they are hidden),
6
+ * the strip's own toggle hides them, and Ctrl+Shift+B (⌘⇧B) or ⌘. / Ctrl+.
7
+ * does either from anywhere but a field (`useKey`).
8
+ *
9
+ * The strip: the tabs, `+`, and at its end the surface's menu, pop-out and the
10
+ * hide toggle. A page tab has a browser's bar — Back and Forward through the
11
+ * addresses it went through, reload, the address, and its menu — and an empty
12
+ * one offers the surface's suggestions.
13
+ *
14
+ * WHAT A PAGE CAN SHOW IS SAID, NOT FAKED. A host that draws pages itself
15
+ * (`Host.browse`: Hanzo Desktop's native webviews) shows any address. A browser
16
+ * page frames only what may be framed — this page's bytes and our own hosts
17
+ * (`frames`) — and offers any other address in a new window instead of a frame
18
+ * the site would refuse.
6
19
  *
7
20
  * The column the panel stands in is the host's: its width and edge. On a phone
8
21
  * the panel is a sheet over the page instead (`sheet`).
9
22
  */
10
23
  import { SizableText, XStack, YStack } from '@hanzo/gui';
11
- import { ExternalLink, PanelRightClose, Plus, RotateCw, TableOfContents, X } from '@hanzogui/lucide-icons-2';
12
- import { DropdownMenu } from '@hanzo/ui';
13
- import { useEffect, useMemo, useState } from 'react';
24
+ import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minus, MoreHorizontal, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2';
25
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@hanzo/ui';
26
+ import { useEffect, useMemo, useRef, useState } from 'react';
14
27
  import { createPortal } from 'react-dom';
15
- import { address, PAGE } from './tabs.js';
16
- /** The shortcut, as this platform writes it. */
17
- const KEY = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘.' : 'Ctrl+.';
28
+ import { useBrowse, useBrowser } from './host.js';
29
+ import { destination, PAGE } from './tabs.js';
30
+ const MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
31
+ /** The tabs' shortcut, as this platform writes it. */
32
+ const KEYS = MAC ? '⌘⇧B' : 'Ctrl+Shift+B';
33
+ /** Every key that shows and hides the tabs (tabs.ts `useKey`). */
34
+ const SHORTCUTS = 'Control+Shift+B Meta+Shift+B Control+Period Meta+Period';
35
+ /** A tab's title where it has none of its own. */
36
+ const BLANK = 'New tab';
37
+ /** The row heights, px: the strip (the conversation header's row), and a page's bar. */
38
+ const STRIP = 56;
39
+ const BAR = 48;
40
+ /** A round control in a page's bar, px. */
41
+ const PILL = 34;
42
+ /** A press on an icon control of the panel's: quiet until pointed at, lit while its thing is open. */
43
+ const quiet = (on = false, disabled = false) => ({
44
+ items: 'center',
45
+ justify: 'center',
46
+ shrink: 0,
47
+ rounded: 8,
48
+ cursor: disabled ? 'default' : 'pointer',
49
+ opacity: disabled ? 0.35 : 1,
50
+ bg: on ? '$hover' : 'transparent',
51
+ hoverStyle: disabled ? undefined : { bg: '$hover' },
52
+ focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' },
53
+ });
18
54
  /**
19
- * The header's Panel button: an icon among the header's icons, the summary's
20
- * mark rather than a sidebar's, because the window's own sidebar toggle is the
21
- * one at its top-left and two of that glyph read as one control drawn twice.
22
- * Its name and its keys say what it does; lit while the panel is open. Below
23
- * `lg`, open, it steps aside: the panel and its own close are in view.
55
+ * The header's control for the tabs, while they are hidden: an icon among the
56
+ * header's icons. Open, the strip's own toggle stands at the panel's end and
57
+ * this one is not drawn, so the page carries one toggle at a time.
24
58
  */
25
59
  export function PanelToggle({ open, onToggle }) {
26
- return (_jsx(XStack, { render: "button", role: "button", "data-slot": "side-toggle", "aria-label": open ? 'Close the side panel' : 'Open the side panel', "aria-pressed": open, "aria-keyshortcuts": "Meta+Period Control+Period", ...{ title: `${open ? 'Close' : 'Open'} the side panel (${KEY})` }, onPress: onToggle, items: "center", justify: "center", width: 32, height: 32, "$max-lg": open ? { display: 'none' } : undefined, rounded: "$3", bg: open ? '$hover' : 'transparent', cursor: "pointer", shrink: 0, hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: _jsx(TableOfContents, { size: 16 }) }));
60
+ if (open)
61
+ return null;
62
+ return (_jsx(XStack, { render: "button", role: "button", "data-slot": "side-toggle", "aria-label": "Show tabs", "aria-expanded": false, "aria-keyshortcuts": SHORTCUTS, ...{ title: `Show tabs (${KEYS})` }, onPress: onToggle, width: 32, height: 32, ...quiet(), children: _jsx(SquarePlus, { size: 16 }) }));
63
+ }
64
+ /** Our own hosts: a page of theirs answers a frame on any of our pages. */
65
+ const OURS = ['hanzo.ai', 'hanzo.app', 'hanzo.bot'];
66
+ /** Whether a browser page may frame an address: this page's bytes, this origin, and our own hosts. */
67
+ export function frames(url) {
68
+ let u;
69
+ try {
70
+ u = new URL(url, typeof window === 'undefined' ? 'https://hanzo.ai' : window.location.href);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ if (u.protocol === 'blob:' || u.protocol === 'data:')
76
+ return true;
77
+ if (typeof window !== 'undefined' && u.origin === window.location.origin)
78
+ return true;
79
+ if (u.protocol !== 'https:')
80
+ return false;
81
+ return OURS.some((h) => u.hostname === h || u.hostname.endsWith(`.${h}`));
82
+ }
83
+ /** A site or a preview we host: a page under one of our hosts, not this page's own origin or our apexes. */
84
+ export function hosted(url) {
85
+ let u;
86
+ try {
87
+ u = new URL(url);
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ if (u.protocol !== 'https:' || (typeof window !== 'undefined' && u.origin === window.location.origin))
93
+ return false;
94
+ return OURS.some((h) => u.hostname.endsWith(`.${h}`) && u.hostname !== `www.${h}`);
27
95
  }
28
96
  const suffix = (s) => {
29
97
  const dot = s.lastIndexOf('.');
@@ -31,33 +99,212 @@ const suffix = (s) => {
31
99
  };
32
100
  const PICTURES = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg'];
33
101
  const FRAMED = ['html', 'htm', 'txt', 'md', 'json', 'csv', 'pdf', 'css', 'js', 'ts'];
102
+ /** The address a tab can be opened at in a window of its own: its own, or its bytes. Null for a kind or a blank page. */
103
+ function outward(tab) {
104
+ if (!tab || tab.kind !== PAGE)
105
+ return null;
106
+ if (tab.body !== undefined)
107
+ return URL.createObjectURL(new Blob([tab.body], { type: tab.type || 'text/plain' }));
108
+ return tab.url || null;
109
+ }
110
+ /** Opens a tab's address or bytes in a new window. */
111
+ function popOut(tab) {
112
+ const href = outward(tab);
113
+ if (href)
114
+ window.open(href, '_blank', 'noopener,noreferrer');
115
+ }
116
+ /** A place's mark: its image, else its initial on a quiet tile. */
117
+ function Mark({ icon, title }) {
118
+ const [broken, setBroken] = useState(false);
119
+ return (_jsx(XStack, { width: 40, height: 40, rounded: 10, items: "center", justify: "center", bg: "$edge", overflow: "hidden", shrink: 0, children: icon && !broken ? (_jsx("img", { src: icon, alt: "", width: 28, height: 28, style: { objectFit: 'contain' }, onError: () => setBroken(true) })) : (_jsx(SizableText, { size: "$4", fontWeight: "600", color: "$ink", "aria-hidden": true, children: (title.trim()[0] ?? '·').toUpperCase() })) }));
120
+ }
121
+ /** An empty page: the places the surface suggests, as cards in a row that scrolls sideways. */
122
+ function Suggested({ suggested, onGo }) {
123
+ return (_jsxs(YStack, { "data-slot": "side-suggested", px: "$4", pt: "$4", gap: "$3", children: [_jsx(SizableText, { fontSize: 13, color: "$soft", children: "Suggested" }), suggested.length ? (_jsx(XStack, { role: "list", "aria-label": "Suggested", gap: "$2", overflowX: "auto", overflowY: "hidden", pb: "$2", children: suggested.map((s) => (_jsxs(YStack, { role: "listitem", render: "button", "aria-label": s.title, onPress: () => onGo(s.url), width: 120, shrink: 0, items: "center", gap: "$2", py: "$2.5", px: "$1.5", rounded: 12, cursor: "pointer", hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(Mark, { icon: s.icon, title: s.title }), _jsx(SizableText, { fontSize: 13, color: "$ink", numberOfLines: 1, maxW: "100%", children: s.title })] }, s.url))) })) : (_jsx(SizableText, { fontSize: 13, color: "$soft", children: "No suggestions." }))] }));
124
+ }
125
+ /** A remote address a browser page may not frame: what it is, and the one way to open it. */
126
+ function Elsewhere({ tab }) {
127
+ return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", p: "$5", "data-slot": "side-elsewhere", children: [_jsx(Globe, { size: 20, opacity: 0.6 }), _jsx(SizableText, { fontSize: 13, color: "$ink", numberOfLines: 1, maxW: "100%", children: tab.title || tab.url }), _jsxs(XStack, { render: "button", role: "button", onPress: () => popOut(tab), items: "center", gap: "$1.5", px: "$3", height: 32, rounded: 999, borderWidth: 1, borderColor: "$borderColor", cursor: "pointer", hoverStyle: { bg: '$hover' }, children: [_jsx(ExternalLink, { size: 13 }), _jsx(SizableText, { fontSize: 13, color: "$ink", children: "Open in a new window" })] })] }));
128
+ }
129
+ /** A row of the page's menu, px: a browser's menu is denser than a form's. */
130
+ const ROW = 29;
131
+ /** A browser's zoom steps, percent. */
132
+ const ZOOMS = [25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300];
133
+ /** The next zoom step past `now`, one way. */
134
+ const zoomed = (now, way) => {
135
+ const at = ZOOMS.findIndex((z) => z >= now);
136
+ const i = at < 0 ? ZOOMS.length - 1 : ZOOMS[at] === now ? at + way : way > 0 ? at : at - 1;
137
+ return ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, i))];
138
+ };
139
+ /** A row of the menu: its words, and what stands at its end. */
140
+ function Row({ label, onSelect, disabled, end }) {
141
+ return (_jsxs(DropdownMenuItem, { disabled: disabled, onSelect: onSelect, minH: ROW, py: 0, children: [_jsx(SizableText, { fontSize: 13, color: "$ink", flex: 1, numberOfLines: 1, children: label }), end] }));
142
+ }
143
+ /** The zoom row: the level between − and +, and the way back to 100. Plain buttons, so a press keeps the menu open. */
144
+ function Zoom({ level, onLevel, disabled }) {
145
+ const step = (way) => () => onLevel(zoomed(level, way));
146
+ const box = (label, onPress, child) => (_jsx(XStack, { render: "button", role: "button", "aria-label": label, "aria-disabled": disabled || undefined, onPress: disabled ? undefined : onPress, width: 24, height: 24, ...quiet(false, disabled), rounded: 6, children: child }));
147
+ return (_jsxs(XStack, { "data-slot": "page-zoom", role: "group", "aria-label": "Zoom", items: "center", gap: "$2", px: 8, height: 38, opacity: disabled ? 0.5 : 1, children: [_jsx(SizableText, { fontSize: 13, color: "$ink", flex: 1, children: "Zoom" }), _jsxs(XStack, { items: "center", gap: 2, borderWidth: 1, borderColor: "$borderColor", rounded: 8, px: 2, height: 28, children: [box('Zoom out', step(-1), _jsx(Minus, { size: 12 })), _jsx(SizableText, { fontSize: 12, color: "$ink", width: 40, text: "center", "aria-live": "polite", children: `${level}%` }), box('Zoom in', step(1), _jsx(Plus, { size: 12 }))] }), box('Reset zoom', () => onLevel(100), _jsx(RotateCcw, { size: 13 }))] }));
148
+ }
149
+ /**
150
+ * Skills, a submenu. Its panel is mounted only while open: a closed one stays
151
+ * laid out over the menu and takes its presses.
152
+ */
153
+ function Skills({ skills, here }) {
154
+ const [open, setOpen] = useState(false);
155
+ return (_jsxs(DropdownMenuSub, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenuSubTrigger, { disabled: !here, minH: ROW, py: 0, children: _jsx(SizableText, { fontSize: 13, color: "$ink", flex: 1, children: "Skills" }) }), open ? (_jsx(DropdownMenuSubContent, { minW: 200, children: skills.map((k) => (_jsx(Row, { label: k.name, onSelect: () => here && k.run(here) }, k.id))) })) : null] }));
156
+ }
157
+ /**
158
+ * The page's menu, in a browser's order: what we add first (ask about it, open
159
+ * it in Dev, a skill, a new window, its link), then what is done to the page
160
+ * (find, print, zoom, device toolbar, a screenshot), then the browser's own
161
+ * (passwords, downloads, history, clearing) and its settings. A row is drawn
162
+ * only where it can be done — by the host's browser (`Host.browser`), or by a
163
+ * page itself for a frame it may reach — and one that needs a page is dim on
164
+ * an empty tab.
165
+ */
166
+ function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser, }) {
167
+ const shown = here ? { id: tab.id, ...here } : null;
168
+ const none = !shown;
169
+ const pick = () => {
170
+ if (!same)
171
+ return undefined;
172
+ try {
173
+ return frame.current?.contentWindow?.getSelection()?.toString() || undefined;
174
+ }
175
+ catch {
176
+ return undefined;
177
+ }
178
+ };
179
+ const ask = async () => {
180
+ if (!shown || !acts.onAsk)
181
+ return;
182
+ const selection = browser?.selection ? await browser.selection(shown).catch(() => '') : pick();
183
+ acts.onAsk({ url: shown.url, title: shown.title, ...(selection ? { selection } : {}) });
184
+ };
185
+ const capture = async () => {
186
+ if (!shown || !browser?.capture || !acts.onCapture)
187
+ return;
188
+ const blob = await browser.capture(shown);
189
+ const name = `Screenshot ${new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19)}.png`;
190
+ acts.onCapture(new File([blob], name, { type: blob.type || 'image/png' }), { url: shown.url, title: shown.title });
191
+ };
192
+ const print = browser?.print ?? (same ? () => frame.current?.contentWindow?.print() : undefined);
193
+ const zoom = Boolean(browser?.zoom) || framed;
194
+ const ours = [
195
+ acts.onAsk ? _jsx(Row, { label: "Ask about this page", disabled: none, onSelect: () => void ask() }, "ask") : null,
196
+ acts.onDev && shown && hosted(shown.url) ? _jsx(Row, { label: "Open in Dev", onSelect: () => acts.onDev?.({ url: shown.url, title: shown.title }) }, "dev") : null,
197
+ acts.skills?.length ? _jsx(Skills, { skills: acts.skills, here: here }, "skills") : null,
198
+ _jsx(Row, { label: "Open in a new window", disabled: none, onSelect: () => popOut(tab) }, "out"),
199
+ _jsx(Row, { label: "Copy link", disabled: none || !tab.url, onSelect: () => tab.url && void navigator.clipboard?.writeText(tab.url) }, "copy"),
200
+ ].filter(Boolean);
201
+ const page = [
202
+ browser?.find ? _jsx(Row, { label: "Find in page", disabled: none, onSelect: () => shown && browser.find?.(shown) }, "find") : null,
203
+ print ? _jsx(Row, { label: "Print", disabled: none, onSelect: () => shown && print(shown) }, "print") : null,
204
+ zoom ? _jsx(Zoom, { level: level, onLevel: onLevel, disabled: none }, "zoom") : null,
205
+ browser?.devices ? _jsx(Row, { label: "Show device toolbar", disabled: none, onSelect: () => shown && browser.devices?.(shown) }, "devices") : null,
206
+ browser?.capture && acts.onCapture ? _jsx(Row, { label: "Take a screenshot", disabled: none, onSelect: () => void capture() }, "capture") : null,
207
+ ].filter(Boolean);
208
+ const own = [
209
+ browser?.passwords ? _jsx(Row, { label: "Passwords and autofill", onSelect: browser.passwords, end: _jsx(ChevronRight, { size: 14, opacity: 0.6 }) }, "passwords") : null,
210
+ browser?.downloads ? _jsx(Row, { label: "Downloads", onSelect: browser.downloads }, "downloads") : null,
211
+ browser?.history ? _jsx(Row, { label: "History", onSelect: browser.history }, "history") : null,
212
+ browser?.clear ? _jsx(Row, { label: "Clear browsing data", onSelect: browser.clear }, "clear") : null,
213
+ ].filter(Boolean);
214
+ const settings = browser?.settings ? [_jsx(Row, { label: "Browser settings", onSelect: browser.settings }, "settings")] : [];
215
+ const groups = [ours, page, own, settings].filter((g) => g.length);
216
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(XStack, { render: "button", role: "button", "aria-label": "Page actions", width: PILL, height: PILL, ...quiet(), rounded: 999, bg: "$edge", children: _jsx(MoreHorizontal, { size: 16 }) }) }), _jsx(DropdownMenuContent, { align: "end", minW: 240, "data-slot": "page-menu", children: groups.flatMap((g, i) => (i ? [_jsx(DropdownMenuSeparator, {}, `line-${i}`), ...g] : g)) })] }));
217
+ }
34
218
  /**
35
- * A page tab: an address with its bar, or bytes rendered as their type.
219
+ * A page tab: its bar, then an address, bytes rendered as their type, or the
220
+ * suggestions of an empty tab.
36
221
  *
37
222
  * Two sandboxes. Bytes and an object URL take THIS page's origin, so a script
38
223
  * an answer wrote runs without `allow-same-origin`, never with our storage. A
39
224
  * remote page takes its own origin, and withholding same-origin breaks it.
40
225
  */
41
- export function Page({ tab, onGo }) {
226
+ export function Page({ tab, onGo, onBack, onForward, onTitle, suggested = [], ...acts }) {
227
+ const Browse = useBrowse();
228
+ const browser = useBrowser();
229
+ const frame = useRef(null);
230
+ // The page's zoom: the host's browser zooms its own; a frame here is scaled.
231
+ const [level, setLevel] = useState(100);
42
232
  const own = tab.body !== undefined;
43
233
  const src = useMemo(() => (own ? URL.createObjectURL(new Blob([tab.body], { type: tab.type || 'text/plain' })) : (tab.url ?? '')), [own, tab.body, tab.type, tab.url]);
44
234
  useEffect(() => () => (own ? URL.revokeObjectURL(src) : undefined), [own, src]);
45
235
  const [round, again] = useState(0);
46
236
  const [typed, setTyped] = useState(tab.url ?? '');
237
+ const [typing, setTyping] = useState(false);
238
+ // A move the page made, Back or a suggestion is the bar's new address.
239
+ useEffect(() => setTyped(tab.url ?? ''), [tab.url]);
47
240
  const local = own || src.startsWith('blob:');
48
241
  const type = tab.type ?? '';
49
242
  const ext = suffix(tab.title);
50
243
  const picture = type.startsWith('image/') || PICTURES.includes(ext);
51
244
  const shows = !local || own || !type || type.startsWith('text/') || type === 'application/pdf' || type === 'application/json' || FRAMED.includes(ext);
52
- return (_jsxs(YStack, { flex: 1, minH: 0, "data-slot": "side-page", children: [_jsxs(XStack, { px: "$2", py: "$1.5", gap: "$1.5", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(XStack, { render: "button", role: "button", "aria-label": "Reload this tab", onPress: () => again((n) => n + 1), p: "$1.5", rounded: "$2", cursor: "pointer", hoverStyle: { bg: '$hover' }, children: _jsx(RotateCw, { size: 14 }) }), local ? (_jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$soft", numberOfLines: 1, children: tab.title })) : (_jsx(XStack, { flex: 1, minW: 0, items: "center", px: "$2", height: 28, rounded: "$2", borderWidth: 1, borderColor: "$borderColor", bg: "$raised", children: _jsx("input", { defaultValue: tab.url ?? '', placeholder: "Type an address", "aria-label": "Address", spellCheck: false, autoFocus: !tab.url, onChange: (e) => setTyped(e.target.value), onKeyDown: (e) => {
53
- if (e.key === 'Enter' && address(typed))
54
- onGo(address(typed));
55
- }, style: { width: '100%', border: 0, outline: 'none', background: 'transparent', color: 'var(--foreground)', font: 'inherit', fontSize: 13 } }, tab.id) })), src ? (_jsx(XStack, { render: "button", role: "button", "aria-label": "Open this tab in a new window", onPress: () => window.open(src, '_blank', 'noopener,noreferrer'), p: "$1.5", rounded: "$2", cursor: "pointer", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 14 }) })) : null] }), !src ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$5", children: _jsx(SizableText, { size: "$2", color: "$soft", children: "Type an address to open a page here." }) })) : picture && !own ? (_jsx(YStack, { flex: 1, minH: 0, items: "center", justify: "center", p: "$3", overflowY: "auto", overflowX: "hidden", children: _jsx("img", { src: src, alt: tab.title, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' } }, round) })) : shows ? (_jsx("iframe", { src: src, title: tab.title, sandbox: local ? 'allow-scripts allow-popups allow-forms' : 'allow-same-origin allow-scripts allow-popups allow-forms', style: { border: 0, width: '100%', height: '100%', display: 'block', background: 'var(--background)' } }, round)) : (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: `${(ext || type || 'This').toUpperCase()} previews aren’t supported yet.` }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Open it in a new window to view it." })] }))] }));
245
+ const back = Boolean(tab.back?.length && onBack);
246
+ const ahead = Boolean(tab.ahead?.length && onForward);
247
+ const go = () => {
248
+ const to = destination(typed);
249
+ if (to)
250
+ onGo(to);
251
+ };
252
+ const native = !local && Boolean(Browse);
253
+ const elsewhere = !local && !native && Boolean(src) && !frames(src);
254
+ const framed = Boolean(src) && !native && !elsewhere;
255
+ const same = framed && !local && (() => {
256
+ try {
257
+ return new URL(src, window.location.href).origin === window.location.origin;
258
+ }
259
+ catch {
260
+ return false;
261
+ }
262
+ })();
263
+ const here = src ? { url: tab.url || src, title: tab.title } : null;
264
+ const zoomTo = (percent) => {
265
+ setLevel(percent);
266
+ if (browser?.zoom && native && here)
267
+ browser.zoom({ id: tab.id, ...here }, percent);
268
+ };
269
+ // A frame scaled from its corner, its box grown to fill the panel at the scale.
270
+ const scale = native || level === 100 ? null : { width: `${10000 / level}%`, height: `${10000 / level}%`, transform: `scale(${level / 100})`, transformOrigin: '0 0' };
271
+ return (_jsxs(YStack, { flex: 1, minH: 0, "data-slot": "side-page", children: [_jsxs(XStack, { "data-slot": "side-bar", height: BAR, px: "$2", gap: "$2", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsxs(XStack, { items: "center", height: PILL, px: 4, gap: 2, rounded: 999, bg: "$edge", shrink: 0, children: [_jsx(XStack, { render: "button", role: "button", "aria-label": "Back", "aria-disabled": !back || undefined, onPress: back ? onBack : undefined, width: 28, height: 28, ...quiet(false, !back), rounded: 999, children: _jsx(ArrowLeft, { size: 15 }) }), _jsx(XStack, { render: "button", role: "button", "aria-label": "Forward", "aria-disabled": !ahead || undefined, onPress: ahead ? onForward : undefined, width: 28, height: 28, ...quiet(false, !ahead), rounded: 999, children: _jsx(ArrowRight, { size: 15 }) }), _jsx(XStack, { render: "button", role: "button", "aria-label": "Reload this tab", onPress: () => again((n) => n + 1), width: 28, height: 28, ...quiet(), rounded: 999, children: _jsx(RotateCw, { size: 14 }) })] }), _jsx(XStack, { flex: 1, minW: 0, items: "center", height: PILL, px: "$3.5", rounded: 999, bg: "$edge", borderWidth: 1,
272
+ // Focused, the address is the bar's one ringed thing, as a browser's is.
273
+ borderColor: typing ? '$outlineColor' : 'transparent', "data-slot": "side-address", children: _jsx("input", { value: own ? tab.title : typed, readOnly: own, placeholder: "Search or enter a URL", "aria-label": "Address", spellCheck: false, autoFocus: !own && !tab.url, onFocus: (e) => {
274
+ setTyping(true);
275
+ e.currentTarget.select();
276
+ }, onBlur: () => setTyping(false), onChange: (e) => setTyped(e.target.value), onKeyDown: (e) => {
277
+ if (e.key === 'Enter')
278
+ go();
279
+ if (e.key === 'Escape')
280
+ setTyped(tab.url ?? '');
281
+ }, style: {
282
+ width: '100%',
283
+ border: 0,
284
+ outline: 'none',
285
+ background: 'transparent',
286
+ color: 'var(--foreground)',
287
+ font: 'inherit',
288
+ fontSize: 14,
289
+ // The resting bar reads centred, as a browser's new tab does; typed in, it reads from the left.
290
+ textAlign: typing || typed ? 'left' : 'center',
291
+ } }, tab.id) }), _jsx(Actions, { tab: tab, here: here, frame: frame, framed: framed, same: same, level: level, onLevel: zoomTo, acts: acts, browser: browser })] }), !src ? (_jsx(Suggested, { suggested: suggested, onGo: onGo })) : native && Browse ? (_jsx(YStack, { flex: 1, minH: 0, children: _jsx(Browse, { url: src, title: tab.title, onGo: onGo, onTitle: onTitle }) }, round)) : elsewhere ? (_jsx(Elsewhere, { tab: tab })) : picture && !own ? (_jsx(YStack, { flex: 1, minH: 0, items: "center", justify: "center", p: "$3", overflowY: "auto", overflowX: "hidden", children: _jsx("img", { src: src, alt: tab.title, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', ...(scale ? { transform: scale.transform } : null) } }, round) })) : shows ? (_jsx(YStack, { flex: 1, minH: 0, overflow: "hidden", position: "relative", children: _jsx("iframe", { ref: frame, src: src, title: tab.title, sandbox: local ? 'allow-scripts allow-popups allow-forms' : 'allow-same-origin allow-scripts allow-popups allow-forms allow-modals', style: { position: 'absolute', top: 0, left: 0, border: 0, width: '100%', height: '100%', display: 'block', background: 'var(--background)', ...scale } }, round) })) : (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: `${(ext || type || 'This').toUpperCase()} previews aren’t supported yet.` }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Open it in a new window to view it." })] }))] }));
56
292
  }
57
293
  const DRAG = 'text/x-hanzo-tab';
58
- /** One tab in the strip: what you press, and its cross. */
59
- function Leaf({ tab, on, side, index, label }) {
60
- return (_jsxs(XStack, { "data-slot": "side-tab", "data-tab": tab.id, items: "center", gap: "$0.5", pl: "$2.5", pr: "$1", height: 30, maxW: 160, shrink: 0, rounded: "$3", bg: on ? '$edge' : 'transparent', hoverStyle: { bg: on ? '$edge' : '$hover' }, ...{
294
+ /** One tab in the strip: its mark, its title, and its cross. */
295
+ function Leaf({ tab, on, side, index, label, icon }) {
296
+ return (_jsxs(XStack, { "data-slot": "side-tab", "data-tab": tab.id, items: "center", gap: "$1", pl: "$2.5", pr: "$1", height: 30,
297
+ // As wide as a title wants up to a browser tab's width, sharing the strip
298
+ // as a browser's tabs do when there are many.
299
+ flexBasis: 236, shrink: 1, minW: 56, maxW: 236, rounded: 8, borderWidth: 1, borderColor: on ? '$borderColor' : 'transparent', bg: on ? '$edge' : 'transparent', hoverStyle: { bg: on ? '$edge' : '$hover' }, ...{
300
+ // A middle press shuts any tab, as a browser's does; the chosen one also has its cross.
301
+ onAuxClick: (e) => {
302
+ if (e.button !== 1)
303
+ return;
304
+ e.preventDefault();
305
+ side.shut(tab.id);
306
+ },
307
+ title: label,
61
308
  draggable: true,
62
309
  onDragStart: (e) => {
63
310
  e.dataTransfer.setData(DRAG, tab.id);
@@ -74,7 +321,7 @@ function Leaf({ tab, on, side, index, label }) {
74
321
  e.preventDefault();
75
322
  side.move(id, index);
76
323
  },
77
- }, children: [_jsx(XStack, { render: "button", role: "tab", id: `side-tab-${tab.id}`, "aria-selected": on, "aria-controls": "side-body", tabIndex: on ? 0 : -1, onPress: () => side.pick(tab.id), onKeyDown: (e) => {
324
+ }, children: [_jsxs(XStack, { render: "button", role: "tab", id: `side-tab-${tab.id}`, "aria-selected": on, "aria-controls": "side-body", tabIndex: on ? 0 : -1, onPress: () => side.pick(tab.id), onKeyDown: (e) => {
78
325
  const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
79
326
  if (step && e.shiftKey && (e.metaKey || e.ctrlKey)) {
80
327
  e.preventDefault?.();
@@ -94,23 +341,34 @@ function Leaf({ tab, on, side, index, label }) {
94
341
  e.preventDefault?.();
95
342
  side.shut(tab.id);
96
343
  }
97
- }, flex: 1, minW: 0, cursor: "pointer", children: _jsx(SizableText, { size: "$2", color: on ? '$ink' : '$soft', numberOfLines: 1, children: label }) }), _jsx(XStack, { render: "button", role: "button", "aria-label": `Close ${label}`, tabIndex: -1, onPress: () => side.shut(tab.id), p: "$1", rounded: "$2", cursor: "pointer", opacity: on ? 0.8 : 0.45, hoverStyle: { bg: '$hover', opacity: 1 }, children: _jsx(X, { size: 12 }) })] }));
344
+ }, flex: 1, minW: 0, items: "center", gap: "$2", cursor: "pointer", children: [icon ? (_jsx(XStack, { "aria-hidden": true, shrink: 0, opacity: on ? 1 : 0.7, children: icon })) : null, _jsx(SizableText, { fontSize: 13, color: on ? '$ink' : '$soft', numberOfLines: 1, children: label })] }), _jsx(XStack, { render: "button", role: "button", "aria-label": `Close ${label}`, tabIndex: -1, onPress: () => side.shut(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.45, hoverStyle: { bg: '$hover', opacity: 1 },
345
+ // The chosen tab's cross: the others keep their room for their titles when the strip is shared.
346
+ display: on ? 'flex' : 'none', children: _jsx(X, { size: 12 }) })] }));
98
347
  }
99
348
  /** The panel: the strip, and the chosen tab. */
100
- export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = 'Side panel' }) {
349
+ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = 'Side panel', suggested, ...acts }) {
101
350
  const tab = side.tabs.find((t) => t.id === side.at) ?? null;
351
+ // The chosen tab is in view in a strip that scrolls: a tab just opened stands at its end.
352
+ useEffect(() => {
353
+ if (side.at)
354
+ document.getElementById(`side-tab-${side.at}`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
355
+ }, [side.at, side.tabs.length]);
102
356
  const kind = tab && tab.kind !== PAGE ? kinds.find((k) => k.id === tab.kind) : undefined;
103
357
  const bleed = !tab || tab.kind === PAGE || Boolean(kind?.bleed);
104
358
  const closed = kinds.filter((k) => !side.tabs.some((t) => t.kind === k.id));
105
- const title = (t) => (t.kind === PAGE ? t.title || 'New page' : (kinds.find((k) => k.id === t.kind)?.label ?? t.title));
106
- const body = (_jsxs(YStack, { role: "complementary", "aria-label": label, "data-slot": "side", flex: 1, minW: 0, minH: 0, overflow: "hidden", style: { borderRadius: 'inherit' }, children: [_jsxs(XStack, { px: "$2", py: "$1.5", gap: "$1", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(XStack, { role: "tablist", "aria-label": "Tabs", flex: 1, minW: 0, gap: "$1", items: "center", overflowX: "auto", overflowY: "hidden", "$platform-web": { scrollbarWidth: 'none' }, children: side.tabs.map((t, i) => (_jsx(Leaf, { tab: t, on: t.id === side.at, side: side, index: i, label: title(t) }, t.id))) }), _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", role: "button", "aria-label": "Open a tab", p: "$1.5", rounded: "$2", cursor: "pointer", hoverStyle: { bg: '$hover' }, children: _jsx(Plus, { size: 16 }) }), items: [
359
+ const title = (t) => (t.kind === PAGE ? t.title || BLANK : (kinds.find((k) => k.id === t.kind)?.label ?? t.title));
360
+ const mark = (t) => (t.kind === PAGE ? _jsx(Globe, { size: 14 }) : (kinds.find((k) => k.id === t.kind)?.icon ?? null));
361
+ const blank = () => side.open({ kind: PAGE, title: BLANK, url: '' });
362
+ const poppable = tab?.kind === PAGE && (tab.body !== undefined || Boolean(tab.url));
363
+ const plus = (_jsx(XStack, { render: "button", role: "button", "aria-label": "Open a tab", width: 28, height: 28, ...quiet(), onPress: closed.length ? undefined : blank, children: _jsx(Plus, { size: 16 }) }));
364
+ const body = (_jsxs(YStack, { role: "complementary", "aria-label": label, "data-slot": "side", flex: 1, minW: 0, minH: 0, overflow: "hidden", style: { borderRadius: 'inherit' }, children: [_jsxs(XStack, { "data-slot": "side-strip", height: STRIP, px: "$2", gap: "$1.5", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(XStack, { role: "tablist", "aria-label": "Tabs", shrink: 1, minW: 0, gap: "$1", items: "center", overflowX: "auto", overflowY: "hidden", "$platform-web": { scrollbarWidth: 'none' }, children: side.tabs.map((t, i) => (_jsx(Leaf, { tab: t, on: t.id === side.at, side: side, index: i, label: title(t), icon: mark(t) }, t.id))) }), closed.length ? (_jsx(DropdownMenu, { trigger: plus, items: [
107
365
  ...closed.map((k) => ({ key: k.id, label: k.label, onSelect: () => side.open({ kind: k.id, title: k.label }) })),
108
- { key: PAGE, label: 'Page', description: 'An address, framed here', onSelect: () => side.open({ kind: PAGE, title: 'New page', url: '' }) },
109
- ] }), menu, _jsx(XStack, { render: "button", role: "button", "aria-label": "Close the side panel", onPress: onHide, p: "$1.5", rounded: "$2", cursor: "pointer", hoverStyle: { bg: '$hover' }, children: _jsx(PanelRightClose, { size: 16 }) })] }), bar, _jsx(YStack, { id: "side-body", role: "tabpanel", "aria-labelledby": tab ? `side-tab-${tab.id}` : undefined,
366
+ { key: PAGE, label: 'Page', description: 'An address, framed here', onSelect: blank },
367
+ ] })) : (plus), _jsx(XStack, { flex: 1 }), menu, _jsx(XStack, { render: "button", role: "button", "aria-label": "Open this tab in a new window", "aria-disabled": !poppable || undefined, onPress: poppable ? () => popOut(tab) : undefined, width: 28, height: 28, ...quiet(false, !poppable), children: _jsx(Maximize2, { size: 14 }) }), _jsx(XStack, { render: "button", role: "button", "data-slot": "side-toggle", "aria-label": "Hide tabs", "aria-expanded": true, "aria-keyshortcuts": SHORTCUTS, ...{ title: `Hide tabs (${KEYS})` }, onPress: onHide, width: 32, height: 32, ...quiet(true), children: _jsx(Columns2, { size: 16 }) })] }), bar, _jsx(YStack, { id: "side-body", role: "tabpanel", "aria-labelledby": tab ? `side-tab-${tab.id}` : undefined,
110
368
  // What a tab lays over itself (a door's "Connecting…") stays inside it, under the strip.
111
- position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$2", p: "$5", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "Nothing open here." }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Press + to open a tab." })] })) : tab.kind === PAGE ? (_jsx(Page, { tab: tab, onGo: (url) => side.go(tab.id, url) }, tab.id)) : kind ? (kind.render()) : (_jsx(SizableText, { size: "$2", color: "$soft", children: "This tab is not offered here." })) })] }));
369
+ position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$5", children: _jsx(SizableText, { fontSize: 13, color: "$soft", children: "No tabs open." }) })) : tab.kind === PAGE ? (_jsx(Page, { tab: tab, onGo: (url) => side.go(tab.id, url), onBack: () => side.back(tab.id), onForward: () => side.forward(tab.id), onTitle: (t) => side.rename(tab.id, t), suggested: suggested, ...acts }, tab.id)) : kind ? (kind.render()) : (_jsx(SizableText, { size: "$2", color: "$soft", children: "This tab is not offered here." })) })] }));
112
370
  if (!sheet || typeof document === 'undefined')
113
371
  return body;
114
372
  // A phone: a sheet from the foot of the window over a dim, the same panel in it.
115
- return createPortal(_jsxs(YStack, { "data-slot": "side-sheet", position: "fixed", t: 0, r: 0, b: 0, l: 0, z: 60, children: [_jsx(YStack, { render: "button", "aria-label": "Close the side panel", onPress: onHide, position: "absolute", t: 0, r: 0, l: 0, height: "12dvh", bg: "var(--surface-scrim)" }), _jsx(YStack, { position: "absolute", l: 0, r: 0, b: 0, height: "88dvh", bg: "$background", borderTopWidth: 1, borderColor: "$borderColor", style: { borderTopLeftRadius: 16, borderTopRightRadius: 16 }, overflow: "hidden", children: body })] }), document.body);
373
+ return createPortal(_jsxs(YStack, { "data-slot": "side-sheet", position: "fixed", t: 0, r: 0, b: 0, l: 0, z: 60, children: [_jsx(YStack, { "aria-hidden": true, onPress: onHide, position: "absolute", t: 0, r: 0, l: 0, height: "12dvh", bg: "var(--surface-scrim)" }), _jsx(YStack, { position: "absolute", l: 0, r: 0, b: 0, height: "88dvh", bg: "$background", borderTopWidth: 1, borderColor: "$borderColor", style: { borderTopLeftRadius: 16, borderTopRightRadius: 16 }, overflow: "hidden", children: body })] }), document.body);
116
374
  }
package/lib/prompt.d.ts CHANGED
@@ -14,8 +14,8 @@ export declare const measure: {
14
14
  readonly maxW: "calc(var(--container-prose, 48rem) - 1.5rem)";
15
15
  readonly mx: 'auto';
16
16
  };
17
- /** The round controls' box, px: a line of the footer row. */
18
- export declare const ROUND = 30;
17
+ /** The round controls' box, px: the pill's row less its padding. */
18
+ export declare const ROUND = 32;
19
19
  /**
20
20
  * The frame as a pane of its own, floating on the pane it is drawn in: the
21
21
  * `--pane-*` cut on design's first paper rung (a second rung's drop reads as a
@@ -59,6 +59,8 @@ export interface PromptOptions {
59
59
  talk?: Machine;
60
60
  /** Drawn on a pane: the frame is cut as a pane of its own (`cut`). */
61
61
  framed?: boolean;
62
+ /** Centred just above the pill: what every message is sent under. */
63
+ note?: ReactNode;
62
64
  }
63
65
  /**
64
66
  * The chip beside send, and the panel it opens: the effort, a sentence saying
package/lib/prompt.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * The composer, once, for Chat and Dev: the field, and under it in the frame
4
- * the attach button and the surface's own tools at the start, then the model
5
- * and effort (`Tune`), dictation, talk and send at the end.
3
+ * The composer, once, for Chat and Dev: one pill. While the draft is a line,
4
+ * the pill is one row — attach (`+`) and the surface's own tools at the start,
5
+ * the field, then the model and effort (`Tune`), dictation, talk and send. A
6
+ * draft longer than a line takes the pill's whole width above, and the
7
+ * controls keep the bottom row. A note the surface owes every message (the
8
+ * "can make mistakes" line) stands centred just above the pill.
6
9
  *
7
10
  * `prompt` builds @hanzo/ui's `Composer` props, so a surface that draws the
8
11
  * composer through `Chat` (its `composer` prop) and one that draws it directly
@@ -14,7 +17,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
17
  * `MEASURE` and this lines up with its words.
15
18
  */
16
19
  import { SizableText, XStack, YStack } from '@hanzo/gui';
17
- import { ArrowUp, AudioLines, Check, ChevronDown, Mic, Paperclip, Square } from '@hanzogui/lucide-icons-2';
20
+ import { ArrowUp, AudioLines, Check, ChevronDown, Mic, Plus, Square } from '@hanzogui/lucide-icons-2';
18
21
  import { Control } from '@hanzo/composer';
19
22
  import { Popover, PopoverContent, PopoverTrigger, Separator, Tooltip, TooltipContent, TooltipTrigger } from '@hanzo/ui';
20
23
  import { Composer } from '@hanzo/ui/chat';
@@ -29,8 +32,13 @@ export const MEASURE = 'var(--container-prose, 48rem)';
29
32
  export const GAP = 20;
30
33
  /** The composer's width: the measure less the thread column's 0.75rem gutters, centred, so its edges are the text's. */
31
34
  export const measure = { width: 'calc(100% - 1.5rem)', maxW: `calc(${MEASURE} - 1.5rem)`, mx: 'auto' };
32
- /** The round controls' box, px: a line of the footer row. */
33
- export const ROUND = 30;
35
+ /** The round controls' box, px: the pill's row less its padding. */
36
+ export const ROUND = 32;
37
+ /** The pill's height on one line, px, and the corner a taller draft keeps. */
38
+ const PILL = 44;
39
+ const CORNER = 22;
40
+ /** About a line of draft at the measure: past it, or past a newline, the field takes the pill's width. */
41
+ const LINE = 72;
34
42
  /**
35
43
  * The frame as a pane of its own, floating on the pane it is drawn in: the
36
44
  * `--pane-*` cut on design's first paper rung (a second rung's drop reads as a
@@ -62,7 +70,7 @@ export function Tune({ mind, limits, disabled = false }) {
62
70
  const thinks = reasons(mind.model, mind.models);
63
71
  const pace = EFFORTS.find((e) => e.id === mind.effort);
64
72
  const routed = mind.model === ENSO;
65
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, allowFlip: true, children: [_jsx(PopoverTrigger, { disabled: disabled, children: _jsxs(XStack, { "data-slot": "mind", role: "button", tabIndex: disabled ? -1 : 0, "aria-label": thinks ? `Model and effort: ${name}, ${pace.label}` : `Model: ${name}`, "aria-haspopup": "dialog", "aria-expanded": open, items: "center", gap: "$1.5", px: "$2.5", height: 30, maxW: 220, rounded: "$10", borderWidth: 1, borderColor: "$borderColor", cursor: disabled ? 'default' : 'pointer', hoverStyle: disabled ? undefined : { bg: '$hover' }, focusVisibleStyle: { outlineColor: '$outlineColor', outlineWidth: 2, outlineStyle: 'solid' }, opacity: disabled ? 0.4 : 1, onKeyDown: (e) => {
73
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, allowFlip: true, children: [_jsx(PopoverTrigger, { disabled: disabled, children: _jsxs(XStack, { "data-slot": "mind", role: "button", tabIndex: disabled ? -1 : 0, "aria-label": thinks ? `Model and effort: ${name}, ${pace.label}` : `Model: ${name}`, "aria-haspopup": "dialog", "aria-expanded": open, items: "center", gap: "$1.5", px: "$2.5", height: ROUND, maxW: 220, rounded: 999, cursor: disabled ? 'default' : 'pointer', hoverStyle: disabled ? undefined : { bg: '$hover' }, focusVisibleStyle: { outlineColor: '$outlineColor', outlineWidth: 2, outlineStyle: 'solid' }, opacity: disabled ? 0.4 : 1, onKeyDown: (e) => {
66
74
  if (disabled || (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'ArrowUp' && e.key !== 'ArrowDown'))
67
75
  return;
68
76
  e.preventDefault?.();
@@ -84,9 +92,9 @@ export function Tune({ mind, limits, disabled = false }) {
84
92
  setOpen(false);
85
93
  }, loading: mind.loading, error: mind.error })] })] }) })] }));
86
94
  }
87
- /** The paperclip, with its name on hover. */
95
+ /** The pill's `+`: files go with the message. Its name on hover. */
88
96
  function Attach({ onAttach, disabled }) {
89
- return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onAttach, children: _jsx(Paperclip, { size: 15 }) }) }), _jsx(TooltipContent, { children: _jsx(SizableText, { size: "$2", children: "Attach files" }) })] }));
97
+ return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onAttach, children: _jsx(Plus, { size: 18 }) }) }), _jsx(TooltipContent, { children: _jsx(SizableText, { size: "$2", children: "Attach files" }) })] }));
90
98
  }
91
99
  /** @hanzo/ui `Composer` props for the one composer. */
92
100
  export function prompt(o) {
@@ -94,6 +102,11 @@ export function prompt(o) {
94
102
  const busy = Boolean(o.busy);
95
103
  const disabled = Boolean(o.disabled);
96
104
  const live = busy ? Boolean(o.onStop) : lit && !disabled;
105
+ // A draft past a line takes the pill's width above the controls. The same
106
+ // field either way, moved by flex order alone: a different tree would remount
107
+ // it and drop the caret mid-word.
108
+ const tall = o.value.includes('\n') || o.value.length > LINE;
109
+ const head = o.head || o.note ? (_jsxs(YStack, { ...measure, gap: "$1.5", "data-slot": "prompt-head", children: [o.head, o.note ? (_jsx(SizableText, { "data-slot": "prompt-note", fontSize: 12, color: "$soft", text: "center", self: "center", children: o.note })) : null] })) : undefined;
97
110
  return {
98
111
  value: o.value,
99
112
  onChange: o.onChange,
@@ -103,13 +116,33 @@ export function prompt(o) {
103
116
  disabled,
104
117
  ...(o.placeholder ? { placeholder: o.placeholder } : {}),
105
118
  ...(o.label ? { label: o.label } : {}),
106
- ...(o.field ? { field: o.field } : {}),
107
- // Above the frame and at its width, so a chip lines up with the field's edge.
108
- head: o.head ? (_jsx(YStack, { ...measure, gap: "$1.5", "data-slot": "prompt-head", children: o.head })) : undefined,
119
+ // The field: first and the pill's width on a tall draft, after `+` on a line.
120
+ // inline-style: flex `order`, which no gui prop carries; the field keeps its node either way.
121
+ // An empty field keeps its placeholder to the line, cut short rather than wrapped inside the pill.
122
+ field: {
123
+ ...o.field,
124
+ style: { ...(o.field?.style ?? {}), order: tall ? 0 : 1, ...(o.value ? null : { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }) },
125
+ ...(tall ? { width: '100%', flexBasis: '100%', px: '$2' } : null),
126
+ // A phone keeps the field a readable width: what does not fit beside it goes to a second row.
127
+ '$max-sm': { minW: 140 },
128
+ },
129
+ // Above the pill and at its width, so a chip lines up with the field's edge.
130
+ head,
131
+ inline: true,
109
132
  ...measure,
110
- ...(o.framed ? cut : null),
111
- children: (_jsxs(XStack, { items: "center", gap: "$2", "data-slot": "prompt-start", children: [o.onAttach ? _jsx(Attach, { onAttach: o.onAttach, disabled: disabled }) : null, o.tools] })),
112
- send: (_jsxs(XStack, { items: "center", gap: "$2", "data-slot": "prompt-end", children: [o.mind ? _jsx(Tune, { mind: o.mind, limits: o.limits, disabled: disabled && !busy }) : null, o.voice ? (_jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: o.voice, disabled: disabled, says: { idle: 'Dictate', listening: 'Dictating — click to stop' }, children: (state) => _jsx(Mic, { size: 15, fill: state === 'idle' ? 'none' : 'currentColor' }) }) })) : null, o.talk ? (_jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: o.talk, disabled: disabled, "data-slot": "composer-talk", says: { idle: 'Talk with Hanzo', listening: 'In conversation — click to hang up', speaking: 'Hanzo is speaking — talk to interrupt' }, children: () => _jsx(AudioLines, { size: 15 }) }) })) : null, _jsx(Control, { type: "button", size: ROUND, fill: true, "data-slot": "composer-send", "aria-label": busy ? 'Stop' : 'Send', "aria-disabled": !live || undefined, opacity: live ? 1 : 0.4, onClick: () => {
133
+ minH: PILL,
134
+ rounded: tall ? CORNER : 999,
135
+ pl: 6,
136
+ pr: 6,
137
+ gap: 4,
138
+ items: tall ? 'flex-end' : 'center',
139
+ flexWrap: tall ? 'wrap' : 'nowrap',
140
+ '$max-sm': { flexWrap: 'wrap', rounded: CORNER },
141
+ borderColor: '$borderColor',
142
+ bg: '$edge',
143
+ ...(o.framed ? { boxShadow: 'var(--shadow-sheet-1)' } : null),
144
+ children: (_jsxs(XStack, { items: "center", gap: 2, "data-slot": "prompt-start", style: { order: tall ? 1 : 0 }, shrink: 0, children: [o.onAttach ? _jsx(Attach, { onAttach: o.onAttach, disabled: disabled }) : null, o.tools] })),
145
+ send: (_jsxs(XStack, { items: "center", gap: 2, "data-slot": "prompt-end", style: { order: 2 }, ml: tall ? 'auto' : 0, "$max-sm": { ml: 'auto' }, shrink: 0, children: [o.mind ? _jsx(Tune, { mind: o.mind, limits: o.limits, disabled: disabled && !busy }) : null, o.voice ? (_jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: o.voice, disabled: disabled, says: { idle: 'Dictate', listening: 'Dictating — click to stop' }, children: (state) => _jsx(Mic, { size: 15, fill: state === 'idle' ? 'none' : 'currentColor' }) }) })) : null, o.talk ? (_jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: o.talk, disabled: disabled, "data-slot": "composer-talk", says: { idle: 'Talk with Hanzo', listening: 'In conversation — click to hang up', speaking: 'Hanzo is speaking — talk to interrupt' }, children: () => _jsx(AudioLines, { size: 15 }) }) })) : null, _jsx(Control, { type: "button", size: ROUND, fill: true, rounded: 999, "data-slot": "composer-send", "aria-label": busy ? 'Stop' : 'Send', "aria-disabled": !live || undefined, opacity: live ? 1 : 0.4, onClick: () => {
113
146
  if (!live)
114
147
  return;
115
148
  if (busy)
package/lib/run.js CHANGED
@@ -388,7 +388,7 @@ export function Run({ id }) {
388
388
  ...(busy || waiting ? ['pause', 'resume', 'stop'] : []),
389
389
  ] }), _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),
390
390
  // What a new run from here is made with; words that steer this run use neither.
391
- mind: signed && !steering ? mind : null, limits: limits, voice: voice }), _jsx(SizableText, { size: "$1", color: "$soft", style: { textAlign: 'center' }, children: "Hanzo is AI and can make mistakes." })] })] }), desk && !wide ? desk_(true) : null, desk && wide ? (_jsxs(YStack, { "data-slot": "desk", ref: (el) => {
391
+ 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) => {
392
392
  deskBox.current = el instanceof HTMLElement ? el : null;
393
393
  }, ...pane,
394
394
  // A pane of its own beside the run's, the gutter between them.
package/lib/tabs.d.ts CHANGED
@@ -8,6 +8,9 @@ export interface Tab {
8
8
  /** A page's own bytes, rendered as `type`. */
9
9
  body?: string;
10
10
  type?: string;
11
+ /** The addresses a page came from, oldest first, and those Back stepped over, nearest first. */
12
+ back?: string[];
13
+ ahead?: string[];
11
14
  }
12
15
  /** The tabs in a scope, in order, and the chosen one. */
13
16
  export interface Tabs {
@@ -32,9 +35,18 @@ export interface Deck extends Tabs {
32
35
  pick: (id: string) => void;
33
36
  shut: (id: string) => void;
34
37
  move: (id: string, to: number) => void;
35
- /** Point a page somewhere else. */
38
+ /** Point a page somewhere else: where it was goes on its Back, and Forward is forgotten. */
36
39
  go: (id: string, url: string) => void;
40
+ /** A page's Back and Forward. */
41
+ back: (id: string) => void;
42
+ forward: (id: string) => void;
43
+ /** A page's title, as what it shows calls itself. */
44
+ rename: (id: string, title: string) => void;
37
45
  }
46
+ /** A page pointed at `url`, where it was on its Back. */
47
+ export declare function went(t: Tab, url: string): Tab;
48
+ /** A page one step back, or forward: the address it leaves goes the other way. */
49
+ export declare function stepped(t: Tab, way: 'back' | 'forward'): Tab;
38
50
  /** A scope's tabs: as kept, else `seed`, and every change kept. */
39
51
  export declare function useDeck(scope: string, seed: Tabs): Deck;
40
52
  /** Whether the panel is open in `mode`, kept; `first` before anyone has said. */
@@ -45,9 +57,18 @@ export declare function useShown(mode: string, first: boolean): readonly [boolea
45
57
  * the conversation, never on a sheet it did not ask for.
46
58
  */
47
59
  export declare function usePanel(mode: string, first: boolean, wide: boolean): readonly [boolean, (open: boolean) => void];
48
- /** ⌘. on a Mac, Ctrl+. elsewhere: the panel, from anywhere on the page. */
60
+ /**
61
+ * The panel's keys, from anywhere on the page but a field: Ctrl+Shift+B (⌘⇧B on
62
+ * a Mac), the tabs' own, and ⌘. / Ctrl+., which it had first.
63
+ */
49
64
  export declare function useKey(toggle: () => void): void;
50
65
  /** The host and path of an address: as much as a title can honestly be. */
51
66
  export declare function name(url: string): string;
67
+ /** Where words typed in a page's bar are looked up. */
68
+ export declare const SEARCH = "https://duckduckgo.com/?q=";
69
+ /** Whether what was typed in a page's bar is an address rather than words to look up. */
70
+ export declare function located(typed: string): boolean;
71
+ /** What a page's bar opens for what was typed: the address, or a search for the words. */
72
+ export declare function destination(typed: string): string;
52
73
  /** An address as typed, made one that opens: https for a host, http for this machine. */
53
74
  export declare function address(typed: string): string;
package/lib/tabs.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * new page; a tab is dragged to reorder (Ctrl/⌘+Shift+←/→ from the keyboard)
13
13
  * and shut with its cross (Delete). A page's own bytes are kept with it, so an
14
14
  * artifact's tab survives a reload; an object URL does not and is not kept.
15
+ * A page keeps the addresses it went through (`back`, `ahead`), so its bar's
16
+ * Back and Forward walk them as a browser's do.
15
17
  *
16
18
  */
17
19
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
@@ -102,6 +104,27 @@ export function moved(was, id, to) {
102
104
  tabs.splice(Math.max(0, Math.min(tabs.length, to)), 0, tab);
103
105
  return { ...was, tabs };
104
106
  }
107
+ /** How many addresses a page's Back keeps. */
108
+ const DEPTH = 50;
109
+ /** A page pointed at `url`, where it was on its Back. */
110
+ export function went(t, url) {
111
+ if (url === t.url)
112
+ return t;
113
+ const back = t.url ? [...(t.back ?? []), t.url].slice(-DEPTH) : (t.back ?? []);
114
+ return { ...t, url, title: name(url), body: undefined, type: undefined, back, ahead: [] };
115
+ }
116
+ /** A page one step back, or forward: the address it leaves goes the other way. */
117
+ export function stepped(t, way) {
118
+ const from = way === 'back' ? (t.back ?? []) : (t.ahead ?? []);
119
+ const url = way === 'back' ? from.at(-1) : from[0];
120
+ if (!url)
121
+ return t;
122
+ const rest = way === 'back' ? from.slice(0, -1) : from.slice(1);
123
+ const here = t.url ? [t.url] : [];
124
+ return way === 'back'
125
+ ? { ...t, url, title: name(url), back: rest, ahead: [...here, ...(t.ahead ?? [])] }
126
+ : { ...t, url, title: name(url), ahead: rest, back: [...(t.back ?? []), ...here].slice(-DEPTH) };
127
+ }
105
128
  /** A scope's tabs: as kept, else `seed`, and every change kept. */
106
129
  export function useDeck(scope, seed) {
107
130
  const kept = useSyncExternalStore(subscribe, () => read(scope), () => null);
@@ -113,7 +136,10 @@ export function useDeck(scope, seed) {
113
136
  pick: (id) => change((was) => ({ ...was, at: id })),
114
137
  shut: (id) => change((was) => shut(was, id)),
115
138
  move: (id, to) => change((was) => moved(was, id, to)),
116
- go: (id, url) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? { ...t, url, title: name(url) } : t)) })),
139
+ go: (id, url) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? went(t, url) : t)) })),
140
+ back: (id) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? stepped(t, 'back') : t)) })),
141
+ forward: (id) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? stepped(t, 'forward') : t)) })),
142
+ rename: (id, title) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id && title && t.title !== title ? { ...t, title } : t)) })),
117
143
  };
118
144
  }
119
145
  const shownKey = (mode) => where(`${mode}.open`);
@@ -158,16 +184,28 @@ export function usePanel(mode, first, wide) {
158
184
  const [sheet, setSheet] = useState(false);
159
185
  return wide ? [kept, keep] : [sheet, setSheet];
160
186
  }
161
- /** ⌘. on a Mac, Ctrl+. elsewhere: the panel, from anywhere on the page. */
187
+ /** Whether a key went to something the reader types in, where it is theirs. */
188
+ const typing = (e) => {
189
+ const el = e.target;
190
+ return !!el && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT');
191
+ };
192
+ /**
193
+ * The panel's keys, from anywhere on the page but a field: Ctrl+Shift+B (⌘⇧B on
194
+ * a Mac), the tabs' own, and ⌘. / Ctrl+., which it had first.
195
+ */
162
196
  export function useKey(toggle) {
163
197
  const now = useRef(toggle);
164
198
  now.current = toggle;
165
199
  useEffect(() => {
166
200
  const press = (e) => {
167
- if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key === '.') {
168
- e.preventDefault();
169
- now.current();
170
- }
201
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || typing(e))
202
+ return;
203
+ const tabs = e.shiftKey && e.key.toLowerCase() === 'b';
204
+ const dot = !e.shiftKey && e.key === '.';
205
+ if (!tabs && !dot)
206
+ return;
207
+ e.preventDefault();
208
+ now.current();
171
209
  };
172
210
  window.addEventListener('keydown', press);
173
211
  return () => window.removeEventListener('keydown', press);
@@ -183,6 +221,26 @@ export function name(url) {
183
221
  return url;
184
222
  }
185
223
  }
224
+ /** Where words typed in a page's bar are looked up. */
225
+ export const SEARCH = 'https://duckduckgo.com/?q=';
226
+ /** Whether what was typed in a page's bar is an address rather than words to look up. */
227
+ export function located(typed) {
228
+ const t = typed.trim();
229
+ if (!t || /\s/.test(t))
230
+ return false;
231
+ if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(t))
232
+ return true;
233
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || /^(about|blob|data):/i.test(t))
234
+ return true;
235
+ return /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(:\d+)?(\/.*)?$/i.test(t);
236
+ }
237
+ /** What a page's bar opens for what was typed: the address, or a search for the words. */
238
+ export function destination(typed) {
239
+ const t = typed.trim();
240
+ if (!t)
241
+ return '';
242
+ return located(t) ? address(t) : SEARCH + encodeURIComponent(t);
243
+ }
186
244
  /** An address as typed, made one that opens: https for a host, http for this machine. */
187
245
  export function address(typed) {
188
246
  const t = typed.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.54",
3
+ "version": "0.2.55",
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.",