babavoss 0.12.6 → 0.13.1
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/gui/babavoss-web.js +4 -1
- package/gui/{chunk-9gmmm54v.js → chunk-qgzmajan.js} +619 -574
- package/gui/gui.js +1538 -277
- package/gui/theme.css +2007 -1128
- package/index.ts +1 -1
- package/package.json +5 -5
- package/src/{promptware → agents}/compile.ts +23 -31
- package/src/{promptware → agents}/define.ts +2 -2
- package/src/{promptware → agents}/disk.ts +7 -4
- package/src/{promptware → agents}/sync.ts +2 -2
- package/src/{promptware → agents}/system.ts +17 -17
- package/src/baba/check.ts +3 -3
- package/src/baba/config.ts +5 -2
- package/src/baba/init.ts +13 -18
- package/src/baba/node.ts +2 -2
- package/src/baba/project.ts +2 -2
- package/src/baba/requirements.ts +39 -0
- package/src/baba/setup.ts +30 -0
- package/src/baba/worker.ts +13 -5
- package/src/build/mdx.ts +4 -4
- package/src/build/project.ts +6 -1
- package/src/build/views.ts +3 -3
- package/src/desktop/desktop.css +35 -39
- package/src/desktop/index.ts +9 -7
- package/src/desktop/keys.ts +153 -0
- package/src/desktop/view.tsx +174 -130
- package/src/door/core.ts +2 -2
- package/src/ecs/baba.ts +27 -17
- package/src/generated/build.ts +1 -1
- package/src/gui/gui.tsx +21 -31
- package/src/gui/index.ts +6 -2
- package/src/gui/{promptware.tsx → instructions.tsx} +12 -12
- package/src/gui/levels.tsx +241 -59
- package/src/gui/lockup.ts +11 -0
- package/src/gui/settings.tsx +241 -0
- package/src/gui/setup.tsx +77 -0
- package/src/gui/theme.css +205 -58
- package/src/gui/theme.ts +9 -5
- package/src/gui/voss-settings.ts +48 -0
- package/src/gui/wizard.tsx +17 -40
- package/src/guide/add-a-desktop.mdx +7 -7
- package/src/guide/index.ts +4 -4
- package/src/guide/write-a-system.mdx +3 -3
- package/src/guide/{write-promptware.mdx → write-agentic-instructions.mdx} +17 -24
- package/src/http/server.ts +140 -17
- package/src/{prompt → instructions}/evals.ts +1 -1
- package/src/{prompt → instructions}/index.ts +9 -8
- package/src/{prompt → instructions}/jsx-runtime.ts +3 -3
- package/src/mcp/main.ts +3 -3
- package/src/mcp/tools.ts +4 -4
- package/src/runtime/harness.ts +2 -2
- package/src/server/edge.ts +31 -3
- package/src/server/main.ts +2 -1
- package/src/server/messages.ts +47 -7
- package/src/shell/run.ts +18 -18
- package/src/spec/index.ts +1 -1
- package/src/web/core.tsx +2 -2
- package/src/web/index.tsx +1 -1
- package/src/desktop/bob.ts +0 -76
- /package/src/{promptware → agents}/markdown.d.ts +0 -0
- /package/src/{prompt → instructions}/jsx-dev-runtime.ts +0 -0
- /package/src/{prompt → instructions}/mdx.d.ts +0 -0
package/src/desktop/view.tsx
CHANGED
|
@@ -4,9 +4,9 @@ import { useColumn, useHand, useResource } from "../web/hooks.ts";
|
|
|
4
4
|
import { useScope } from "../web/core.tsx";
|
|
5
5
|
import { useBaba } from "../web/state.tsx";
|
|
6
6
|
import type { Hand } from "../web/app.tsx";
|
|
7
|
-
import { Bob } from "./bob.ts";
|
|
8
7
|
import type { IconShape } from "./icons.ts";
|
|
9
8
|
import { canonicalPath, matchRoute } from "./routes.ts";
|
|
9
|
+
import { commandOf, formatBinding, keymapOf, type Keymap, type KeyOverrides } from "./keys.ts";
|
|
10
10
|
import { searchApps, type DesktopSystem, type DesktopApp, type DesktopWindow, type Theme, type Session } from "./index.ts";
|
|
11
11
|
|
|
12
12
|
/** What an app receives from the shell: where it is, what its window remembers, and the shell's verbs, so it never names an action. */
|
|
@@ -46,7 +46,9 @@ export interface DesktopProps {
|
|
|
46
46
|
address?: boolean;
|
|
47
47
|
/** Where the address begins: the baba's own, `/NAME`, unless given. */
|
|
48
48
|
base?: string;
|
|
49
|
-
/**
|
|
49
|
+
/** The person's own settings, voss's, the same on every baba: the theme over the baba's, and the keys remapped. */
|
|
50
|
+
settings?: { theme?: Theme; keys?: KeyOverrides } | null;
|
|
51
|
+
/** Where this baba was opened from, voss's picker: the launcher's last row, Leave. A page inside a baba goes nowhere else. */
|
|
50
52
|
root?: Root;
|
|
51
53
|
}
|
|
52
54
|
/** The way out of a baba: the picker's name and address. */
|
|
@@ -54,8 +56,10 @@ export interface Root { label: string; href: string }
|
|
|
54
56
|
type Row<T> = T & { entity: Entity };
|
|
55
57
|
/** The plus of a new session in the launcher. */
|
|
56
58
|
const plusIcon: readonly IconShape[] = [{ tag: "path", attrs: { d: "M5 12h14" } }, { tag: "path", attrs: { d: "M12 5v14" } }];
|
|
57
|
-
/** The way out, in the launcher: an arrow leaving a box. */
|
|
58
|
-
const leaveIcon: readonly IconShape[] = [{ tag: "path", attrs: { d: "
|
|
59
|
+
/** The way out, on the mark and in the launcher: an arrow leaving a box to the left, toward the bar's edge, where voss is; Lucide's log-out, mirrored. */
|
|
60
|
+
const leaveIcon: readonly IconShape[] = [{ tag: "path", attrs: { d: "M15 21h4a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-4" } }, { tag: "polyline", attrs: { points: "8 17 3 12 8 7" } }, { tag: "line", attrs: { x1: "3", x2: "15", y1: "12", y2: "12" } }];
|
|
61
|
+
/** The search's icon in the launcher: a magnifier. */
|
|
62
|
+
const searchIcon: readonly IconShape[] = [{ tag: "circle", attrs: { cx: "11", cy: "11", r: "8" } }, { tag: "path", attrs: { d: "m21 21-4.3-4.3" } }];
|
|
59
63
|
/** A session's icon in the launcher: a window with its bar. */
|
|
60
64
|
const sessionIcon: readonly IconShape[] = [{ tag: "rect", attrs: { x: "3", y: "5", width: "18", height: "14", rx: "2" } }, { tag: "path", attrs: { d: "M3 9.5h18" } }];
|
|
61
65
|
/** In an old hash, the tab's own session rather than a named one. */
|
|
@@ -76,8 +80,13 @@ export interface Picture {
|
|
|
76
80
|
sessions?: SessionRow[];
|
|
77
81
|
/** The baba's theme; the session's old field when a picture has none. */
|
|
78
82
|
theme?: Theme;
|
|
83
|
+
/** The keys the person remapped, voss's settings'; every other command has its default. */
|
|
84
|
+
keys?: KeyOverrides;
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
/** How the launcher shows: as the mark's tab, a place with its own address, or quick, over the app in front, which Escape goes back to. */
|
|
88
|
+
export type LauncherMode = "tab" | "quick";
|
|
89
|
+
|
|
81
90
|
/** The address of a window: the baba's base, the app, its path, which is left off at the app's home; the base alone is the launcher. */
|
|
82
91
|
export function desktopAddress(base: string, app?: string, path = "/") {
|
|
83
92
|
return app ? `${base}/${encodeURIComponent(app)}${path === "/" ? "" : path}` : `${base}/`;
|
|
@@ -116,7 +125,7 @@ function addressedSession(): string | undefined {
|
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
/** Only the desktop columns are subscribed here; a busy pond does not redraw the bar. */
|
|
119
|
-
export function Desktop({ system, apps, surfaces, session: fixed, title = "Desktop", address = true, base: givenBase, root }: DesktopProps) {
|
|
128
|
+
export function Desktop({ system, apps, surfaces, session: fixed, title = "Desktop", address = true, base: givenBase, root, settings: person }: DesktopProps) {
|
|
120
129
|
const renderers = apps ?? surfaces ?? {};
|
|
121
130
|
const scope = useScope();
|
|
122
131
|
const base = givenBase ?? `/${encodeURIComponent(scope)}`;
|
|
@@ -125,7 +134,11 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
|
|
|
125
134
|
const iconRows = useColumn(selectors.icons, system.desktopIcon);
|
|
126
135
|
const sessions = useColumn(selectors.sessions, system.desktopSession);
|
|
127
136
|
const windows = useColumn(selectors.windows, system.desktopWindow);
|
|
128
|
-
|
|
137
|
+
// The person's settings: voss's pages look and key alike on every baba. Until they arrive, the theme the page's head put on the root element
|
|
138
|
+
// from this browser's memory of them, else the system's; only a desktop inside another page wears the baba's own.
|
|
139
|
+
const babaTheme = useResource(system.desktopSettings)?.theme;
|
|
140
|
+
const remembered = address ? document.documentElement.dataset.theme as Theme | undefined : undefined;
|
|
141
|
+
const theme: Theme = person?.theme ?? (address ? remembered ?? "system" : babaTheme ?? "system");
|
|
129
142
|
const { fire, ready, connected } = useBaba<DesktopSystem>();
|
|
130
143
|
const [error, setError] = useState<string | null>(null);
|
|
131
144
|
// The tab's session: the one fixed by the page, else the address's, else the one this tab had, else none yet, and the tab enters a new one.
|
|
@@ -151,6 +164,7 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
|
|
|
151
164
|
return { entity, key: value.key, touched: value.touched ?? 0, windows: wins.length, front: front ? front.title || (front.path === "/" ? s?.title ?? null : front.path) : null };
|
|
152
165
|
}).sort((a, b) => b.touched - a.touched || b.entity - a.entity),
|
|
153
166
|
theme,
|
|
167
|
+
...(person?.keys ? { keys: person.keys } : {}),
|
|
154
168
|
} : null;
|
|
155
169
|
const hand: Hand = { fire: (name, args) => {
|
|
156
170
|
setError(null);
|
|
@@ -185,7 +199,12 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
|
|
|
185
199
|
const pending = useRef<string | null>(null);
|
|
186
200
|
const initialized = useRef(false);
|
|
187
201
|
// Asked by the address for the launcher, or for an app, which closes it: counts, so every arrival asks again.
|
|
188
|
-
|
|
202
|
+
// What the address names is known before the first frame: the launcher shows at once for the base alone, and holds back for an app on its way, so neither flashes the other.
|
|
203
|
+
const first = useMemo(() => address ? parseDesktopAddress(base, location.pathname) : null, []);
|
|
204
|
+
const [launch, setLaunch] = useState(first && !first.app ? 1 : 0);
|
|
205
|
+
// An app the address asked for, on its way: until it is in front the launcher holds back, so opening an app's address never flashes it.
|
|
206
|
+
const [arriving, setArriving] = useState(!!first?.app);
|
|
207
|
+
useEffect(() => { if (!arriving) return; const t = setTimeout(() => setArriving(false), 2000); return () => clearTimeout(t); }, [arriving]);
|
|
189
208
|
const [dismiss, setDismiss] = useState(0);
|
|
190
209
|
/** Goes to a session: another one, or a new one. The address is written first, to what is in front there, so the adapter agrees with the key rather than pulling the tab back; it replaces the entry, since the address names no session and Back could not return to this one. */
|
|
191
210
|
const go = (next: string | null) => {
|
|
@@ -213,9 +232,11 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
|
|
|
213
232
|
const expected = desktopAddress(base, route.app, path);
|
|
214
233
|
if (expected === current.current) { if (location.pathname !== expected) history.replaceState(null, "", expected); return; }
|
|
215
234
|
pending.current = expected;
|
|
235
|
+
setArriving(true);
|
|
216
236
|
void fire("desktop-open", { session: key, app: route.app, path }).catch((e) => {
|
|
217
237
|
if (!live) return;
|
|
218
238
|
pending.current = null;
|
|
239
|
+
setArriving(false);
|
|
219
240
|
setError(String(e));
|
|
220
241
|
if (current.current) history.replaceState(null, "", current.current);
|
|
221
242
|
});
|
|
@@ -228,9 +249,12 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
|
|
|
228
249
|
}, [address, ready, key, !!picture, base, fire]);
|
|
229
250
|
useEffect(() => {
|
|
230
251
|
if (!address || !initialized.current || !here) return;
|
|
252
|
+
// Nothing in front yet while the address's app is on its way: the ask is not over, and the address stays as it is.
|
|
253
|
+
if (pending.current && here === desktopAddress(base)) return;
|
|
231
254
|
// What the address asked for has come, or something else came first: either way the ask is over. Arriving where the address asked rewrites its spelling, no new entry.
|
|
232
255
|
const asked = pending.current === here;
|
|
233
256
|
pending.current = null;
|
|
257
|
+
setArriving(false);
|
|
234
258
|
if (here === agreed.current) return;
|
|
235
259
|
agreed.current = here;
|
|
236
260
|
if (location.pathname !== here) {
|
|
@@ -238,25 +262,41 @@ export function Desktop({ system, apps, surfaces, session: fixed, title = "Deskt
|
|
|
238
262
|
else history.pushState(null, "", here);
|
|
239
263
|
}
|
|
240
264
|
}, [address, here, base]);
|
|
241
|
-
/**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
265
|
+
/**
|
|
266
|
+
* The launcher's tab is a place: chosen on the page, its address is pushed, with the address it was chosen over, so Back returns there.
|
|
267
|
+
* Left with nothing new chosen, the address goes back to what is in front: by Back when this page pushed the entry over that very address,
|
|
268
|
+
* else in place, since the entry before it may be another app's (Back from a later one came here). A choice is left to the writer above, which pushes it.
|
|
269
|
+
* The quick launcher, ⌘P's, is not a place: the address never hears of it.
|
|
270
|
+
*/
|
|
271
|
+
const launcherShown = (mode: LauncherMode | null) => {
|
|
272
|
+
if (!address || !current.current) return;
|
|
273
|
+
const at = location.pathname === `${base}/`;
|
|
274
|
+
if (mode === "tab") { if (!at) history.pushState({ launcher: true, over: current.current }, "", `${base}/`); return; }
|
|
275
|
+
if (current.current !== agreed.current || !at || current.current === `${base}/`) return;
|
|
276
|
+
const entry = history.state as { launcher?: boolean; over?: string } | null;
|
|
277
|
+
if (entry?.launcher && entry.over === current.current) history.back(); else history.replaceState(null, "", current.current);
|
|
245
278
|
};
|
|
246
279
|
|
|
247
280
|
return <>
|
|
248
281
|
{error && <div className="desktop-error" role="alert">{error}<button onClick={() => setError(null)}>Dismiss</button></div>}
|
|
249
282
|
{!connected && ready && <p className="desktop-error" role="status">Reconnecting to the desktop…</p>}
|
|
250
|
-
{picture ? <DesktopFrame picture={picture} title={title} hand={hand} root={root} go={fixed ? undefined : go} launch={launch} dismiss={dismiss} onLauncher={launcherShown} render={(app, props) => {
|
|
283
|
+
{picture ? <DesktopFrame picture={picture} title={title} hand={hand} root={root} documentTitle={address} launcherHref={address ? `${base}/` : undefined} go={fixed ? undefined : go} launch={launch} dismiss={dismiss} arriving={arriving} onLauncher={launcherShown} render={(app, props) => {
|
|
251
284
|
const View = renderers[app.key];
|
|
252
285
|
return View ? <View {...props} /> : <p className="desktop-empty">No component is composed for the app {app.title}.</p>;
|
|
253
|
-
}} /> : <
|
|
286
|
+
}} /> : <DesktopSkeleton title={title} />}
|
|
254
287
|
</>;
|
|
255
288
|
}
|
|
256
289
|
|
|
257
|
-
/** The
|
|
258
|
-
export function
|
|
259
|
-
return <
|
|
290
|
+
/** The desktop before its state arrives: the bar and the mark on the desk, as the desktop will draw them, so the page never flashes another look while it loads. */
|
|
291
|
+
export function DesktopSkeleton({ title }: { title?: string }) {
|
|
292
|
+
return <div className="desktop" aria-busy="true" aria-label="Opening the desktop">
|
|
293
|
+
<header className="desktop-bar"><span className="desktop-pill desktop-pill-mark"><span className="desktop-pill-label"><span className="desktop-pill-icon"><Eyes size={18} /></span>{title && <PillText text={title} />}</span></span></header>
|
|
294
|
+
</div>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The two eyes, the mark of Baba Voss, drawn in the current color. Without `large`, the larger eye is away and the smaller one moves to the middle. */
|
|
298
|
+
export function Eyes({ size = 16, large = true }: { size?: number; large?: boolean }) {
|
|
299
|
+
return <svg className={`desktop-eyes${large ? "" : " is-alone"}`} viewBox="0 0 100 100" width={size} height={size} aria-hidden="true" fill="currentColor"><circle className="desktop-eye-large" cx="33.772" cy="50" r="18" /><circle className="desktop-eye-small" cx="71.5" cy="50" r="12.728" /></svg>;
|
|
260
300
|
}
|
|
261
301
|
|
|
262
302
|
/**
|
|
@@ -291,7 +331,7 @@ const middleCloses = (close: (() => void) | undefined) => close ? {
|
|
|
291
331
|
onAuxClick: (e: { button: number; preventDefault(): void }) => { if (e.button === 1) { e.preventDefault(); close(); } },
|
|
292
332
|
} : {};
|
|
293
333
|
|
|
294
|
-
function WindowPill({ label, active, hand, icon, shortcut, enter, onSelect, onClose }: { label: string; active: boolean; hand?: Hand; icon: ReactNode; shortcut?:
|
|
334
|
+
function WindowPill({ label, active, hand, icon, shortcut, enter, onSelect, onClose }: { label: string; active: boolean; hand?: Hand; icon: ReactNode; shortcut?: string; enter?: boolean; onSelect(): void; onClose(): void }) {
|
|
295
335
|
const pill = useRef<HTMLSpanElement>(null);
|
|
296
336
|
// Arriving, a pill drops into its place from above the strip, which clips it, and lands with a small bounce; it takes its place at once, so no other pill moves.
|
|
297
337
|
useLayoutEffect(() => {
|
|
@@ -303,23 +343,32 @@ function WindowPill({ label, active, hand, icon, shortcut, enter, onSelect, onCl
|
|
|
303
343
|
}, []);
|
|
304
344
|
return <span ref={pill} className={`desktop-pill${active ? " is-active" : ""}`} role="tab" aria-selected={active} title={label} {...middleCloses(hand && onClose)}>
|
|
305
345
|
<button type="button" className="desktop-pill-label" disabled={!hand} onClick={onSelect}><span className="desktop-pill-icon">{icon}</span><PillText text={label} /></button>
|
|
306
|
-
{hand && <button type="button" className="desktop-pill-close" aria-label={`Close ${label}`} title={`Close ${label}${shortcut ?
|
|
346
|
+
{hand && <button type="button" className="desktop-pill-close" aria-label={`Close ${label}`} title={`Close ${label}${shortcut ? ` (${shortcut})` : ""}`} onClick={onClose}><Cross /></button>}
|
|
307
347
|
</span>;
|
|
308
348
|
}
|
|
309
349
|
|
|
310
|
-
/** The
|
|
311
|
-
function
|
|
312
|
-
const ref = useRef<
|
|
350
|
+
/** The classes that fade a scrolling box into whatever is behind it at each edge with more beyond it: sideways, `is-faded-left` and `is-faded-right`; down, `is-faded-top` and `is-faded-bottom`. */
|
|
351
|
+
function useScrollFade<T extends HTMLElement>(axis: "x" | "y") {
|
|
352
|
+
const ref = useRef<T>(null);
|
|
313
353
|
const [fade, setFade] = useState("");
|
|
314
354
|
useEffect(() => {
|
|
315
355
|
const el = ref.current;
|
|
316
356
|
if (!el || typeof ResizeObserver === "undefined") return;
|
|
317
|
-
const measure = () =>
|
|
357
|
+
const measure = () => {
|
|
358
|
+
const [at, size, whole, start, end] = axis === "x" ? [el.scrollLeft, el.clientWidth, el.scrollWidth, "left", "right"] : [el.scrollTop, el.clientHeight, el.scrollHeight, "top", "bottom"];
|
|
359
|
+
setFade(`${at > 1 ? ` is-faded-${start}` : ""}${at + size < whole - 1 ? ` is-faded-${end}` : ""}`);
|
|
360
|
+
};
|
|
318
361
|
measure();
|
|
319
362
|
const ro = new ResizeObserver(measure); ro.observe(el); for (const child of el.children) ro.observe(child);
|
|
320
363
|
el.addEventListener("scroll", measure, { passive: true });
|
|
321
364
|
return () => { ro.disconnect(); el.removeEventListener("scroll", measure); };
|
|
322
365
|
});
|
|
366
|
+
return { ref, fade };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The strip of pills: it scrolls sideways, and fades into whatever is behind it at an edge with more beyond it. */
|
|
370
|
+
function Pills({ children, label }: { children: ReactNode; label: string }) {
|
|
371
|
+
const { ref, fade } = useScrollFade<HTMLDivElement>("x");
|
|
323
372
|
return <div ref={ref} className={`desktop-pills${fade}`} role="tablist" aria-label={label} onWheel={(e) => { const el = ref.current; if (el && !e.deltaX && e.deltaY && el.scrollWidth > el.clientWidth) { el.scrollLeft += e.deltaY; } }}>{children}</div>;
|
|
324
373
|
}
|
|
325
374
|
|
|
@@ -350,46 +399,72 @@ function rank(texts: string[], words: string[]): number | null {
|
|
|
350
399
|
}
|
|
351
400
|
|
|
352
401
|
/** The same drawing is used live and for scenario frames. It never fetches state. */
|
|
353
|
-
export function DesktopFrame({ picture, title, hand, render, root, go, launch, dismiss, onLauncher }: {
|
|
402
|
+
export function DesktopFrame({ picture, title, hand, render, root, documentTitle = false, launcherHref, go, launch, dismiss, arriving = false, onLauncher }: {
|
|
354
403
|
picture: Picture; title: string; hand?: Hand; render(app: Row<DesktopApp>, props: AppProps): ReactNode;
|
|
355
|
-
/** The picker this baba was opened from:
|
|
404
|
+
/** The picker this baba was opened from: Leave, the launcher's last row. */
|
|
356
405
|
root?: Root;
|
|
406
|
+
/** Names the page after the tab in front, the window's title then the desktop's, or the desktop alone on the launcher's; for a frame that is the whole page. */
|
|
407
|
+
documentTitle?: boolean;
|
|
408
|
+
/** The launcher's own address, which a ⌘/Ctrl or middle click on the mark opens in a new browser tab. */
|
|
409
|
+
launcherHref?: string;
|
|
357
410
|
/** Takes the tab to another session by key, or to a new one with null; without it the launcher lists no sessions. */
|
|
358
411
|
go?: (key: string | null) => void;
|
|
359
412
|
/** Opens the launcher each time it changes: the page's address asked for it. */
|
|
360
413
|
launch?: number;
|
|
414
|
+
/** An app is on its way to the front: with nothing in front meanwhile, the page shows an empty sheet, not the launcher. */
|
|
415
|
+
arriving?: boolean;
|
|
361
416
|
/** Closes the launcher each time it changes: the page's address named an app. */
|
|
362
417
|
dismiss?: number;
|
|
363
|
-
/** Told
|
|
364
|
-
onLauncher?: (
|
|
418
|
+
/** Told how the launcher shows, or that it does not, each time that changes after the first drawing. */
|
|
419
|
+
onLauncher?: (mode: LauncherMode | null) => void;
|
|
365
420
|
}) {
|
|
366
421
|
const { session: w, apps, windows } = picture;
|
|
422
|
+
const keymap: Keymap = keymapOf(picture.keys);
|
|
423
|
+
const keyOf = (c: keyof Keymap) => { const b = keymap[c]; return b === null ? undefined : formatBinding(b); };
|
|
367
424
|
const sessions = go ? picture.sessions ?? [] : [];
|
|
368
425
|
const frame = useRef<HTMLDivElement>(null), input = useRef<HTMLInputElement>(null);
|
|
369
426
|
const resultsId = useId();
|
|
370
427
|
const fire = (name: string, args: Record<string, unknown>) => hand?.fire(name, { ...args, session: w.key });
|
|
371
428
|
const open = (app: string, path?: string) => fire("desktop-open", { app, ...(path === undefined ? {} : { path }) });
|
|
372
|
-
// The launcher
|
|
373
|
-
|
|
429
|
+
// The launcher shows two ways. The mark chooses its tab, a place: it takes the app's place, the mark filled, its address the page's. ⌘P opens it quick, over
|
|
430
|
+
// the app in front, which stays its tab and shows dimmed behind; Escape goes back to it as it was. While nothing is in front the tab is all there is.
|
|
431
|
+
const [mode, setMode] = useState<LauncherMode | null>(() => launch ? "tab" : null);
|
|
374
432
|
// A choice holds the launcher over the page until the state shows what was chosen, so the app behind never flashes between; a moment at most.
|
|
375
|
-
const [awaiting, setAwaiting] = useState<{ from: Entity | null } | null>(null);
|
|
433
|
+
const [awaiting, setAwaiting] = useState<{ from: Entity | null; mode: LauncherMode } | null>(null);
|
|
376
434
|
useEffect(() => { if (awaiting && w.selected !== awaiting.from) setAwaiting(null); }, [w.selected, awaiting]);
|
|
377
435
|
useEffect(() => { if (!awaiting) return; const t = setTimeout(() => setAwaiting(null), 800); return () => clearTimeout(t); }, [awaiting]);
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
436
|
+
const shownMode: LauncherMode | null = w.selected === null ? (arriving && mode === null ? null : "tab") : mode ?? awaiting?.mode ?? null;
|
|
437
|
+
const launcher = shownMode !== null, quick = shownMode === "quick";
|
|
438
|
+
/** Where focus was when the quick launcher opened, so leaving it gives focus back. */
|
|
439
|
+
const before = useRef<HTMLElement | null>(null);
|
|
440
|
+
const openQuick = () => { before.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null; setMode("quick"); };
|
|
441
|
+
/** Leaves the launcher, whichever way it shows; leaving the quick one gives focus back to where it was. */
|
|
442
|
+
const leave = () => {
|
|
443
|
+
const back = quick ? before.current : null;
|
|
444
|
+
setMode(null); input.current?.blur(); before.current = null;
|
|
445
|
+
if (back?.isConnected) back.focus({ preventScroll: true });
|
|
446
|
+
};
|
|
447
|
+
useEffect(() => { if (launch) setMode("tab"); }, [launch]);
|
|
448
|
+
useEffect(() => { if (dismiss) { setMode(null); input.current?.blur(); } }, [dismiss]);
|
|
449
|
+
// The quick launcher is over the app in front: with that gone it has nothing to go back to, and ends, so a window arriving later is not covered.
|
|
450
|
+
useEffect(() => { if (w.selected === null) setMode((m) => (m === "quick" ? null : m)); }, [w.selected]);
|
|
381
451
|
// Told only when it changes: as the frame first draws, the address has not been read yet.
|
|
382
452
|
const told = useRef(onLauncher); told.current = onLauncher;
|
|
383
|
-
const shown = useRef(
|
|
384
|
-
useEffect(() => { if (shown.current !==
|
|
385
|
-
// Each time the launcher closes the mark's eyes settle back into place, so the mark is keyed by how many times it has closed.
|
|
386
|
-
const [settles, setSettles] = useState(0);
|
|
387
|
-
const [was, setWas] = useState(launcher);
|
|
388
|
-
if (was !== launcher) { setWas(launcher); if (!launcher) setSettles(settles + 1); }
|
|
453
|
+
const shown = useRef(shownMode);
|
|
454
|
+
useEffect(() => { if (shown.current !== shownMode) { shown.current = shownMode; told.current?.(shownMode); } }, [shownMode]);
|
|
389
455
|
// Pills that arrive after the first drawing grow into the strip; those already open when the page opens are simply there.
|
|
390
456
|
const firstDrawn = useRef(false);
|
|
391
457
|
useEffect(() => { firstDrawn.current = true; }, []);
|
|
392
|
-
|
|
458
|
+
/** The mark is a tab that is never closed: choosing it shows the launcher's tab, and choosing it again only gives the search focus. With ⌘/Ctrl, or the middle button, it opens the launcher in a new browser tab. */
|
|
459
|
+
const chooseMark = (e: { metaKey: boolean; ctrlKey: boolean; button: number }) => {
|
|
460
|
+
if (launcherHref && (e.metaKey || e.ctrlKey || e.button === 1)) { window.open(launcherHref, "_blank"); return; }
|
|
461
|
+
setMode("tab"); input.current?.focus();
|
|
462
|
+
};
|
|
463
|
+
/** A pill chosen while the launcher shows leaves it for that window. */
|
|
464
|
+
const selectWindow = (o: Entity) => {
|
|
465
|
+
if (o !== w.selected) { fire("desktop-select", { window: o }); if (launcher) setAwaiting({ from: w.selected, mode: shownMode! }); }
|
|
466
|
+
leave();
|
|
467
|
+
};
|
|
393
468
|
const selectedWindow = windows.find((o) => o.entity === w.selected);
|
|
394
469
|
const selectedApp = apps.find((s) => s.entity === selectedWindow?.app);
|
|
395
470
|
// What is typed is the page's: it clears when the launcher closes, and never waits on the state.
|
|
@@ -398,78 +473,55 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
|
|
|
398
473
|
const search = (next: string, cursor = 0) => { setText(next); setAt(cursor); };
|
|
399
474
|
useEffect(() => { if (!launcher) search(""); }, [launcher]);
|
|
400
475
|
const label = (o: Row<DesktopWindow>, s: Row<DesktopApp>) => o.title || (o.path === "/" ? s.title : o.path);
|
|
401
|
-
|
|
476
|
+
const tabTitle = (launcher && !quick) || !selectedWindow || !selectedApp ? null : label(selectedWindow, selectedApp);
|
|
477
|
+
useEffect(() => { if (documentTitle) document.title = tabTitle ? `${tabTitle} · ${title}` : title; }, [documentTitle, tabTitle, title]);
|
|
478
|
+
// The palette: what can be opened, answering the words typed, best first, and the keyboard runs down the whole list. What is open is on the bar, and ⌘J and ⌘K walk it.
|
|
402
479
|
const words = text.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
403
480
|
const ranked = <T,>(items: T[], texts: (item: T) => string[]) => items.map((item, order) => ({ item, order, rank: rank(texts(item), words) })).filter((r): r is { item: T; order: number; rank: number } => r.rank !== null).sort((a, b) => a.rank - b.rank || a.order - b.order).map((r) => r.item);
|
|
404
|
-
const openRows = ranked(windows.flatMap((o) => { const s = apps.find((s) => s.entity === o.app); return s ? [{ o, s }] : []; }), ({ o, s }) => [label(o, s), s.title, s.group]);
|
|
405
481
|
const found = ranked(apps, (s) => [s.title, s.key, s.group]);
|
|
406
|
-
//
|
|
482
|
+
// The apps first; the other sessions after, and a new one; last, the way out of the baba.
|
|
407
483
|
const sessionRows = ranked(sessions, (x) => [x.key, x.front ?? ""]);
|
|
408
484
|
const newSession = go && rank(["new session"], words) !== null;
|
|
409
485
|
const leaving = root && rank([`leave ${title}`, root.label], words) !== null;
|
|
410
|
-
const S0 =
|
|
486
|
+
const S0 = found.length;
|
|
411
487
|
const L = S0 + sessionRows.length + (newSession ? 1 : 0);
|
|
412
488
|
const count = L + (leaving ? 1 : 0);
|
|
413
489
|
const cursor = Math.min(at, Math.max(0, count - 1));
|
|
414
490
|
const choose = (i: number) => {
|
|
415
491
|
if (i >= L) { location.assign(root!.href); return; }
|
|
416
|
-
if (i >= S0) { const x = sessionRows[i - S0]; go?.(x ? x.key : null);
|
|
417
|
-
const
|
|
418
|
-
// What will be in front: the
|
|
419
|
-
const target =
|
|
420
|
-
if (
|
|
421
|
-
if (
|
|
422
|
-
|
|
492
|
+
if (i >= S0) { const x = sessionRows[i - S0]; go?.(x ? x.key : null); setMode(null); input.current?.blur(); return; }
|
|
493
|
+
const s = found[i];
|
|
494
|
+
// What will be in front: the app's own window where it allows only one; a fresh window is not known until it is made.
|
|
495
|
+
const target = s && !s.many ? windows.filter((o) => o.app === s.entity).at(-1)?.entity : undefined;
|
|
496
|
+
if (s) fire("desktop-open", { app: s.key, fresh: true });
|
|
497
|
+
if (s && target !== w.selected) setAwaiting({ from: w.selected, mode: shownMode ?? "tab" });
|
|
498
|
+
setMode(null); input.current?.blur();
|
|
423
499
|
};
|
|
424
|
-
useEffect(() => { if (
|
|
500
|
+
useEffect(() => { if (mode && hand) input.current?.focus(); }, [mode, !!hand]);
|
|
425
501
|
useEffect(() => { frame.current?.querySelector(`[data-result="${cursor}"]`)?.scrollIntoView({ block: "nearest" }); }, [cursor]);
|
|
426
|
-
//
|
|
427
|
-
const
|
|
428
|
-
/** Where the string is tied, the middle of the mark's lower edge, and the dot's radius, measured when the launcher opens. */
|
|
429
|
-
const tie = useRef({ x: 0, y: 0, r: 0 });
|
|
430
|
-
const bob = useRef<Bob | null>(null);
|
|
431
|
-
bob.current ??= new Bob((at) => {
|
|
432
|
-
const el = dot.current, line = string.current, t = tie.current;
|
|
433
|
-
if (!el || !line) return;
|
|
434
|
-
el.style.transform = `translate(${at.x}px, ${at.y}px)`;
|
|
435
|
-
// The string runs from the tie to the dot's edge, at whatever angle the dot hangs.
|
|
436
|
-
const dx = at.x - t.x, dy = at.y - t.y, reach = Math.hypot(dx, dy), length = Math.max(0, reach - t.r);
|
|
437
|
-
line.style.opacity = length > 1 ? "1" : "0";
|
|
438
|
-
line.style.transform = `translate(${t.x}px, ${t.y}px) rotate(${reach ? -Math.asin(dx / reach) : 0}rad) scaleY(${length})`;
|
|
439
|
-
}, () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
440
|
-
useEffect(() => () => bob.current?.stop(), []);
|
|
441
|
-
/** Aims the dot at the chosen row's middle, on the mark's vertical; a scroll moves it with the list at once. */
|
|
442
|
-
const place = (now = false) => {
|
|
443
|
-
const o = overlay.current, el = dot.current;
|
|
444
|
-
const row = o?.querySelector<HTMLElement>(`[data-result="${cursor}"]`)?.closest<HTMLElement>(".desktop-row");
|
|
445
|
-
if (!el) return;
|
|
446
|
-
el.style.opacity = row ? "1" : "0";
|
|
447
|
-
if (!row) { if (string.current) string.current.style.opacity = "0"; return; }
|
|
448
|
-
// Layout offsets, not rects: a row still arriving is transformed, and the dot aims at where it will rest.
|
|
449
|
-
const body = row.closest<HTMLElement>(".desktop-launcher-body");
|
|
450
|
-
bob.current!.to({ x: tie.current.x, y: row.offsetTop - (body?.scrollTop ?? 0) + row.offsetHeight / 2 }, now);
|
|
451
|
-
};
|
|
452
|
-
useLayoutEffect(() => {
|
|
453
|
-
if (!launcher) { bob.current!.stop(); return; }
|
|
454
|
-
const o = overlay.current, mark = o?.querySelector<HTMLElement>(".desktop-logo"), eye = o?.querySelector<SVGCircleElement>(".desktop-eye-large"), el = dot.current;
|
|
455
|
-
if (!o || !mark || !eye || !el) return;
|
|
456
|
-
const O = o.getBoundingClientRect(), M = mark.getBoundingClientRect(), E = eye.getBoundingClientRect();
|
|
457
|
-
tie.current = { x: M.left + M.width / 2 - O.left, y: M.bottom - O.top, r: E.width / 2 };
|
|
458
|
-
// Born where the larger eye is, at the eye's own size, which it keeps; then it falls.
|
|
459
|
-
el.style.width = el.style.height = `${E.width}px`; el.style.margin = `${-E.width / 2}px 0 0 ${-E.width / 2}px`;
|
|
460
|
-
bob.current!.drop({ x: E.left + E.width / 2 - O.left, y: E.top + E.height / 2 - O.top });
|
|
461
|
-
}, [launcher]);
|
|
462
|
-
useEffect(() => { if (launcher) place(); });
|
|
502
|
+
// The rows fade into the palette's background at an edge with more beyond it, as the bar's pills do sideways.
|
|
503
|
+
const { ref: body, fade: bodyFade } = useScrollFade<HTMLDivElement>("y");
|
|
463
504
|
useEffect(() => {
|
|
464
505
|
if (!hand) return;
|
|
465
506
|
const key = (e: KeyboardEvent) => {
|
|
466
507
|
// An embedded picture only handles keys while it contains focus.
|
|
467
508
|
if (document.activeElement !== document.body && !frame.current?.contains(document.activeElement)) return;
|
|
468
|
-
|
|
469
|
-
|
|
509
|
+
// The commands the person's keymap runs; a press something else already took is left to it.
|
|
510
|
+
const command = e.defaultPrevented ? null : commandOf(keymap, e);
|
|
511
|
+
// The quick launcher opens, and closes again; over the launcher's tab it only gives the search focus.
|
|
512
|
+
if (command === "launcher") { e.preventDefault(); if (!launcher) openQuick(); else if (quick) leave(); else input.current?.focus(); }
|
|
513
|
+
// The tabs on the bar, walked as they sit, the mark's tab among them, round at the ends.
|
|
514
|
+
else if ((command === "previousTab" || command === "nextTab") && windows.length) {
|
|
515
|
+
e.preventDefault();
|
|
516
|
+
const tabs: (Entity | null)[] = [null, ...windows.map((o) => o.entity)];
|
|
517
|
+
const here = launcher && !quick ? 0 : tabs.indexOf(w.selected);
|
|
518
|
+
const next = tabs[(here + (command === "nextTab" ? 1 : -1) + tabs.length) % tabs.length]!;
|
|
519
|
+
if (next === null) { setMode("tab"); input.current?.focus(); } else selectWindow(next);
|
|
520
|
+
}
|
|
521
|
+
else if (command === "closeTab" && !launcher && w.selected !== null) { e.preventDefault(); fire("desktop-close", {}); }
|
|
470
522
|
else if (launcher && e.key === "Escape") {
|
|
471
523
|
e.preventDefault();
|
|
472
|
-
if (text) search(""); else if (w.selected !== null)
|
|
524
|
+
if (text) search(""); else if (w.selected !== null) leave();
|
|
473
525
|
}
|
|
474
526
|
};
|
|
475
527
|
addEventListener("keydown", key); return () => removeEventListener("keydown", key);
|
|
@@ -480,15 +532,20 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
|
|
|
480
532
|
const pills = windows.map((o) => {
|
|
481
533
|
const s = apps.find((s) => s.entity === o.app);
|
|
482
534
|
if (!s) return null;
|
|
483
|
-
const active = w.selected === o.entity;
|
|
484
|
-
return <WindowPill key={o.entity} label={label(o, s)} active={active} hand={hand} shortcut={active} icon={<AppIcon drawing={s.drawing} />} enter={firstDrawn.current} onSelect={() =>
|
|
535
|
+
const active = (!launcher || quick) && w.selected === o.entity;
|
|
536
|
+
return <WindowPill key={o.entity} label={label(o, s)} active={active} hand={hand} shortcut={active ? keyOf("closeTab") : undefined} icon={<AppIcon drawing={s.drawing} />} enter={firstDrawn.current} onSelect={() => selectWindow(o.entity)} onClose={() => fire("desktop-close", { window: o.entity })} />;
|
|
485
537
|
});
|
|
486
|
-
// The bar: the mark,
|
|
487
|
-
// The launcher
|
|
538
|
+
// The bar holds tabs only: the mark, the launcher's tab, which is never closed; a pill per open window; the tab in front filled.
|
|
539
|
+
// The launcher's tab takes the content's place under the bar, a palette in its middle: the search, then what is open and what can be. The quick one is the same palette over the app.
|
|
488
540
|
return <div ref={frame} className="desktop" data-theme={picture.theme ?? w.theme}>
|
|
489
541
|
<header className="desktop-bar" aria-label="Bar">
|
|
490
|
-
|
|
491
|
-
|
|
542
|
+
{/* The mark is the launcher's tab, named after the baba, a pill as every tab is; pointing at it turns its eyes into the way out, which leaves the baba for voss's picker, the session kept to come back to. */}
|
|
543
|
+
<span className={`desktop-pill desktop-pill-mark${launcher && !quick ? " is-active" : ""}`}>
|
|
544
|
+
<button type="button" className="desktop-pill-label" title={`${title}: launcher`} aria-label="Launcher" aria-pressed={launcher && !quick} disabled={!hand} onClick={chooseMark} onMouseDown={(e) => { if (e.button === 1) e.preventDefault(); }} onAuxClick={(e) => { if (e.button === 1) chooseMark(e); }}>
|
|
545
|
+
<span className="desktop-pill-icon"><Eyes size={18} /></span><PillText text={title} />
|
|
546
|
+
</button>
|
|
547
|
+
{hand && root && <button type="button" className="desktop-pill-close" aria-label={`Leave ${title}`} title={`Leave ${title}: back to ${root.label}`} onClick={() => location.assign(root.href)}><AppIcon drawing={leaveIcon} /></button>}
|
|
548
|
+
</span>
|
|
492
549
|
<Pills label="Open apps">{pills}</Pills>
|
|
493
550
|
</header>
|
|
494
551
|
<main className="desktop-content">
|
|
@@ -496,7 +553,7 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
|
|
|
496
553
|
const s = apps.find((s) => s.entity === o.app);
|
|
497
554
|
if (!s) return null;
|
|
498
555
|
const active = w.selected === o.entity;
|
|
499
|
-
return <section key={o.entity} className=
|
|
556
|
+
return <section key={o.entity} className={`desktop-app${active && launcher && !quick ? " is-behind" : ""}`} hidden={!active} aria-label={label(o, s)}>
|
|
500
557
|
<AppBoundary><>{render(s, {
|
|
501
558
|
session: w.key, window: o.entity, path: o.path, ...routeOf(s.routes, o.path), active, memory: (o.memory ?? {}) as Record<string, unknown>,
|
|
502
559
|
navigate: (path) => fire("desktop-open", { app: s.key, path }),
|
|
@@ -507,40 +564,26 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
|
|
|
507
564
|
</section>;
|
|
508
565
|
})}
|
|
509
566
|
</main>
|
|
510
|
-
{launcher && <div
|
|
511
|
-
// Pressing anywhere in the launcher keeps the search's focus, empty space included; choosing a row,
|
|
567
|
+
{launcher && <div className={`desktop-launcher${quick ? " is-quick" : ""}`} aria-label="Launcher" onPointerDown={(e) => {
|
|
568
|
+
// Pressing anywhere in the launcher keeps the search's focus, empty space included; choosing a row, a pill, Escape or ⌘P are what leave it. Around the quick one's palette, a press goes back.
|
|
512
569
|
if (!(e.target as HTMLElement).closest("input")) e.preventDefault();
|
|
570
|
+
if (quick && !(e.target as HTMLElement).closest(".desktop-palette")) leave();
|
|
513
571
|
}}>
|
|
514
|
-
<div className="desktop-
|
|
515
|
-
|
|
516
|
-
<
|
|
572
|
+
<div className="desktop-palette">
|
|
573
|
+
<div className="desktop-launcher-search">
|
|
574
|
+
<span className="desktop-pill-icon"><AppIcon drawing={searchIcon} /></span>
|
|
575
|
+
<input ref={input} className="desktop-search" autoFocus={mode !== null} aria-label="Search" placeholder="Type an app" value={text} disabled={!hand} onChange={(e) => search(e.target.value)} role="combobox" aria-expanded="true" aria-controls={resultsId} aria-activedescendant={count ? `${resultsId}-${cursor}` : undefined} onKeyDown={(e) => {
|
|
517
576
|
if (["ArrowDown", "ArrowUp"].includes(e.key) && count) { e.preventDefault(); setAt((cursor + (e.key === "ArrowDown" ? 1 : -1) + count) % count); }
|
|
518
577
|
else if (e.key === "Enter" && count) { e.preventDefault(); choose(cursor); }
|
|
519
578
|
}} />
|
|
520
579
|
</div>
|
|
521
|
-
<
|
|
522
|
-
<span ref={dot} className="desktop-dot" aria-hidden="true" />
|
|
523
|
-
<div id={resultsId} className="desktop-launcher-body" role="listbox" aria-label="Open windows and apps" onScroll={() => place(true)}>
|
|
524
|
-
{openRows.length > 0 && <div className="desktop-group">
|
|
525
|
-
<h2 className="desktop-group-title" style={{ "--i": 0 } as CSSProperties}>Open</h2>
|
|
526
|
-
{openRows.map(({ o, s }, k) => {
|
|
527
|
-
const i = k, front = w.selected === o.entity;
|
|
528
|
-
return <span key={o.entity} className={`desktop-row desktop-row-window${cursor === i ? " is-cursor" : ""}${front ? " is-active" : ""}`} style={{ "--i": i + 1 } as CSSProperties} {...middleCloses(hand && (() => fire("desktop-close", { window: o.entity })))}>
|
|
529
|
-
<button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className="desktop-row-main" disabled={!hand} title={label(o, s)} onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
|
|
530
|
-
<span className="desktop-pill-icon"><AppIcon drawing={s.drawing} /></span>
|
|
531
|
-
<span className="desktop-row-label">{label(o, s)}</span>
|
|
532
|
-
{label(o, s) !== s.title && <span className="desktop-row-note">{s.title}</span>}
|
|
533
|
-
</button>
|
|
534
|
-
{hand && <button type="button" className="desktop-pill-close" aria-label={`Close ${label(o, s)}`} title={`Close ${label(o, s)}`} onClick={() => fire("desktop-close", { window: o.entity })}><Cross /></button>}
|
|
535
|
-
</span>;
|
|
536
|
-
})}
|
|
537
|
-
</div>}
|
|
580
|
+
<div ref={body} id={resultsId} className={`desktop-launcher-body${bodyFade}`} role="listbox" aria-label="Apps">
|
|
538
581
|
{groups.map((group) => <div key={group} className="desktop-group">
|
|
539
|
-
{group && <h2 className="desktop-group-title"
|
|
582
|
+
{group && <h2 className="desktop-group-title">{group}</h2>}
|
|
540
583
|
{appsOf(group).map((s) => {
|
|
541
|
-
const i =
|
|
584
|
+
const i = found.indexOf(s);
|
|
542
585
|
const open = windows.filter((o) => o.app === s.entity).length;
|
|
543
|
-
return <button type="button" key={s.key} id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className={`desktop-row desktop-row-app${cursor === i ? " is-cursor" : ""}`}
|
|
586
|
+
return <button type="button" key={s.key} id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className={`desktop-row desktop-row-app${cursor === i ? " is-cursor" : ""}`} disabled={!hand} title={s.many ? `Open another ${s.title}` : open ? `Bring ${s.title} to the front` : `Open ${s.title}`} onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
|
|
544
587
|
<span className="desktop-pill-icon"><AppIcon drawing={s.drawing} /></span>
|
|
545
588
|
<span className="desktop-row-label">{s.title}</span>
|
|
546
589
|
<span className="desktop-row-note">{words.length && s.group ? s.group : ""}{open > 1 ? `${words.length && s.group ? " · " : ""}${open} open` : ""}</span>
|
|
@@ -548,10 +591,10 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
|
|
|
548
591
|
})}
|
|
549
592
|
</div>)}
|
|
550
593
|
{(sessionRows.length > 0 || newSession) && <div className="desktop-group desktop-sessions">
|
|
551
|
-
<h2 className="desktop-group-title"
|
|
594
|
+
<h2 className="desktop-group-title">Sessions</h2>
|
|
552
595
|
{sessionRows.map((x, k) => {
|
|
553
596
|
const i = S0 + k, note = `${x.windows ? `${x.windows} open` : "empty"}${x.front ? ` · ${x.front}` : ""}`;
|
|
554
|
-
return <span key={x.entity} className={`desktop-row desktop-row-window desktop-row-session${cursor === i ? " is-cursor" : ""}`}
|
|
597
|
+
return <span key={x.entity} className={`desktop-row desktop-row-window desktop-row-session${cursor === i ? " is-cursor" : ""}`} {...middleCloses(hand && (() => hand.fire("desktop-leave", { session: x.key })))}>
|
|
555
598
|
<button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className="desktop-row-main" disabled={!hand} title={`Go to the session ${x.key}`} onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
|
|
556
599
|
<span className="desktop-pill-icon"><AppIcon drawing={sessionIcon} /></span>
|
|
557
600
|
<span className="desktop-row-label">{x.key}</span>
|
|
@@ -560,14 +603,14 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
|
|
|
560
603
|
{hand && <button type="button" className="desktop-pill-close" aria-label={`End the session ${x.key}`} title={`End the session ${x.key}`} onClick={() => hand.fire("desktop-leave", { session: x.key })}><Cross /></button>}
|
|
561
604
|
</span>;
|
|
562
605
|
})}
|
|
563
|
-
{newSession && (() => { const i = S0 + sessionRows.length; return <button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className={`desktop-row desktop-row-app${cursor === i ? " is-cursor" : ""}`}
|
|
606
|
+
{newSession && (() => { const i = S0 + sessionRows.length; return <button type="button" id={`${resultsId}-${i}`} data-result={i} role="option" aria-selected={cursor === i} className={`desktop-row desktop-row-app${cursor === i ? " is-cursor" : ""}`} disabled={!hand} title="Open a new session in this tab" onMouseEnter={() => setAt(i)} onClick={() => choose(i)}>
|
|
564
607
|
<span className="desktop-pill-icon"><AppIcon drawing={plusIcon} /></span>
|
|
565
608
|
<span className="desktop-row-label">New session</span>
|
|
566
609
|
<span className="desktop-row-note">this tab is on {w.key}</span>
|
|
567
610
|
</button>; })()}
|
|
568
611
|
</div>}
|
|
569
612
|
{leaving && <div className="desktop-group desktop-leave">
|
|
570
|
-
<button type="button" id={`${resultsId}-${L}`} data-result={L} role="option" aria-selected={cursor === L} className={`desktop-row desktop-row-app${cursor === L ? " is-cursor" : ""}`}
|
|
613
|
+
<button type="button" id={`${resultsId}-${L}`} data-result={L} role="option" aria-selected={cursor === L} className={`desktop-row desktop-row-app${cursor === L ? " is-cursor" : ""}`} title={`Leave ${title}: back to ${root!.label}`} onMouseEnter={() => setAt(L)} onClick={() => choose(L)}>
|
|
571
614
|
<span className="desktop-pill-icon"><AppIcon drawing={leaveIcon} /></span>
|
|
572
615
|
<span className="desktop-row-label">Leave {title}</span>
|
|
573
616
|
<span className="desktop-row-note">{root!.label}</span>
|
|
@@ -578,10 +621,11 @@ export function DesktopFrame({ picture, title, hand, render, root, go, launch, d
|
|
|
578
621
|
<footer className="desktop-launcher-legend" aria-label="Shortcuts">
|
|
579
622
|
<span><kbd>↑</kbd><kbd>↓</kbd> move</span>
|
|
580
623
|
<span><kbd>↵</kbd> open</span>
|
|
581
|
-
<span><kbd>esc</kbd> {text ? "clear" : "
|
|
582
|
-
<span><kbd
|
|
583
|
-
<span><kbd
|
|
624
|
+
{(text || w.selected !== null) && <span><kbd>esc</kbd> {text ? "clear" : "back"}</span>}
|
|
625
|
+
{quick && keyOf("launcher") && <span><kbd>{keyOf("launcher")}</kbd> close</span>}
|
|
626
|
+
{windows.length > 0 && keyOf("previousTab") && keyOf("nextTab") && <span><kbd>{keyOf("previousTab")}</kbd><kbd>{keyOf("nextTab")}</kbd> tabs</span>}
|
|
584
627
|
</footer>
|
|
628
|
+
</div>
|
|
585
629
|
</div>}
|
|
586
630
|
</div>;
|
|
587
631
|
}
|
package/src/door/core.ts
CHANGED
|
@@ -73,7 +73,7 @@ export interface Index {
|
|
|
73
73
|
error?: string;
|
|
74
74
|
systems: { name: string }[];
|
|
75
75
|
/** What the baba tells agents: whether it has a context, and its skills by name. */
|
|
76
|
-
|
|
76
|
+
instructions: { context: boolean; skills: string[] };
|
|
77
77
|
calls: IndexCall[];
|
|
78
78
|
components: Record<string, { system: string; key?: string; accept?: true }>;
|
|
79
79
|
resources: string[];
|
|
@@ -115,7 +115,7 @@ export function index(m: Manifest, extra: IndexExtra = {}): Index {
|
|
|
115
115
|
...(extra.gen !== undefined ? { gen: extra.gen } : {}),
|
|
116
116
|
...(extra.error ? { error: extra.error } : {}),
|
|
117
117
|
systems: m.systems.map((c) => ({ name: c.name })),
|
|
118
|
-
|
|
118
|
+
instructions: { context: !!m.instructions.context, skills: Object.keys(m.instructions.skills) },
|
|
119
119
|
calls: entries(m).map(({ name, kind, c }) => ({ name, kind, system: c.owner, summary: c.summary, example: exampleCall(name, c, extra.prog) })),
|
|
120
120
|
components: Object.fromEntries(Object.entries(m.components).map(([n, c]) => {
|
|
121
121
|
const key = keyField(c.schema);
|