@fungi.computer/tourtois 0.3.0 → 0.4.0

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.
@@ -11,7 +11,7 @@ import { addressKey } from "@fungi.computer/nacre/app-directory";
11
11
  import { reconcileAppDirectory, reconcileAppWindowFromDirectory, } from "./app-directory-reconciliation.js";
12
12
  import { useAppDirectory } from "./app-directory.js";
13
13
  import { AppIcon } from "./app-icon.js";
14
- import { appForRole, composeOpenRequests, createAppLaunchDescriptor, attachWindowComputer, launchComputerId, launchForComputer, launchForRoleIntent, requireHandledNavigation, resolveFileEditorWindow, } from "./app-launch.js";
14
+ import { appForRole, composeOpenRequests, createAppLaunchDescriptor, attachWindowComputer, launchComputerId, launchForComputer, findOpenFileCommand, launchForRoleIntent, requireHandledNavigation, resolveFileEditorWindow, } from "./app-launch.js";
15
15
  import { useAppSurfaceHost, } from "./app-surface.js";
16
16
  import { hasVisibleAppWindows, } from "./app-windows.js";
17
17
  import { desktopVisibility } from "./desktop-visibility.js";
@@ -37,7 +37,7 @@ function phoneShowDesktop(phone, covered, host, onFailure) {
37
37
  return undefined;
38
38
  return () => {
39
39
  void host
40
- .execute("shell:desktop.show", { origin: "menu" })
40
+ .execute({ namespace: "shell", id: "desktop.show" }, { origin: "menu" })
41
41
  .then((outcome) => {
42
42
  if (outcome.status !== "handled") {
43
43
  console.error("Phone Show desktop refused", outcome);
@@ -298,8 +298,10 @@ export function AppWorkbench({ paneLayerRef, phone, teamId, teamName, initialCom
298
298
  layout.zoom(null);
299
299
  focusWindow(launch.windowId);
300
300
  appSurfaceHost.focus?.(launch.surfaceId);
301
- const commandId = `${launch.surfaceId}:open-file`;
302
- const outcome = await appSurfaceHost.execute(commandId, {
301
+ const openFile = findOpenFileCommand(appSurfaceHost.snapshot(), launch.surfaceId);
302
+ if (!openFile)
303
+ throw new Error("The editor's open-file command is unavailable.");
304
+ const outcome = await appSurfaceHost.execute(openFile.command, {
303
305
  origin: "programmatic",
304
306
  arguments: input,
305
307
  });
@@ -456,7 +458,6 @@ export function AppWorkbench({ paneLayerRef, phone, teamId, teamName, initialCom
456
458
  };
457
459
  return launcher.icon ? { ...item, icon: launcher.icon } : item;
458
460
  }), ...extraLaunchers);
459
- const inboxSurfaceId = useMemo(() => appForRole(directory, directoryRoles, "inbox")?.surfaceId ?? null, [directory, directoryRoles]);
460
461
  const visibility = desktopVisibility({
461
462
  hasVisibleWindows: hasVisibleAppWindows(appProjection) ||
462
463
  order.some((id) => !appProjection.windows.some((window) => window.windowId === id)),
@@ -530,7 +531,7 @@ export function AppWorkbench({ paneLayerRef, phone, teamId, teamName, initialCom
530
531
  openAppPermissions(activeWindow.launch.surfaceId);
531
532
  } }, activeWindow.windowId)) : null, systemMenuItems: systemMenuItems }), noticeContainer
532
533
  ? createPortal(_jsx(Toaster, { id: "workbench-notices", position: "bottom-right", offset: 24, mobileOffset: 24, closeButton: true }), noticeContainer)
533
- : null, _jsxs("div", { className: "relative min-h-0 flex-1", children: [_jsx(MaosaicoRegion, { layer: "desktop", style: { position: "absolute", inset: 0 }, "data-desktop-visibility": visibility, ref: desktopRef, "aria-hidden": covered ? true : undefined, children: _jsx(Desktop, { phone: phone, editing: desktopEditing, onEditingChange: setDesktopEditing, inboxSurfaceId: inboxSurfaceId, onOwner: receiveDesktopOwner, launcherItems: launchableItems, onAllApps: () => {
534
+ : null, _jsxs("div", { className: "relative min-h-0 flex-1", children: [_jsx(MaosaicoRegion, { layer: "desktop", style: { position: "absolute", inset: 0 }, "data-desktop-visibility": visibility, ref: desktopRef, "aria-hidden": covered ? true : undefined, children: _jsx(Desktop, { phone: phone, editing: desktopEditing, onEditingChange: setDesktopEditing, onOwner: receiveDesktopOwner, launcherItems: launchableItems, onAllApps: () => {
534
535
  setAppDialOpen(true);
535
536
  }, paused: covered, apps: windowApps, needsUpdate: needsUpdate, onAppSettings: (app) => {
536
537
  setSettingsLocation({
@@ -12,8 +12,10 @@ export function useAppDirectory(teamId, directoryReader) {
12
12
  // result fresh for that handoff; explicit mutations still invalidate it.
13
13
  staleTime: 5_000,
14
14
  // Quiet open Apps need release availability too, not just after a request
15
- // is refused. React Query pauses this polling while the page is hidden.
16
- refetchInterval: 15_000,
15
+ // is refused. Every directory read costs the host a Registry read, and
16
+ // explicit mutations already invalidate this query, so the poll is a slow
17
+ // backstop. React Query pauses it while the page is hidden.
18
+ refetchInterval: 60_000,
17
19
  });
18
20
  return {
19
21
  query,
@@ -1,5 +1,5 @@
1
1
  import type { AppOpenInput } from "@fungi.computer/agar/apps";
2
- import type { WhistleSnapshot } from "@fungi.computer/whistle";
2
+ import { type WhistleMenuProjection, type WhistleSnapshot } from "@fungi.computer/whistle";
3
3
  import { type TourtoisAppSurfaceHost } from "./app-surface.js";
4
4
  import { createAppWindowManager, type AppLaunchDescriptor, type AppWindowManager, type AppWindowProjection } from "./app-windows.js";
5
5
  import type { WorkbenchDirectory } from "@fungi.computer/nacre/workbench-directory";
@@ -30,8 +30,15 @@ export declare function sameOfferedComputers(left: AppLaunchDescriptor, right: A
30
30
  export declare function launchComputerId(href: string): string | null;
31
31
  /** Keep the admitted App identity and point its existing surface at one Computer. */
32
32
  export declare function launchForComputer(launch: AppLaunchDescriptor, computerId: string): AppLaunchDescriptor;
33
+ /** An editor surface's admitted open-file row, once the App registered it. */
34
+ export declare function findOpenFileCommand(snapshot: WhistleSnapshot, surfaceId: string): WhistleMenuProjection | undefined;
33
35
  /** Find the user's already-open editor by its admitted open-file command. */
34
36
  export declare function findOpenFileEditor<Window extends Pick<AppWindowProjection, "launch">>(windows: readonly Window[], snapshot: WhistleSnapshot): Window | undefined;
37
+ /**
38
+ * Resolve once a just-mounted App has registered a declared command: an App
39
+ * window is ready when its host connects, and registers its commands after.
40
+ */
41
+ export declare function waitForSurfaceCommand(host: Pick<TourtoisAppSurfaceHost, "subscribe">, isRegistered: () => boolean): Promise<void>;
35
42
  /**
36
43
  * Select the Team's Files App for file navigation before it is mounted, or
37
44
  * any App that declares it opens files when no Files App handles them.
@@ -41,7 +48,7 @@ export declare function resolveFileEditorWindow(options: {
41
48
  apps: readonly AppDirectoryApp[];
42
49
  roles: AppDirectoryRoles;
43
50
  appWindows: ReturnType<typeof createAppWindowManager>;
44
- appSurfaceHost: TourtoisAppSurfaceHost;
51
+ appSurfaceHost: Pick<TourtoisAppSurfaceHost, "snapshot" | "subscribe">;
45
52
  computerId: string;
46
53
  computers: readonly ComputerOption[];
47
54
  origin: string;
@@ -1,7 +1,9 @@
1
1
  import { defineIdFamily } from "@fungi.computer/cairn/id";
2
+ import { sameWhistleCommand, } from "@fungi.computer/whistle";
2
3
  import {} from "./app-surface.js";
3
4
  import { AppError } from "@fungi.computer/agar";
4
5
  import { createAppWindowManager, } from "./app-windows.js";
6
+ import { appSurfaceCommandKey } from "@fungi.computer/nacre/command-host";
5
7
  /** The Team's effective App for a role when it can be launched now. */
6
8
  export function appForRole(apps, roles, role) {
7
9
  const surfaceId = roles[role]?.surfaceId;
@@ -146,9 +148,37 @@ export function launchForComputer(launch, computerId) {
146
148
  href: href.pathname + href.search,
147
149
  };
148
150
  }
151
+ /** An editor surface's admitted open-file row, once the App registered it. */
152
+ export function findOpenFileCommand(snapshot, surfaceId) {
153
+ const key = appSurfaceCommandKey({ surfaceId, commandId: "open-file" });
154
+ return snapshot.menu.find((row) => sameWhistleCommand(row.command, key));
155
+ }
149
156
  /** Find the user's already-open editor by its admitted open-file command. */
150
157
  export function findOpenFileEditor(windows, snapshot) {
151
- return windows.find((window) => snapshot.menu.some((command) => command.commandId === `${window.launch.surfaceId}:open-file`));
158
+ return windows.find((window) => findOpenFileCommand(snapshot, window.launch.surfaceId) !== undefined);
159
+ }
160
+ /**
161
+ * Resolve once a just-mounted App has registered a declared command: an App
162
+ * window is ready when its host connects, and registers its commands after.
163
+ */
164
+ export function waitForSurfaceCommand(host, isRegistered) {
165
+ if (isRegistered())
166
+ return Promise.resolve();
167
+ return new Promise((resolve, reject) => {
168
+ const timeout = window.setTimeout(() => {
169
+ unsubscribe();
170
+ reject(new Error("The App did not register its declared command."));
171
+ }, 10_000);
172
+ const check = () => {
173
+ if (!isRegistered())
174
+ return;
175
+ window.clearTimeout(timeout);
176
+ unsubscribe();
177
+ resolve();
178
+ };
179
+ const unsubscribe = host.subscribe(check);
180
+ check();
181
+ });
152
182
  }
153
183
  /**
154
184
  * Select the Team's Files App for file navigation before it is mounted, or
@@ -184,7 +214,7 @@ export async function resolveFileEditorWindow(options) {
184
214
  .read()
185
215
  .windows.find((window) => window.windowId === candidate.windowId);
186
216
  const editor = mounted
187
- ? findOpenFileEditor([mounted], options.appSurfaceHost.snapshot())
217
+ ? await waitForSurfaceCommand(options.appSurfaceHost, () => findOpenFileCommand(options.appSurfaceHost.snapshot(), mounted.launch.surfaceId) !== undefined).then(() => mounted, () => undefined)
188
218
  : undefined;
189
219
  if (editor)
190
220
  return { launch: editor.launch, opened: true };
package/dist/app-menus.js CHANGED
@@ -56,7 +56,7 @@ export function AppMenus({ host, container: popover, }) {
56
56
  setPending(command.commandId);
57
57
  setMessage(null);
58
58
  void host
59
- .execute(command.commandId, {
59
+ .execute(command.command, {
60
60
  origin: "menu",
61
61
  signal: controller.signal,
62
62
  })
@@ -1,10 +1,11 @@
1
1
  import { type ReactNode } from "react";
2
- import type { WhistleCommand, WhistleExecutionInput, WhistleExecutionOutcome, WhistleMenuProjection, WhistleSnapshot } from "@fungi.computer/whistle";
2
+ import type { WhistleCommand, WhistleCommandKey, WhistleExecutionInput, WhistleExecutionOutcome, WhistleMenuProjection, WhistleSnapshot } from "@fungi.computer/whistle";
3
3
  import type { AgentSessionSelection, AppNavigationState, AppStatusItem, FileNavigationInput } from "@fungi.computer/agar";
4
4
  import type { AppOpenInput, AppRoleChoice } from "@fungi.computer/agar/apps";
5
5
  import type { AppDeclaredCommand } from "@fungi.computer/puffball/app";
6
6
  import type { AppWindowMount } from "./app-windows.js";
7
7
  import type { WindowEvents } from "@fungi.computer/nacre/window-manager";
8
+ import type { AppSurfaceCommandHost } from "@fungi.computer/nacre/command-host";
8
9
  /** What one mounted App may raise toward the shell. */
9
10
  export type TourtoisAppMountEvents = Pick<WindowEvents, "onDeliveryPending"> & Readonly<{
10
11
  onComputerChoice?: () => Promise<void>;
@@ -39,6 +40,8 @@ export type TourtoisAppSurfaceHost = Readonly<{
39
40
  appTitle: string;
40
41
  command: AppDeclaredCommand;
41
42
  }>[], run: (surfaceId: string, commandId: string) => Promise<void>) => () => void;
43
+ /** The App command a snapshot row runs, mounted or from the directory. */
44
+ appCommand: AppSurfaceCommandHost["appCommand"];
42
45
  /** Commands contributed by the currently focused App window. */
43
46
  menu: () => readonly WhistleMenuProjection[];
44
47
  /** The shell-owned semantic command graph, including every mounted App. */
@@ -49,7 +52,7 @@ export type TourtoisAppSurfaceHost = Readonly<{
49
52
  */
50
53
  active: () => WhistleSnapshot;
51
54
  /** Execute an active command or an exact host-owned programmatic action. */
52
- execute: (commandId: string, input: WhistleExecutionInput) => Promise<WhistleExecutionOutcome>;
55
+ execute: (command: WhistleCommandKey, input: WhistleExecutionInput) => Promise<WhistleExecutionOutcome>;
53
56
  /** Bind one mounted App's explicit Agent Session selection to the shell. */
54
57
  reportAgentSessionSelection?: (teamId: string, mountId: string, selection: AgentSessionSelection | null) => Promise<void> | void;
55
58
  /** Observe focused App and command-graph changes. */
@@ -22,7 +22,7 @@ export type PaletteApp = Readonly<{
22
22
  }>;
23
23
  /** Which-key list and `:` line for the focused window, drawn from Caps. */
24
24
  export declare function CommandDialogs({ host, state, focusedWindow, directoryApps, onStatus, finalFocus, container: dialog, renderIcon, }: Readonly<{
25
- host: Pick<AppSurfaceCommandHost, "execute"> | null;
25
+ host: Pick<AppSurfaceCommandHost, "appCommand" | "execute"> | null;
26
26
  state: ShellCommandState;
27
27
  focusedWindow: Readonly<{
28
28
  launch: Readonly<{
@@ -8,12 +8,6 @@ import { leaderKeyMatches, leaderKeys, parseChord, } from "@fungi.computer/nacre
8
8
  function isMac() {
9
9
  return /Mac|iPhone|iPad/u.test(navigator.userAgent);
10
10
  }
11
- function appCommandId(surfaceId, commandId) {
12
- return `${encodeURIComponent(surfaceId)}:${commandId}`;
13
- }
14
- function directoryCommandId(surfaceId, commandId) {
15
- return `app-directory:${encodeURIComponent(surfaceId)}-${encodeURIComponent(commandId)}`;
16
- }
17
11
  function chordKey(key) {
18
12
  switch (key) {
19
13
  case "enter":
@@ -85,12 +79,26 @@ export function CommandDialogs({ host, state, focusedWindow, directoryApps, onSt
85
79
  surfaceId: focusedWindow.launch.surfaceId,
86
80
  }
87
81
  : undefined;
88
- const focusedCommandIds = new Set(focusedApp?.commands.flatMap((command) => [
89
- appCommandId(focusedApp.surfaceId, command.id),
90
- directoryCommandId(focusedApp.surfaceId, command.id),
91
- ]) ?? []);
82
+ // The App a palette row belongs to, and the command its Release declares.
83
+ const paletteEntry = (command) => {
84
+ const target = host?.appCommand(command);
85
+ if (target === undefined)
86
+ return undefined;
87
+ const app = target.surfaceId === focusedApp?.surfaceId
88
+ ? focusedApp
89
+ : command.sourceId === appDirectorySourceId
90
+ ? directoryApps.find((entry) => entry.surfaceId === target.surfaceId)
91
+ : undefined;
92
+ if (app === undefined)
93
+ return undefined;
94
+ return {
95
+ app,
96
+ declared: app.commands.find((entry) => entry.id === target.commandId),
97
+ };
98
+ };
92
99
  const isFocusedCommand = (command) => command.scope === focusedApp?.surfaceId ||
93
- focusedCommandIds.has(command.commandId);
100
+ (focusedApp !== undefined &&
101
+ paletteEntry(command)?.app.surfaceId === focusedApp.surfaceId);
94
102
  const orderedPaletteRows = [
95
103
  ...palette.filter(isFocusedCommand),
96
104
  ...palette.filter((command) => command.sourceId === appDirectorySourceId && !isFocusedCommand(command)),
@@ -104,18 +112,11 @@ export function CommandDialogs({ host, state, focusedWindow, directoryApps, onSt
104
112
  seenPaletteIds.add(command.commandId);
105
113
  return true;
106
114
  });
107
- const appByCommandId = new Map();
108
- if (focusedApp)
109
- for (const command of focusedApp.commands)
110
- appByCommandId.set(appCommandId(focusedApp.surfaceId, command.id), focusedApp);
111
- for (const app of directoryApps)
112
- for (const command of app.commands)
113
- appByCommandId.set(directoryCommandId(app.surfaceId, command.id), app);
114
115
  const mac = isMac();
115
- const run = useCallback((commandId, origin) => {
116
+ const run = useCallback((command, origin) => {
116
117
  if (!host)
117
118
  return;
118
- void host.execute(commandId, { origin }).then((outcome) => {
119
+ void host.execute(command, { origin }).then((outcome) => {
119
120
  const message = outcomeMessage(outcome);
120
121
  if (message !== null)
121
122
  onStatus(message);
@@ -127,7 +128,7 @@ export function CommandDialogs({ host, state, focusedWindow, directoryApps, onSt
127
128
  onStatus(row.availability.reason);
128
129
  return;
129
130
  }
130
- run(row.commandId, "keyboard");
131
+ run(row.command, "keyboard");
131
132
  }, [onStatus, run, setKeysOpen]);
132
133
  useLayoutEffect(() => {
133
134
  if (!keysOpen)
@@ -160,7 +161,7 @@ export function CommandDialogs({ host, state, focusedWindow, directoryApps, onSt
160
161
  } })] }, group.label)))] }) }), _jsx(Dialog, { open: commandLineOpen, onOpenChange: setCommandLineOpen, children: _jsxs(DialogContent, { container: dialog, className: "tourtois-command-palette", finalFocus: finalFocus, children: [_jsx(DialogTitle, { className: "sr-only", children: "Command palette" }), _jsxs("div", { className: "tourtois-command-palette-heading", children: [focusedApp
161
162
  ? renderIcon(focusedApp.roles, focusedApp.icon ?? undefined)
162
163
  : null, _jsx("span", { children: focusedTitle ?? "Commands" })] }), _jsx(Combobox, { activeTone: "primary", "aria-label": "Available commands", emptyMessage: "No matching commands", getKey: (command) => command.commandId, getLabel: (command) => command.title, getSearchText: (command) => {
163
- const app = appByCommandId.get(command.commandId);
164
+ const app = paletteEntry(command)?.app;
164
165
  return `${command.title} ${command.category} ${command.description ?? ""} ${app?.name ?? ""} ${app ? `opens ${app.name}` : ""}`;
165
166
  }, groupBy: (command) => {
166
167
  if (isFocusedCommand(command))
@@ -169,26 +170,17 @@ export function CommandDialogs({ host, state, focusedWindow, directoryApps, onSt
169
170
  return "Other apps";
170
171
  return "Workspace";
171
172
  }, inputProps: { "aria-label": "Search commands" }, items: paletteRows, placeholder: ":", renderItem: (command) => (_jsxs("div", { className: "tourtois-command-palette-item", children: [_jsx("span", { className: "tourtois-command-palette-item-icon", children: (() => {
172
- const app = appByCommandId.get(command.commandId);
173
- if (app === undefined)
173
+ const entry = paletteEntry(command);
174
+ if (entry === undefined)
174
175
  return null;
175
- const declared = app.commands.find((entry) => (command.sourceId === appDirectorySourceId
176
- ? directoryCommandId(app.surfaceId, entry.id)
177
- : appCommandId(app.surfaceId, entry.id)) ===
178
- command.commandId);
179
- return renderIcon(app.roles, declared?.sigil ?? app.icon ?? undefined);
180
- })() }), _jsx("span", { className: "tourtois-command-palette-item-title", children: command.title }), command.sourceId === appDirectorySourceId ? (appByCommandId.get(command.commandId)?.name !==
181
- focusedApp?.name ? (_jsxs("small", { children: ["opens", " ", appByCommandId.get(command.commandId)?.name ?? "App"] })) : null) : null, (() => {
182
- const app = appByCommandId.get(command.commandId);
183
- const declared = app?.commands.find((entry) => [
184
- appCommandId(app.surfaceId, entry.id),
185
- directoryCommandId(app.surfaceId, entry.id),
186
- ].includes(command.commandId));
176
+ return renderIcon(entry.app.roles, entry.declared?.sigil ?? entry.app.icon ?? undefined);
177
+ })() }), _jsx("span", { className: "tourtois-command-palette-item-title", children: command.title }), command.sourceId === appDirectorySourceId ? (paletteEntry(command)?.app.name !== focusedApp?.name ? (_jsxs("small", { children: ["opens ", paletteEntry(command)?.app.name ?? "App"] })) : null) : null, (() => {
178
+ const declared = paletteEntry(command)?.declared;
187
179
  return (_jsx("span", { className: "tourtois-command-palette-shortcut", children: commandKeycaps(command.commandId, active, mac, declared).map((key, index) => key === "then" ? (_jsx("span", { children: "then" }, `${key}-${String(index)}`)) : (_jsx("kbd", { className: "kbd kbd-sm", children: key }, `${key}-${String(index)}`))) }));
188
180
  })()] })), value: null, onValueChange: (_value, command) => {
189
181
  if (!command)
190
182
  return;
191
183
  setCommandLineOpen(false);
192
- run(command.commandId, "palette");
184
+ run(command.command, "palette");
193
185
  } }), _jsxs("div", { className: "tourtois-command-palette-footer", children: [_jsx("span", { className: "tourtois-command-palette-close-hint", children: "Esc to close" }), _jsxs("span", { className: "tourtois-command-palette-leader-hint", children: [_jsx("kbd", { className: "kbd kbd-sm", children: mac ? "⌘ K" : "Ctrl K" }), _jsx("span", { children: "then" }), _jsx("kbd", { className: "kbd kbd-sm", children: "?" }), _jsx("span", { children: "for all keys" })] })] })] }) })] }));
194
186
  }
@@ -8,7 +8,6 @@ import { type DesktopGillTransport } from "./row-gill.js";
8
8
  export type DesktopProps = {
9
9
  phone: boolean;
10
10
  apps: WorkbenchDirectory["apps"];
11
- inboxSurfaceId: string | null;
12
11
  launcherItems: readonly AppBarItem[];
13
12
  needsUpdate: NonNullable<WorkbenchDirectory["needsUpdate"]>;
14
13
  onAppSettings: (app: WorkbenchDirectory["apps"][number]) => void;
@@ -48,14 +48,18 @@ export function Desktop(props) {
48
48
  props.onOwner?.(null);
49
49
  current?.close();
50
50
  };
51
- }, [
52
- props.methods,
53
- props.storage,
54
- props.apps,
55
- props.inboxSurfaceId,
56
- props.onOwner,
57
- retry,
58
- ]);
51
+ }, [props.methods, props.storage, props.onOwner, retry]);
52
+ // The backend resolves the desktop against its own App catalog. One owner
53
+ // lives across directory polls; only a changed App set re-reads it.
54
+ const catalog = JSON.stringify(props.apps.map((app) => [app.surfaceId, app.releaseId, app.availability]));
55
+ const readCatalog = useRef(null);
56
+ useEffect(() => {
57
+ if (owner === null)
58
+ return;
59
+ if (readCatalog.current !== null && readCatalog.current !== catalog)
60
+ owner.invalidate();
61
+ readCatalog.current = catalog;
62
+ }, [owner, catalog]);
59
63
  return (_jsxs("div", { className: "tourtois-desktop", "data-desktop-bento": true, children: [error ? (_jsxs("p", { role: "alert", children: [error, " ", _jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
60
64
  setRetry((value) => value + 1);
61
65
  }, children: "Retry" })] })) : null, !owner && !error ? _jsx("p", { role: "status", children: "Loading desktop\u2026" }) : null, owner ? _jsx(ArrangedDesktop, { ...props, owner: owner }) : null] }));
@@ -9,6 +9,8 @@ export type DesktopLayoutClient = Readonly<{
9
9
  };
10
10
  subscribe: (listener: () => void) => () => void;
11
11
  refresh: () => Promise<void>;
12
+ /** Re-read in the background; a failure shows through `refreshFailure`. */
13
+ invalidate: () => void;
12
14
  mutate: (method: DesktopMutation, input: WhistleJsonValue) => Promise<DesktopArrangeResult>;
13
15
  close: () => void;
14
16
  }>;
@@ -85,10 +85,11 @@ export async function createDesktopLayoutClient({ storage, methods, }) {
85
85
  });
86
86
  return pending;
87
87
  };
88
- const unsubscribe = watch.subscribe(() => {
89
- // Handle watcher failures through the same idempotent state publisher.
88
+ // Handle background failures through the same idempotent state publisher.
89
+ const invalidate = () => {
90
90
  void refresh().catch(publishRefreshFailure);
91
- });
91
+ };
92
+ const unsubscribe = watch.subscribe(invalidate);
92
93
  const close = () => {
93
94
  closed = true;
94
95
  retirePending?.();
@@ -116,6 +117,7 @@ export async function createDesktopLayoutClient({ storage, methods, }) {
116
117
  };
117
118
  },
118
119
  refresh,
120
+ invalidate,
119
121
  async mutate(method, input) {
120
122
  const result = desktopArrangeResultSchema.parse(await methods.call(method, JSON.stringify(input)));
121
123
  if (result.kind === "accepted" || result.code === "conflict")
@@ -243,6 +243,7 @@ export function createTourtoisShell(workbench) {
243
243
  focus: commands.focus,
244
244
  registerShellCommands: commands.registerShellCommands,
245
245
  registerDirectoryCommands: commands.registerDirectoryCommands,
246
+ appCommand: commands.appCommand,
246
247
  menu: commands.menu,
247
248
  snapshot: commands.snapshot,
248
249
  active: commands.active,
@@ -17,19 +17,21 @@ const statusIcons = {
17
17
  * the Computer picker, never a raised key. Caps' status-bar chip replaces
18
18
  * this composition when it ships.
19
19
  */
20
- const StatusBarButton = forwardRef(function StatusBarButton({ label, icon, count, pressed, expanded, ...props }, ref) {
21
- return (_jsxs(Button, { ...props, ref: ref, variant: "ghost", size: "xs", className: "caps-status-bar-picker tourtois-status-chip", ...(pressed === undefined ? {} : { "aria-pressed": pressed }), ...(expanded === undefined ? {} : { "aria-expanded": expanded }), children: [icon, _jsx("span", { className: "caps-status-bar-picker-label tourtois-status-chip-label", children: label }), count] }));
20
+ const StatusBarButton = forwardRef(function StatusBarButton({ label, icon, count, light, pressed, expanded, ...props }, ref) {
21
+ return (_jsxs(Button, { ...props, ref: ref, variant: "ghost", size: "xs", className: "caps-status-bar-picker tourtois-status-chip", ...(pressed === undefined ? {} : { "aria-pressed": pressed }), ...(expanded === undefined ? {} : { "aria-expanded": expanded }), children: [icon, light, _jsx("span", { className: "caps-status-bar-picker-label tourtois-status-chip-label", children: label }), count] }));
22
22
  });
23
23
  /** One App-declared status item, drawn by the shell as a flat status chip. */
24
24
  export function WindowStatusItem({ item, container, onAction, anchorFor, }) {
25
25
  const count = (item.kind === "button" || item.kind === "menu") &&
26
26
  item.count !== undefined &&
27
27
  item.count > 0 ? (_jsx(CountBadge, { count: item.count })) : null;
28
+ const light = item.kind === "button" &&
29
+ (item.tone !== undefined || item.busy === true) ? (_jsx(Status, { size: "compact", "aria-hidden": "true", tone: item.tone ?? "accent", motion: item.busy === true ? "pulse" : "none" })) : undefined;
28
30
  switch (item.kind) {
29
31
  case "text":
30
32
  return item.tone === undefined ? (_jsx(AppStatusItem, { role: "status", children: item.text })) : (_jsx(Status, { size: "compact", tone: item.tone, label: item.text }));
31
33
  case "button":
32
- return (_jsx(StatusBarButton, { label: item.text, icon: item.icon === undefined ? undefined : statusIcons[item.icon], count: count, title: item.title ?? item.text, disabled: item.disabled, "aria-haspopup": item.opens, ...(item.expanded === undefined ? {} : { expanded: item.expanded }), onClick: (event) => {
34
+ return (_jsx(StatusBarButton, { label: item.text, icon: item.icon === undefined ? undefined : statusIcons[item.icon], count: count, light: light, title: item.title ?? item.text, disabled: item.disabled, "aria-haspopup": item.opens, ...(item.expanded === undefined ? {} : { expanded: item.expanded }), onClick: (event) => {
33
35
  const anchor = anchorFor(event.currentTarget);
34
36
  onAction(anchor === undefined
35
37
  ? { itemId: item.id }
@@ -1,5 +1,5 @@
1
1
  import type { TourtoisAppSurfaceHost } from "./app-surface.js";
2
- import type { AppDirectoryApp } from "./app-launch.js";
2
+ import { type AppDirectoryApp } from "./app-launch.js";
3
3
  import type { createAppWindowManager, AppLaunchDescriptor } from "./app-windows.js";
4
4
  /** Keep declared commands addressable before their App has mounted. */
5
5
  export declare function useDirectoryCommands({ host, directory, manager, launchFor, onFocus, onError, }: {
@@ -1,26 +1,7 @@
1
1
  import { useEffect, useMemo, useRef } from "react";
2
- function waitForPaletteCommand(host, commandId) {
3
- const isReady = () => host.active().palette.some((command) => command.commandId === commandId);
4
- if (isReady())
5
- return Promise.resolve();
6
- return new Promise((resolve, reject) => {
7
- const timeout = window.setTimeout(() => {
8
- unsubscribe();
9
- reject(new Error("The App did not register its declared command."));
10
- }, 10_000);
11
- const finish = () => {
12
- window.clearTimeout(timeout);
13
- unsubscribe();
14
- resolve();
15
- };
16
- const check = () => {
17
- if (isReady())
18
- finish();
19
- };
20
- const unsubscribe = host.subscribe(check);
21
- check();
22
- });
23
- }
2
+ import { waitForSurfaceCommand } from "./app-launch.js";
3
+ import { appSurfaceCommandKey } from "@fungi.computer/nacre/command-host";
4
+ import { sameWhistleCommand } from "@fungi.computer/whistle";
24
5
  /** Keep declared commands addressable before their App has mounted. */
25
6
  export function useDirectoryCommands({ host, directory, manager, launchFor, onFocus, onError, }) {
26
7
  const advertisedCommands = useMemo(() => directory.flatMap((installation) => installation.commands.map((command) => ({
@@ -56,9 +37,15 @@ export function useDirectoryCommands({ host, directory, manager, launchFor, onFo
56
37
  try {
57
38
  await context.manager.open(launch);
58
39
  context.onFocus(launch.windowId);
59
- const runtimeCommandId = `${encodeURIComponent(surfaceId)}:${commandId}`;
60
- await waitForPaletteCommand(host, runtimeCommandId);
61
- const outcome = await host.execute(runtimeCommandId, {
40
+ const key = appSurfaceCommandKey({ surfaceId, commandId });
41
+ const registered = () => host
42
+ .active()
43
+ .palette.find((row) => sameWhistleCommand(row.command, key));
44
+ await waitForSurfaceCommand(host, () => registered() !== undefined);
45
+ const row = registered();
46
+ if (!row)
47
+ throw new Error("The App did not register its declared command.");
48
+ const outcome = await host.execute(row.command, {
62
49
  origin: "palette",
63
50
  });
64
51
  if (outcome.status === "handled")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/tourtois",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Tourtois: the trusted desktop shell for Oyster and Fungi, with host-supplied Settings sections",
6
6
  "type": "module",
@@ -73,19 +73,19 @@
73
73
  "LICENSE"
74
74
  ],
75
75
  "dependencies": {
76
- "@fungi.computer/agar": "0.3.1",
76
+ "@fungi.computer/agar": "0.5.0",
77
77
  "@fungi.computer/cairn": "0.1.0",
78
- "@fungi.computer/caps": "0.3.0",
78
+ "@fungi.computer/caps": "0.4.0",
79
79
  "@fungi.computer/ear-tag": "0.1.0",
80
80
  "@fungi.computer/gills": "0.2.0",
81
81
  "@fungi.computer/maosaico": "0.1.0",
82
- "@fungi.computer/parakeet": "0.3.0",
82
+ "@fungi.computer/parakeet": "0.5.0",
83
83
  "@fungi.computer/puffball": "0.2.0",
84
- "@fungi.computer/shiitake": "0.4.0",
84
+ "@fungi.computer/shiitake": "0.6.0",
85
85
  "@fungi.computer/sigils": "0.2.0",
86
86
  "@fungi.computer/sprite": "0.2.0",
87
87
  "@fungi.computer/sprite-zdog": "0.2.0",
88
- "@fungi.computer/whistle": "0.1.0",
88
+ "@fungi.computer/whistle": "0.3.0",
89
89
  "@tanstack/history": "1.162.1",
90
90
  "@tanstack/react-form": "1.33.2",
91
91
  "@tanstack/react-query": "5.102.8",
@@ -96,16 +96,16 @@
96
96
  "lucide-react": "0.460.0",
97
97
  "react-dom": "18.3.1",
98
98
  "zod": "4.4.3",
99
- "@fungi.computer/nacre": "0.0.2"
99
+ "@fungi.computer/nacre": "0.1.0"
100
100
  },
101
101
  "peerDependencies": {
102
102
  "react": "^18.3.1 || ^19.2.0"
103
103
  },
104
104
  "devDependencies": {
105
- "@fungi.computer/oyster": "0.4.0",
105
+ "@fungi.computer/oyster": "0.6.0",
106
106
  "@cloudflare/workers-types": "5.20260828.1",
107
107
  "@fungi.computer/forage": "0.2.0",
108
- "@fungi.computer/mycelium": "0.2.2",
108
+ "@fungi.computer/mycelium": "0.2.3",
109
109
  "@ladle/react": "5.1.1",
110
110
  "@types/react": "18.3.3",
111
111
  "@vitejs/plugin-react": "6.1.1",