@hanzo/rooms 0.1.38 → 0.1.40

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.
@@ -1,11 +1,15 @@
1
1
  import { type ChatMessage } from '@hanzo/ai/react';
2
+ import { type Deck, type Kind, type Tabs } from '@hanzo/build';
2
3
  import { type Api } from './lib/files.js';
4
+ import { type Held } from './pane.js';
5
+ /** A conversation's panel before anyone has arranged it. */
6
+ export declare const SEED: Tabs;
3
7
  /**
4
8
  * One thing the conversation carries.
5
9
  *
6
- * `href` is where it can be opened and `body` is the text of something that was
10
+ * `href` is where it can be opened and `body` the text of something that was
7
11
  * never a file — a fenced block exists only in the transcript until somebody
8
- * asks to look at it, at which point `body` becomes a blob and gets an address.
12
+ * looks at it, and then it is rendered from these bytes.
9
13
  */
10
14
  export interface Mark {
11
15
  id: string;
@@ -13,36 +17,24 @@ export interface Mark {
13
17
  kind: 'image' | 'code' | 'file';
14
18
  href?: string;
15
19
  body?: string;
16
- /** The type a blob should carry, so a browser renders it rather than downloads it. */
20
+ /** The type a browser should take it as, so it renders rather than downloads. */
17
21
  mime?: string;
18
22
  /** A workspace file's id: it opens through a signed address minted when it is looked at. */
19
23
  file?: string;
20
24
  }
21
25
  /**
22
- * The column. `said` is the conversation it stands beside; without one it draws
23
- * the same shape with nothing in it, which is what a room nobody has spoken in
24
- * honestly looks like.
25
- */
26
- export declare function RightPane({ said, onClose, }: {
27
- said?: ChatMessage[];
28
- onClose?: () => void;
29
- }): import("react").JSX.Element;
30
- /**
31
- * Opens a mark in this conversation's browser.
26
+ * Opens a mark as a page tab: a row under Artifacts or Sources, or a file a
27
+ * message carries (Chat's chips).
32
28
  *
33
- * A BLOCK BECOMES A FILE THE MOMENT SOMEBODY LOOKS AT IT. It has no address
34
- * until then — it is text inside a turn — so one is minted here from its own
35
- * bytes, carrying the type its fence declared. That is what makes an answer's
36
- * `index.html` something you can actually see rather than something you can
37
- * only read the source of.
38
- */
39
- export declare function look(at: string, m: Mark, api: Api): Promise<void>;
40
- /**
41
- * Where a mark can be opened: its own address, a signed one minted for its
42
- * workspace file, or one minted from its text. Null when there is none — a file
43
- * forgotten or a store unreachable opens nothing, and the row stays.
29
+ * A block is rendered from its own bytes, which the tab keeps, so it is there
30
+ * after a reload. A workspace file opens through a signed address minted now,
31
+ * the bytes this document still holds first (`localOf`).
44
32
  */
45
- export declare function addressOf(m: Mark, api: Api): Promise<string | null>;
46
- /** Shows or hides the summary above this conversation's browser. Exported for
47
- * the conversation header, which is where the control belongs. */
48
- export declare function usePinned(): [boolean, () => void];
33
+ export declare function look(deck: Deck, m: Mark, api: Api): Promise<void>;
34
+ /** Chat's kinds of tab, over the conversation `said` and the files `held` for the next message. */
35
+ export declare function useKinds({ at, said, held, deck }: {
36
+ at: string;
37
+ said: ChatMessage[];
38
+ held: Held[];
39
+ deck: Deck;
40
+ }): Kind[];
package/dist/RightPane.js CHANGED
@@ -1,85 +1,40 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- // The column beside a conversation: what it produced, what went into it, and a
4
- // browser for looking at any of it.
5
- //
6
- // WHAT THIS REPLACED. It was a channel tree — `remington`, `selectric`,
7
- // `zed-student-ambassador`, `londonmaxxing` — none of which are this product's
8
- // channels, hand-written into the markup beside a roster of eleven agents. It
9
- // drew a NAVIGATION surface in the one place the frame reserves for a room's
10
- // own contents, duplicating the rail on the opposite edge, and it did it in
11
- // sky blue against a design that has no hue in it. Nothing here reads a list
12
- // somebody typed.
3
+ // What Chat puts in the one side panel (@hanzo/build `Panel`, the panel Dev's
4
+ // runs use): Artifacts, what the conversation made, and Sources, what went into
5
+ // it and what it looked up. Pressing either opens it as a page tab — an
6
+ // answer's `index.html` rendered from its own bytes, a picture, a workspace
7
+ // file — beside the two.
13
8
  //
14
9
  // OUTPUTS AND SOURCES ARE DERIVED FROM THE TURNS, never from a second list kept
15
- // in step with them. `LLM.md` already states this rule for `Work` and it is the
16
- // same rule: the conversation is the record, so a fenced block in an answer IS
17
- // an output, and an image in a question or a block in it that names a file IS a
18
- // source. There is nothing to sync, nothing to invalidate, and no count that
19
- // can disagree with the transcript — and switching channels costs no request,
20
- // because the turns are already in hand. The one thing listed that is not yet
21
- // a turn is what is held for the next message (`hold` in `pane.ts`), which a
22
- // send spends into one. A conversation that has produced nothing says so.
23
- //
24
- // THE COLUMN IS STACKED, NOT SPLIT. The reference this follows puts a ~300px
25
- // summary beside a wide browser, which needs about 900px of column; the frame
26
- // gives this one 200–480 (`FLOOR`/`CEIL` in `Shell.tsx`), so side by side would
27
- // leave the browser about 80px wide. Stacked, both are usable at every width
28
- // the frame allows, and the pin earns its keep — hiding the summary gives the
29
- // browser the whole height rather than a slightly wider strip.
10
+ // in step with them: the conversation is the record, so a fenced block in an
11
+ // answer IS an output (@hanzo/build `artifacts`), and an image in a question, a
12
+ // file it carried, or a block in it that names a file IS a source. The one
13
+ // thing listed that is not yet a turn is what is held for the next message
14
+ // (`hold` in `pane.ts`), which a send spends into one.
30
15
  //
31
- // The frame owns the column; this is one of the things a room puts in it. It
32
- // draws no column of its own — see `Beside` in `Shell.tsx`.
16
+ // The panel's tabs, their order and the open one are kept per conversation by
17
+ // @hanzo/build (`useDeck`, scope `chat:<channel>`); the frame owns the column
18
+ // it stands in (`Beside` in `Shell.tsx`).
33
19
  import { useMemo, useState } from 'react';
34
- import { ExternalLink, FileCode, Image as ImageMark, Paperclip, PanelRightClose, Plus, RotateCw, X, } from 'lucide-react';
35
- import { Box, DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Text, View, XStack, YStack, } from '@hanzo/ui';
20
+ import { FileCode, Image as ImageMark, Paperclip, Plus, Search } from 'lucide-react';
21
+ import { Box, DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Text, YStack, } from '@hanzo/ui';
36
22
  import { useAi } from '@hanzo/ai/react';
23
+ import { artifacts, name as titleOf, PAGE } from '@hanzo/build';
37
24
  import { Card, Quiet, Row } from './card.js';
38
25
  import { said as told } from './lib/attach.js';
39
26
  import { download, fileOf } from './lib/files.js';
40
27
  import { org } from './lib/session.js';
41
- import { useOpen } from './open.js';
42
- import { address, channel, name as titleOf, openTab, pickTab, pin, shutTab, take, usePane, } from './pane.js';
28
+ import { localOf, take } from './pane.js';
43
29
  import { useProviders, useSkills } from './tools.js';
44
30
  import { activate, startDevice } from './connect.js';
45
- /** How many rows a card shows before it offers the rest. */
46
- const FEW = 5;
47
- /** Fenced blocks, as a model writes them. The info string is whatever followed
48
- * the ticks — a language, or a path when the answer named one. */
49
- const FENCE = /```([^\n`]*)\n([\s\S]*?)```/g;
50
- /** What a fence's info string means for the browser that will render it. */
51
- const MIME = {
52
- html: 'text/html',
53
- htm: 'text/html',
54
- svg: 'image/svg+xml',
55
- css: 'text/css',
56
- csv: 'text/csv',
57
- json: 'application/json',
58
- md: 'text/markdown',
59
- markdown: 'text/markdown',
60
- txt: 'text/plain',
61
- };
62
- /** The extension, lowercased, or '' where a name has none. */
63
- const suffix = (s) => {
64
- const dot = s.lastIndexOf('.');
65
- return dot > 0 ? s.slice(dot + 1).toLowerCase() : '';
66
- };
67
- /**
68
- * What a fence is called.
69
- *
70
- * AN INFO STRING IS A FILENAME OR A LANGUAGE, and telling them apart is the
71
- * whole of it: a model writing to a file names it, and one showing a snippet
72
- * names the language. A dot and no spaces is a name; anything else is a
73
- * language, said as what it is rather than dressed up as a file that does not
74
- * exist.
75
- */
76
- const fenceName = (info, n) => {
77
- const word = info.trim().split(/\s+/)[0] ?? '';
78
- if (!word)
79
- return `Block ${n}`;
80
- if (word.includes('.') && suffix(word))
81
- return word;
82
- return `${word} block`;
31
+ /** A conversation's panel before anyone has arranged it. */
32
+ export const SEED = {
33
+ tabs: [
34
+ { id: 'artifacts', kind: 'artifacts', title: 'Artifacts' },
35
+ { id: 'sources', kind: 'sources', title: 'Sources' },
36
+ ],
37
+ at: 'artifacts',
83
38
  };
84
39
  /** The text of a turn, whichever shape the wire used. */
85
40
  const words = (m) => typeof m.content === 'string'
@@ -91,197 +46,133 @@ const words = (m) => typeof m.content === 'string'
91
46
  const pictures = (m) => Array.isArray(m.content)
92
47
  ? m.content.flatMap((p) => (p.type === 'image_url' && p.image_url?.url ? [p.image_url.url] : []))
93
48
  : [];
94
- /**
95
- * What the assistant made in this conversation.
96
- *
97
- * Fenced blocks and pictures, in the order they were said. The id carries the
98
- * turn's index so it survives a re-render without a counter of its own, and so
99
- * two identical blocks in one conversation stay two rows.
100
- */
49
+ /** What the assistant made: fenced blocks and pictures, in the order they were said. */
101
50
  function outputs(said) {
102
51
  const out = [];
103
52
  said.forEach((m, turn) => {
104
53
  if (m.role !== 'assistant')
105
54
  return;
106
- let n = 0;
107
- for (const [, info, body] of words(m).matchAll(FENCE)) {
108
- n += 1;
109
- const named = fenceName(info, n);
110
- out.push({
111
- id: `o${turn}.${n}`,
112
- name: named,
113
- kind: 'code',
114
- body,
115
- mime: MIME[suffix(named) || info.trim().toLowerCase()] ?? 'text/plain',
116
- });
117
- }
118
- pictures(m).forEach((href, i) => {
119
- out.push({ id: `oi${turn}.${i}`, name: 'Generated image', kind: 'image', href });
120
- });
55
+ artifacts(words(m)).forEach((a, n) => out.push({ id: `o${turn}.${n}`, name: a.name, kind: 'code', body: a.body, mime: a.mime }));
56
+ pictures(m).forEach((href, i) => out.push({ id: `oi${turn}.${i}`, name: 'Generated image', kind: 'image', href }));
121
57
  });
122
58
  return out;
123
59
  }
124
60
  /**
125
- * What went into this conversation: pictures asked about, files sent fenced
126
- * under their names (`compose` in `lib/attach`), and files held for the next
127
- * message.
61
+ * What went into this conversation: pictures asked about, files a turn carried,
62
+ * files sent fenced under their names (`compose` in `lib/attach`), and files
63
+ * held for the next message.
128
64
  */
129
65
  function sources(said, held) {
130
66
  const out = [];
131
67
  said.forEach((m, turn) => {
132
68
  if (m.role !== 'user')
133
69
  return;
134
- pictures(m).forEach((href, i) => {
135
- out.push({ id: `s${turn}.${i}`, name: 'Attached image', kind: 'image', href });
136
- });
137
- // Files a turn carried by reference: their names in the turn, their bytes in the workspace.
70
+ pictures(m).forEach((href, i) => out.push({ id: `s${turn}.${i}`, name: 'Attached image', kind: 'image', href }));
138
71
  const read = told(words(m));
139
72
  for (const f of read.carried?.attached ?? []) {
140
73
  out.push({ id: `sw${turn}.${f.id}`, name: f.name, kind: f.type.startsWith('image/') ? 'image' : 'file', file: f.id, mime: f.type });
141
74
  }
142
- let n = 0;
143
- for (const [, info, body] of read.text.matchAll(FENCE)) {
144
- n += 1;
145
- const named = info.trim();
146
- if (!suffix(named))
147
- continue;
148
- out.push({ id: `sf${turn}.${n}`, name: named, kind: 'file', body, mime: MIME[suffix(named)] ?? 'text/plain' });
149
- }
75
+ // A block sent under a file's name; a block that names only a language is not a source.
76
+ artifacts(read.text).forEach((a, n) => {
77
+ if (a.name.includes('.'))
78
+ out.push({ id: `sf${turn}.${n}`, name: a.name, kind: 'file', body: a.body, mime: a.mime });
79
+ });
150
80
  });
151
81
  for (const h of held) {
152
82
  out.push({ id: h.id, name: h.name, kind: h.type.startsWith('image/') ? 'image' : 'file', href: h.href || undefined, file: h.file?.id, mime: h.type });
153
83
  }
154
84
  return out;
155
85
  }
156
- const GLYPH = {
157
- image: ImageMark,
158
- code: FileCode,
159
- file: Paperclip,
160
- };
161
- /**
162
- * The column. `said` is the conversation it stands beside; without one it draws
163
- * the same shape with nothing in it, which is what a room nobody has spoken in
164
- * honestly looks like.
165
- */
166
- export function RightPane({ said = [], onClose, }) {
167
- const open = useOpen();
168
- // ONE KEY, DERIVED, so the pane follows the conversation without anything
169
- // having to tell it that the conversation changed.
170
- const at = channel(open);
171
- const pane = usePane(at);
172
- return (_jsxs(YStack, { flex: 1, minH: 320, bg: "$background", borderLeftWidth: 1, borderColor: "$borderColor", children: [pane.pinned ? _jsx(Summary, { at: at, said: said, held: pane.held }) : null, _jsx(Browser, { at: at, pane: pane, onClose: onClose })] }));
173
- }
174
- /** The two cards: out of the conversation, and into it. */
175
- function Summary({ at, said, held }) {
176
- // Recomputed only when the turns or the held files change — a channel switch
177
- // remounts with different turns and costs one pass over them, never a read.
178
- const made = useMemo(() => outputs(said), [said]);
179
- const took = useMemo(() => sources(said, held), [said, held]);
180
- return (_jsxs(YStack, { shrink: 0,
181
- // A CAP AND NOT A HEIGHT. The summary is as tall as it needs to be until
182
- // it would take the browser's half, and scrolls after that — so two rows
183
- // do not reserve a screen and thirty do not push the browser off the
184
- // bottom of the column.
185
- maxH: "45%", overflowY: "auto", overflowX: "hidden", p: "$3", gap: "$3", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(Marks, { at: at, kind: "outputs", title: "Outputs", icon: _jsx(FileCode, { size: 16, "aria-hidden": true }), marks: made, children: "This conversation has produced nothing yet." }), _jsx(Marks, { at: at, kind: "sources", title: "Sources", icon: _jsx(Paperclip, { size: 16, "aria-hidden": true }), marks: took, children: "Nothing has been put into this conversation yet." })] }));
86
+ /** What the conversation looked up: each call, what it asked, and what came back, keyed by the call. */
87
+ function lookups(said) {
88
+ const back = new Map();
89
+ for (const m of said)
90
+ if (m.role === 'tool' && m.tool_call_id)
91
+ back.set(m.tool_call_id, typeof m.content === 'string' ? m.content : '');
92
+ return said
93
+ .flatMap((m) => m.tool_calls ?? [])
94
+ .map((call) => ({ id: call.id, name: call.function.name, asked: call.function.arguments, got: back.get(call.id) ?? null }));
186
95
  }
96
+ const GLYPH = { image: ImageMark, code: FileCode, file: Paperclip };
187
97
  /**
188
- * One card of things, the first {@link FEW} of them, and the honest number of
189
- * the rest.
98
+ * Opens a mark as a page tab: a row under Artifacts or Sources, or a file a
99
+ * message carries (Chat's chips).
190
100
  *
191
- * THE COUNT IS THE LIST'S OWN LENGTH. A row reading "Show 30 more" that is not
192
- * thirty is the kind of furniture this repo has had to delete twice.
101
+ * A block is rendered from its own bytes, which the tab keeps, so it is there
102
+ * after a reload. A workspace file opens through a signed address minted now,
103
+ * the bytes this document still holds first (`localOf`).
193
104
  */
194
- function Marks({ at, kind, title, icon, marks, children, }) {
195
- const ai = useAi();
196
- const [all, setAll] = useState(false);
197
- const shown = all ? marks : marks.slice(0, FEW);
198
- const rest = marks.length - shown.length;
199
- return (_jsxs(Card, { kind: kind, icon: icon, title: title, action: _jsx(Add, { at: at }), children: [marks.length === 0 ? _jsx(Quiet, { children: children }) : null, shown.map((m) => {
200
- const Mark = GLYPH[m.kind];
201
- return (_jsx(Row, { icon: _jsx(Mark, { size: 16, "aria-hidden": true }), onPress: () => void look(at, m, ai), children: m.name }, m.id));
202
- }), rest > 0 ? (_jsxs(Row, { onPress: () => setAll(true), children: ["Show ", rest, " more"] })) : all && marks.length > FEW ? (_jsx(Row, { onPress: () => setAll(false), children: "Show fewer" })) : null] }));
105
+ export async function look(deck, m, api) {
106
+ if (m.body !== undefined)
107
+ return deck.open({ kind: PAGE, title: m.name, body: m.body, type: m.mime });
108
+ if (m.href)
109
+ return deck.open({ kind: PAGE, title: m.name, url: m.href, type: m.mime });
110
+ if (!m.file)
111
+ return;
112
+ const near = localOf(m.file);
113
+ if (near)
114
+ return deck.open({ kind: PAGE, title: m.name, url: near, type: m.mime });
115
+ try {
116
+ const f = await fileOf(api, m.file);
117
+ deck.open({ kind: PAGE, title: m.name, url: await download(api, f.bucket, f.key), type: m.mime });
118
+ }
119
+ catch {
120
+ // A file forgotten or a store unreachable opens nothing; the row stays.
121
+ }
203
122
  }
204
- /**
205
- * Opens a mark in this conversation's browser.
206
- *
207
- * A BLOCK BECOMES A FILE THE MOMENT SOMEBODY LOOKS AT IT. It has no address
208
- * until then — it is text inside a turn — so one is minted here from its own
209
- * bytes, carrying the type its fence declared. That is what makes an answer's
210
- * `index.html` something you can actually see rather than something you can
211
- * only read the source of.
212
- */
213
- export async function look(at, m, api) {
214
- const href = await addressOf(m, api);
215
- if (href)
216
- openTab(at, href, m.name);
123
+ /** A list of marks, every one of them; pressing one opens it. */
124
+ function Marks({ kind, title, icon, marks, deck, action, children }) {
125
+ const ai = useAi();
126
+ return (_jsxs(Card, { kind: kind, icon: icon, title: title, action: action, children: [marks.length === 0 ? _jsx(Quiet, { children: children }) : null, marks.map((m) => {
127
+ const Glyph = GLYPH[m.kind];
128
+ return (_jsx(Row, { icon: _jsx(Glyph, { size: 16, "aria-hidden": true }), onPress: () => void look(deck, m, ai), children: m.name }, m.id));
129
+ })] }));
217
130
  }
218
- /**
219
- * Where a mark can be opened: its own address, a signed one minted for its
220
- * workspace file, or one minted from its text. Null when there is none — a file
221
- * forgotten or a store unreachable opens nothing, and the row stays.
222
- */
223
- export async function addressOf(m, api) {
224
- if (!m.href && m.file) {
225
- try {
226
- const f = await fileOf(api, m.file);
227
- return await download(api, f.bucket, f.key);
228
- }
229
- catch {
230
- return null;
231
- }
232
- }
233
- return m.href ?? (m.body === undefined ? null : URL.createObjectURL(new Blob([m.body], { type: m.mime })));
131
+ /** Chat's kinds of tab, over the conversation `said` and the files `held` for the next message. */
132
+ export function useKinds({ at, said, held, deck }) {
133
+ const made = useMemo(() => outputs(said), [said]);
134
+ const took = useMemo(() => sources(said, held), [said, held]);
135
+ const done = useMemo(() => lookups(said), [said]);
136
+ return [
137
+ {
138
+ id: 'artifacts',
139
+ label: 'Artifacts',
140
+ render: () => (_jsx(Marks, { kind: "outputs", title: "Outputs", icon: _jsx(FileCode, { size: 16, "aria-hidden": true }), marks: made, deck: deck, children: "This conversation has produced nothing yet." })),
141
+ },
142
+ {
143
+ id: 'sources',
144
+ label: 'Sources',
145
+ render: () => (_jsxs(YStack, { gap: "$3", children: [_jsx(Marks, { kind: "sources", title: "Sources", icon: _jsx(Paperclip, { size: 16, "aria-hidden": true }), marks: took, deck: deck, action: _jsx(Add, { at: at }), children: "Nothing has been put into this conversation yet." }), _jsxs(Card, { kind: "lookups", icon: _jsx(Search, { size: 16, "aria-hidden": true }), title: "Looked up", children: [done.length === 0 ? _jsx(Quiet, { children: "Nothing was looked up for this conversation yet." }) : null, done.map((call) => (_jsxs(YStack, { gap: "$1", p: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(Text, { fontSize: "$2", color: "$ink", children: call.name }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 2, children: call.asked }), _jsx(Text, { fontSize: "$1", color: "$soft", numberOfLines: 6, children: call.got ?? 'Running…' })] }, call.id)))] })] })),
146
+ },
147
+ ];
234
148
  }
235
149
  /**
236
150
  * A menu row's words.
237
151
  *
238
152
  * gui RENDERS NO BARE STRING. Every view is a react-native-web `View`, which
239
- * refuses a raw text child — `DropdownMenuItem` happens to wrap one for you and
240
- * `DropdownMenuSubTrigger` does not, so a label written the obvious way threw
241
- * "Unexpected text node" and took the whole menu down with it. One wrapper for
242
- * every row means the difference between those two components stops mattering.
153
+ * refuses a raw text child — `DropdownMenuItem` wraps one for you and
154
+ * `DropdownMenuSubTrigger` does not — so every row is wrapped once here.
243
155
  */
244
156
  const Word = ({ children }) => (_jsx(Text, { fontSize: "$2", color: "$ink", numberOfLines: 1, children: children }));
245
- /** A menu row that is a statement rather than a control — what is being read,
246
- * what was refused, which code to type. Wrapped for the same reason. */
157
+ /** A menu row that is a statement rather than a control, wrapped for the same reason. */
247
158
  const Note = ({ children }) => (_jsx(DropdownMenuLabel, { children: _jsx(Text, { fontSize: "$2", color: "$soft", children: children }) }));
248
159
  /**
249
- * The `+`: everything a reader can bring into this conversation.
160
+ * The `+` on Sources: everything a reader can bring into this conversation.
250
161
  *
251
162
  * EVERY ROW DOES SOMETHING. Files come off the reader's own disk. Skills are
252
- * `GET /v1/tools/skills` and toggling one is `PUT /v1/tools/activation`, which
253
- * the schema calls "the ONE write path that turns skills, plugins and
254
- * connectors into callable tools". Services are
255
- * `GET /v1/integrations/connectors/providers` and connecting one is the device
256
- * flow those provider cards declare.
257
- *
258
- * There is no "Plugins" row. `/v1/tools/plugins` reads like the next one and is
259
- * an operator's inventory of what this deployment booted with — see the note in
260
- * `tools.ts`. A row offering a reader subsystems they cannot switch is a row
261
- * that does nothing.
262
- *
263
- * ONE PANEL, NOT FLYOUTS, and that is a fact about WHERE this menu is rather
264
- * than a preference. The reference has "Use plugins ▸" opening a submenu, which
265
- * works when the menu has screen to its side; this one hangs off a `+` at the
266
- * right edge of a column that is itself at the right edge of the window.
267
- * Measured at 1440: the sub-panel opened at x=1414 and is 190 wide, so 184px of
268
- * it — every row in it — was off the screen, and `Menu.SubContent` publishes no
269
- * placement to flip it with. Sections in one panel cannot fall off anything,
270
- * and they put a skill one press away instead of two.
163
+ * `GET /v1/tools/skills` and toggling one is `PUT /v1/tools/activation`, the
164
+ * one write path that turns skills, plugins and connectors into callable tools.
165
+ * Services are `GET /v1/integrations/connectors/providers`, connected by the
166
+ * device flow their cards declare. One panel, not flyouts: it hangs off the
167
+ * right edge of the window, where a submenu has no room to open.
271
168
  */
272
169
  function Add({ at }) {
273
170
  const ai = useAi();
274
171
  return (_jsxs(DropdownMenu, { placement: "bottom-end", maxHeight: 420, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Box, { render: "button", "aria-label": "Add to this conversation", p: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(Plus, { size: 16, "aria-hidden": true }) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [_jsx(DropdownMenuItem, { onClick: () => take(at, { api: ai, org: org() }), children: _jsx(Word, { children: "Attach files" }) }), _jsx(DropdownMenuSeparator, {}), _jsx(Skills, {}), _jsx(DropdownMenuSeparator, {}), _jsx(Services, {})] })] }));
275
172
  }
276
173
  /**
277
- * What this org's agents can reach for, as a section of this menu.
278
- *
279
- * A CHECKBOX ROW AND NOT A TICK IN A LABEL. `DropdownMenuCheckboxItem` is
280
- * `role="menuitemcheckbox"` with `aria-checked`, so a screen reader says
281
- * "checked" where a glyph in the text says nothing at all — and it draws the
282
- * mark in gui's own indicator well, which a hand-rolled column beside the label
283
- * cannot line up with. Activation is a two-state fact; this is the component
284
- * for one.
174
+ * What this org's agents can reach for, as checkbox rows: `menuitemcheckbox`
175
+ * with `aria-checked`, so the state is read as well as seen.
285
176
  */
286
177
  function Skills() {
287
178
  const { rows, trouble } = useSkills();
@@ -298,14 +189,10 @@ function Skills() {
298
189
  return (_jsxs(_Fragment, { children: [_jsx(Note, { children: "Skills" }), rows.map((s) => {
299
190
  const lit = isOn(s.name, s.activated);
300
191
  return (_jsx(DropdownMenuCheckboxItem, { checked: lit,
301
- // HELD OPEN, because switching two skills on is one errand and a
302
- // menu that shuts on the first makes it two.
192
+ // Held open: switching two skills on is one errand.
303
193
  onSelect: (e) => e?.preventDefault(), onCheckedChange: () => {
304
194
  setBusy(s.name);
305
- // THE ANSWER IS THE WHOLE SET, so what comes back replaces what
306
- // we hold rather than flipping our own flag: a refusal, a clamp,
307
- // or another tab's change is visible in the reply and invisible
308
- // in an optimistic toggle.
195
+ // The answer is the whole set, so a refusal or another tab's change shows.
309
196
  void activate(s.name, !lit)
310
197
  .then((enabled) => setOn(new Set(enabled)))
311
198
  .catch(() => { })
@@ -313,15 +200,7 @@ function Skills() {
313
200
  }, children: _jsx(Word, { children: busy === s.name ? `${s.name}…` : s.name }) }, s.name));
314
201
  })] }));
315
202
  }
316
- /**
317
- * What this account could connect.
318
- *
319
- * NARROWED TO THE ONE INTAKE THIS PANE CAN FINISH. A provider card declares its
320
- * methods — `device`, `oauth`, `token` — and the device flow is the only one
321
- * that completes without a redirect back into a page this column does not own.
322
- * Offering the others here would be offering a press that goes nowhere; they
323
- * are connected from the Directory, which is built for it.
324
- */
203
+ /** What this account could connect, narrowed to the device flow, the one intake this menu can finish. */
325
204
  function Services() {
326
205
  const { rows, trouble } = useProviders();
327
206
  const [step, setStep] = useState(null);
@@ -336,109 +215,10 @@ function Services() {
336
215
  setStep(`Starting ${p.name}…`);
337
216
  void startDevice(p.id)
338
217
  .then((begun) => {
339
- // The code is the reader's to type at the provider's page, so
340
- // it is said here and the page is opened for them.
218
+ // The code is the reader's to type at the provider's page.
341
219
  setStep(`Enter ${begun.userCode} at ${titleOf(begun.verifyUrl)}`);
342
220
  window.open(begun.verifyUrl, '_blank', 'noopener,noreferrer');
343
221
  })
344
222
  .catch((e) => setStep(String(e?.message ?? 'Could not start')));
345
223
  }, children: _jsx(Word, { children: p.name }) }, p.id)))] }));
346
224
  }
347
- /** The tab strip, the address, and whatever the chosen tab is showing. */
348
- function Browser({ at, pane, onClose, }) {
349
- const leaf = pane.tabs.find((t) => t.id === pane.at) ?? null;
350
- // Bumping this remounts the frame, which is the only way to reload a document
351
- // on an origin we cannot script into.
352
- const [again, reload] = useState(0);
353
- return (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsxs(XStack
354
- // 44 IS THE FLOOR A THUMB NEEDS, and the strip scrolls rather than
355
- // squeezing its tabs under it — the rule `tabs.tsx` exists to hold.
356
- , {
357
- // 44 IS THE FLOOR A THUMB NEEDS, and the strip scrolls rather than
358
- // squeezing its tabs under it — the rule `tabs.tsx` exists to hold.
359
- minH: 44, shrink: 0, items: "center", gap: "$1", px: "$2", overflowX: "auto", overflowY: "hidden", borderBottomWidth: 1, borderColor: "$borderColor", children: [pane.tabs.map((t) => (_jsx(Sheet, { at: at, leaf: t, on: t.id === pane.at }, t.id))), _jsx(Box, { render: "button", onClick: () => openTab(at, 'https://hanzo.ai'), "aria-label": "New tab", p: "$1.5", rounded: "$2", shrink: 0, hoverStyle: { bg: '$hover' }, children: _jsx(Plus, { size: 16, "aria-hidden": true }) }), _jsx(Box, { flex: 1, minW: "$1" }), leaf ? (_jsxs(_Fragment, { children: [_jsx(Box, { render: "button", onClick: () => reload((n) => n + 1), "aria-label": "Reload this tab", p: "$1.5", rounded: "$2", shrink: 0, hoverStyle: { bg: '$hover' }, children: _jsx(RotateCw, { size: 16, "aria-hidden": true }) }), _jsx(Box, { render: "button", onClick: () => window.open(leaf.url, '_blank', 'noopener,noreferrer'), "aria-label": "Open this tab in a new window", p: "$1.5", rounded: "$2", shrink: 0, hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 16, "aria-hidden": true }) })] })) : null, onClose ? (_jsx(Box, { render: "button", onClick: onClose, "aria-label": "Hide side panel", p: "$1.5", rounded: "$2", shrink: 0, hoverStyle: { bg: '$hover' }, children: _jsx(PanelRightClose, { size: 16, "aria-hidden": true }) })) : null] }), leaf ? _jsx(Where, { at: at, leaf: leaf }) : null, _jsx(YStack, { flex: 1, minH: 0, children: leaf ? (_jsx(Preview, { leaf: leaf, held: pane.held }, `${leaf.id}.${again}`)) : (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsx(Quiet, { children: "Nothing open here." }), _jsx(Quiet, { children: "Press an output or a source to look at it, or open a tab." })] })) })] }));
360
- }
361
- /** One tab: the thing you press, and the cross that shuts it. */
362
- function Sheet({ at, leaf, on }) {
363
- return (_jsxs(XStack, { items: "center", gap: "$1", pl: "$2", pr: "$1", minH: 32, maxW: 140, shrink: 0, rounded: "$2",
364
- // A CHOSEN TAB IS THE ONLY ONE WITH AN OUTLINE. Everything else in the
365
- // strip is a plain control that answers to hover, which is the rule the
366
- // rest of this pane follows.
367
- borderWidth: 1, borderColor: on ? '$borderColor' : 'transparent', bg: on ? '$raised' : 'transparent', hoverStyle: { bg: on ? '$raised' : '$hover' }, children: [_jsx(Box, { render: "button", onClick: () => pickTab(at, leaf.id), "aria-current": on, flex: 1, minW: 0, children: _jsx(Text, { fontSize: "$2", color: on ? '$ink' : '$soft', numberOfLines: 1, children: leaf.title }) }), _jsx(Box, { render: "button", onClick: () => shutTab(at, leaf.id), "aria-label": `Close ${leaf.title}`, p: "$1", rounded: "$1", hoverStyle: { bg: '$edge' }, children: _jsx(X, { size: 16, "aria-hidden": true }) })] }));
368
- }
369
- /**
370
- * Where the open tab is, and how to go somewhere else.
371
- *
372
- * A PATH FOR A FILE AND A FIELD FOR A PAGE, because they are different
373
- * questions: a blob has no address a reader can retype, so offering one is
374
- * offering a control that cannot work. The field is the one place in this pane
375
- * with an outline, which is what the house rule reserves an outline for.
376
- */
377
- function Where({ at, leaf }) {
378
- const local = leaf.url.startsWith('blob:');
379
- const [typed, setTyped] = useState(leaf.url);
380
- if (local) {
381
- return (_jsx(XStack, { px: "$3", py: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: _jsx(Text, { fontSize: "$2", color: "$soft", numberOfLines: 1, children: leaf.title }) }));
382
- }
383
- return (_jsx(XStack, { items: "center", px: "$2", py: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: _jsx(XStack, { flex: 1, minW: 0, items: "center", px: "$2", minH: 28, rounded: "$2", borderWidth: 1, borderColor: "$borderColor", bg: "$raised", children: _jsx(Text, { render: _jsx("input", {
384
- // KEYED BY THE TAB at the call site above, so switching tabs starts
385
- // this field at the new tab's address rather than carrying the old
386
- // one's half-typed text across.
387
- defaultValue: leaf.url, onChange: (e) => setTyped(e.target.value), onKeyDown: (e) => {
388
- if (e.key === 'Enter')
389
- openTab(at, address(typed), titleOf(address(typed)));
390
- }, "aria-label": "Address", spellCheck: false }), width: "100%", borderWidth: 0, outlineStyle: "none", bg: "transparent", color: "var(--foreground)", fontSize: "$2" }) }) }));
391
- }
392
- /**
393
- * What a tab is showing, or why it is not.
394
- *
395
- * A BROWSER CANNOT SHOW EVERY FILE AND SAYING SO IS THE FEATURE. An archive, a
396
- * font, a binary — a frame pointed at one either downloads it or draws nothing,
397
- * and drawing nothing is indistinguishable from a broken pane. The type comes
398
- * off the held file where there is one, because a blob address carries no
399
- * extension to guess from.
400
- */
401
- function Preview({ leaf, held }) {
402
- const mine = held.find((h) => h.href === leaf.url);
403
- const type = mine?.type ?? '';
404
- const ext = suffix(mine?.name ?? leaf.title);
405
- if (type.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg'].includes(ext)) {
406
- return (_jsx(YStack, { flex: 1, minH: 0, items: "center", justify: "center", p: "$3", overflowY: "auto", overflowX: "hidden", children: _jsx(View, { render: _jsx("img", { src: leaf.url, alt: leaf.title }), maxW: "100%", maxH: "100%", objectFit: "contain" }) }));
407
- }
408
- // THE LIST IS OF WHAT A FRAME CAN RENDER, not of what a file might be. A type
409
- // absent from it is unsupported, which is the honest default: guessing that
410
- // an unknown type will render is how a reader gets a blank rectangle.
411
- const shows = !mine ||
412
- type.startsWith('text/') ||
413
- type === 'application/pdf' ||
414
- type === 'application/json' ||
415
- ['html', 'htm', 'txt', 'md', 'json', 'csv', 'pdf', 'css', 'js', 'ts'].includes(ext);
416
- if (!shows) {
417
- return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsxs(Quiet, { children: [(ext || type || 'This').toUpperCase(), " previews aren\u2019t supported yet."] }), _jsx(Quiet, { children: "Open this file in another app to view it." })] }));
418
- }
419
- return (_jsx(View, { render: _jsx("iframe", { src: leaf.url, title: leaf.title,
420
- // TWO SANDBOXES, BECAUSE THERE ARE TWO KINDS OF DOCUMENT HERE AND ONE
421
- // RULE WOULD BE WRONG FOR ONE OF THEM.
422
- //
423
- // A `blob:` address INHERITS THE ORIGIN OF THE PAGE THAT MINTED IT — this
424
- // one. So a model's fenced block granted `allow-same-origin` would run as
425
- // us, with our `localStorage`, which is where the IAM access token lives
426
- // (`hanzo_iam_access_token`). An answer that wrote a script tag would be
427
- // reading the reader's session. It gets `allow-scripts` so a generated
428
- // page still lays out and runs, and never the pair.
429
- //
430
- // A remote page is the opposite case: the iframe takes the REMOTE
431
- // origin, so `allow-same-origin` grants it nothing of ours and withholding
432
- // it breaks the site — no cookies, no storage, no fonts on many hosts.
433
- sandbox: leaf.url.startsWith('blob:')
434
- ? 'allow-scripts allow-popups allow-forms'
435
- : 'allow-same-origin allow-scripts allow-popups allow-forms' }), width: "100%", height: "100%", borderWidth: 0, bg: "var(--background)" }));
436
- }
437
- /** Shows or hides the summary above this conversation's browser. Exported for
438
- * the conversation header, which is where the control belongs. */
439
- export function usePinned() {
440
- const open = useOpen();
441
- const at = channel(open);
442
- const pane = usePane(at);
443
- return [pane.pinned, () => pin(at, !pane.pinned)];
444
- }