@peers-app/peers-ui 0.19.12 → 0.19.13

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.
Files changed (53) hide show
  1. package/README.md +7 -2
  2. package/dist/components/voice-playback.d.ts +15 -0
  3. package/dist/components/voice-playback.js +86 -0
  4. package/dist/components/voice-subscribe-events.d.ts +0 -4
  5. package/dist/index.d.ts +10 -2
  6. package/dist/index.js +23 -2
  7. package/dist/operator-console/operator-console-state.d.ts +49 -0
  8. package/dist/operator-console/operator-console-state.js +97 -0
  9. package/dist/operator-console/operator-console.d.ts +32 -0
  10. package/dist/operator-console/operator-console.js +88 -0
  11. package/dist/root-layout/global-overlays.d.ts +37 -0
  12. package/dist/root-layout/global-overlays.js +37 -0
  13. package/dist/root-layout/layout-picker.d.ts +9 -0
  14. package/dist/root-layout/layout-picker.js +22 -0
  15. package/dist/root-layout/layouts/full-screen-layout.d.ts +12 -0
  16. package/dist/root-layout/layouts/full-screen-layout.js +60 -0
  17. package/dist/root-layout/layouts/tabs-with-console-layout.d.ts +20 -0
  18. package/dist/root-layout/layouts/tabs-with-console-layout.js +158 -0
  19. package/dist/root-layout/root-layout-host.d.ts +19 -0
  20. package/dist/root-layout/root-layout-host.js +88 -0
  21. package/dist/root-layout/root-layout-registry.d.ts +15 -0
  22. package/dist/root-layout/root-layout-registry.js +50 -0
  23. package/dist/root-layout/root-layout-state.d.ts +23 -0
  24. package/dist/root-layout/root-layout-state.js +54 -0
  25. package/dist/root-layout/root-layout.d.ts +30 -0
  26. package/dist/root-layout/root-layout.js +12 -0
  27. package/dist/screens/network-viewer/connection-troubleshooter.js +7 -11
  28. package/dist/screens/settings/settings-page.js +2 -1
  29. package/dist/tabs-layout/tabs-layout.d.ts +48 -4
  30. package/dist/tabs-layout/tabs-layout.js +27 -53
  31. package/dist/tabs-layout/tabs-state.d.ts +10 -0
  32. package/dist/tabs-layout/tabs-state.js +33 -1
  33. package/package.json +3 -3
  34. package/src/components/voice-playback.tsx +97 -0
  35. package/src/components/voice-subscribe-events.ts +0 -4
  36. package/src/index.tsx +24 -2
  37. package/src/operator-console/operator-console-state.ts +119 -0
  38. package/src/operator-console/operator-console.tsx +233 -0
  39. package/src/root-layout/global-overlays.ts +50 -0
  40. package/src/root-layout/layout-picker.tsx +47 -0
  41. package/src/root-layout/layouts/full-screen-layout.tsx +144 -0
  42. package/src/root-layout/layouts/tabs-with-console-layout.tsx +260 -0
  43. package/src/root-layout/root-layout-host.tsx +113 -0
  44. package/src/root-layout/root-layout-registry.ts +51 -0
  45. package/src/root-layout/root-layout-state.ts +55 -0
  46. package/src/root-layout/root-layout.ts +32 -0
  47. package/src/screens/network-viewer/connection-troubleshooter.tsx +10 -14
  48. package/src/screens/settings/settings-page.tsx +7 -1
  49. package/src/tabs-layout/tabs-layout.tsx +28 -74
  50. package/src/tabs-layout/tabs-state.ts +31 -0
  51. package/dist/components/chat-overlay.d.ts +0 -14
  52. package/dist/components/chat-overlay.js +0 -477
  53. package/src/components/chat-overlay.tsx +0 -854
@@ -33,24 +33,27 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.TabsLayoutApp = TabsLayoutApp;
37
- exports.TabsLayout = TabsLayout;
36
+ exports.TabErrorBoundary = void 0;
37
+ exports.TabsLayoutInternal = TabsLayoutInternal;
38
+ exports.TabContent = TabContent;
38
39
  const jsx_runtime_1 = require("react/jsx-runtime");
39
40
  const peers_sdk_1 = require("@peers-app/peers-sdk");
40
41
  const react_1 = __importStar(require("react"));
41
42
  const command_palette_1 = require("../command-palette/command-palette");
42
- const command_palette_ui_1 = require("../command-palette/command-palette-ui");
43
43
  const group_switcher_1 = require("../components/group-switcher");
44
44
  const router_1 = require("../components/router");
45
45
  const globals_1 = require("../globals");
46
46
  const hooks_1 = require("../hooks");
47
47
  const color_mode_dropdown_1 = require("../screens/settings/color-mode-dropdown");
48
- const setup_user_1 = require("../screens/setup-user");
49
- const welcome_modal_1 = require("../screens/welcome-modal");
50
48
  const system_apps_1 = require("../system-apps");
51
49
  const routes_loader_1 = require("../ui-router/routes-loader");
52
50
  const ui_loader_1 = require("../ui-router/ui-loader");
53
51
  const tabs_state_1 = require("./tabs-state");
52
+ /**
53
+ * Wraps a single tab's content so a crash in one tab doesn't take down the whole app.
54
+ * Exported so other root layouts (e.g. the full-screen layout) can render a tab body with
55
+ * the same crash isolation. Reset it across screens by giving it a `key`.
56
+ */
54
57
  class TabErrorBoundary extends react_1.Component {
55
58
  constructor(props) {
56
59
  super(props);
@@ -74,53 +77,19 @@ class TabErrorBoundary extends react_1.Component {
74
77
  return this.props.children;
75
78
  }
76
79
  }
77
- function TabsLayoutApp() {
78
- const userId = (0, hooks_1.usePromise)(async () => {
79
- const _userId = await peers_sdk_1.rpcServerCalls.getUserId();
80
- return _userId || null;
81
- });
82
- if (userId === undefined) {
83
- return;
84
- }
85
- if (!userId) {
86
- document.getElementById("appLoadingDiv")?.remove();
87
- return (0, jsx_runtime_1.jsx)(setup_user_1.SetupUser, {});
88
- }
89
- else {
90
- return (0, jsx_runtime_1.jsx)(TabsLayout, { userId: userId });
91
- }
92
- }
93
- let userContextInitialized = false;
94
- function TabsLayout(props) {
95
- if (!userContextInitialized) {
96
- (0, peers_sdk_1.setDefaultClientUserContext)(props.userId);
97
- userContextInitialized = true;
98
- }
99
- const loaded = (0, hooks_1.usePromise)(async () => {
100
- await (0, globals_1.loadGlobals)();
101
- await (0, routes_loader_1.loadAllRoutes)();
102
- await Promise.all([
103
- tabs_state_1.activeTabs.loadingPromise,
104
- tabs_state_1.activeTabId.loadingPromise,
105
- tabs_state_1.recentlyUsedApps.loadingPromise,
106
- peers_sdk_1.hasShownWelcomeModal.loadingPromise,
107
- ]);
108
- return true;
109
- });
110
- const [showWelcomeModal, setShowWelcomeModal] = (0, react_1.useState)(false);
111
- const [_colorMode] = (0, hooks_1.useObservable)(color_mode_dropdown_1.colorMode);
112
- // Check if we should show the welcome modal after loading
113
- (0, react_1.useEffect)(() => {
114
- if (loaded && !(0, peers_sdk_1.hasShownWelcomeModal)()) {
115
- setShowWelcomeModal(true);
116
- }
117
- }, [loaded]);
118
- if (!loaded)
119
- return false;
120
- document.getElementById("appLoadingDiv")?.remove();
121
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(TabsLayoutInternal, {}), (0, jsx_runtime_1.jsx)(command_palette_ui_1.CommandPaletteOverlay, {}), showWelcomeModal && ((0, jsx_runtime_1.jsx)(welcome_modal_1.WelcomeModal, { colorMode: _colorMode, onClose: () => setShowWelcomeModal(false) }))] }));
122
- }
123
- function TabsLayoutInternal() {
80
+ exports.TabErrorBoundary = TabErrorBoundary;
81
+ /**
82
+ * The tab UI body: the tab strip plus the active tab's content. Rendered as the body of
83
+ * the default `tabs` root layout inside `RootLayoutHost` (which owns bootstrapping, the
84
+ * command palette, and the welcome modal). Branches on `isDesktop` for mobile vs. desktop
85
+ * chrome.
86
+ *
87
+ * @param fillContainer When `true`, the body fills its parent (`height: 100%`) instead of
88
+ * sizing itself to the viewport. Set this when embedding the tabs UI inside another
89
+ * layout that owns the vertical space (e.g. the `tabs-with-console` split layout);
90
+ * leave it `false`/unset for the standalone `tabs` layout, which sizes to the viewport.
91
+ */
92
+ function TabsLayoutInternal({ fillContainer = false } = {}) {
124
93
  const [_colorMode] = (0, hooks_1.useObservable)(color_mode_dropdown_1.colorMode);
125
94
  const [tabs] = (0, hooks_1.useObservable)(tabs_state_1.activeTabs);
126
95
  const [activeTab] = (0, hooks_1.useObservable)(tabs_state_1.activeTabId);
@@ -170,7 +139,7 @@ function TabsLayoutInternal() {
170
139
  const switchTab = (tabId) => {
171
140
  (0, tabs_state_1.activeTabId)(tabId);
172
141
  };
173
- return ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex flex-column", style: { height: "calc(100vh - 25px)", overflow: "hidden" }, children: [switchingGroup && ((0, jsx_runtime_1.jsx)("div", { style: {
142
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex flex-column", style: { height: fillContainer ? "100%" : "calc(100vh - 25px)", overflow: "hidden" }, children: [switchingGroup && ((0, jsx_runtime_1.jsx)("div", { style: {
174
143
  position: "fixed",
175
144
  inset: 0,
176
145
  zIndex: 9999,
@@ -284,6 +253,11 @@ function TabHeader({ tab, isActive, onSwitch, onClose, colorMode }) {
284
253
  onClose(tab.tabId);
285
254
  }, children: (0, jsx_runtime_1.jsx)("i", { className: "bi-x" }) }))] }));
286
255
  }
256
+ /**
257
+ * Renders a single tab's body, lazily initializing it on first activation and keeping it
258
+ * mounted thereafter (`initializedTabs`). Exported so other root layouts (e.g. full-screen)
259
+ * can render the active screen through the same routing/launcher path.
260
+ */
287
261
  function TabContent({ tab, isMobile, isActive }) {
288
262
  // Only render content if this tab is active OR has been previously initialized
289
263
  const shouldRender = isActive || tabs_state_1.initializedTabs.has(tab.tabId);
@@ -26,6 +26,16 @@ type AppInfo = {
26
26
  };
27
27
  export declare function determineAppFromPath(path: string): AppInfo | undefined;
28
28
  export declare const updateActiveTabTitle: (newTitle: string) => void;
29
+ /**
30
+ * Enable or disable single-tab mode.
31
+ *
32
+ * When enabled, {@link openNewTab} replaces the active (non-launcher) screen in place
33
+ * rather than appending a new tab, so single-screen layouts (e.g. full-screen) never
34
+ * accumulate tabs. This is non-destructive: any pre-existing tabs are left untouched and
35
+ * reappear when a multi-tab layout becomes active again. Layouts should enable it on mount
36
+ * and disable it on unmount.
37
+ */
38
+ export declare const setSingleTabMode: (enabled: boolean) => void;
29
39
  export declare const openNewTab: (tab: Omit<TabState, "tabId">, forceNew?: boolean) => void;
30
40
  export declare const closeCurrentTab: () => void;
31
41
  export declare const switchToNextTab: () => void;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.switchToPreviousTab = exports.switchToNextTab = exports.closeCurrentTab = exports.openNewTab = exports.updateActiveTabTitle = exports.handleMainPathChanged = exports.initializedTabs = exports.recentlyUsedApps = exports.activeTabId = exports.activeTabs = exports.launcherApp = void 0;
3
+ exports.switchToPreviousTab = exports.switchToNextTab = exports.closeCurrentTab = exports.openNewTab = exports.setSingleTabMode = exports.updateActiveTabTitle = exports.handleMainPathChanged = exports.initializedTabs = exports.recentlyUsedApps = exports.activeTabId = exports.activeTabs = exports.launcherApp = void 0;
4
4
  exports.goToTabPath = goToTabPath;
5
5
  exports.determineAppFromPath = determineAppFromPath;
6
6
  const peers_sdk_1 = require("@peers-app/peers-sdk");
@@ -206,6 +206,22 @@ const updateActiveTabTitle = (newTitle) => {
206
206
  }, 100);
207
207
  };
208
208
  exports.updateActiveTabTitle = updateActiveTabTitle;
209
+ // Single-tab mode: when enabled, opening an app replaces the current screen instead of
210
+ // appending a tab. Used by the full-screen root layout so it shows one screen at a time.
211
+ let singleTabModeEnabled = false;
212
+ /**
213
+ * Enable or disable single-tab mode.
214
+ *
215
+ * When enabled, {@link openNewTab} replaces the active (non-launcher) screen in place
216
+ * rather than appending a new tab, so single-screen layouts (e.g. full-screen) never
217
+ * accumulate tabs. This is non-destructive: any pre-existing tabs are left untouched and
218
+ * reappear when a multi-tab layout becomes active again. Layouts should enable it on mount
219
+ * and disable it on unmount.
220
+ */
221
+ const setSingleTabMode = (enabled) => {
222
+ singleTabModeEnabled = enabled;
223
+ };
224
+ exports.setSingleTabMode = setSingleTabMode;
209
225
  // Global function to open content in new tab
210
226
  const openNewTab = (tab, forceNew = false) => {
211
227
  const newTab = {
@@ -218,6 +234,22 @@ const openNewTab = (tab, forceNew = false) => {
218
234
  // Switch to existing tab
219
235
  (0, exports.activeTabId)(currentTabs[existingTabIndex].tabId);
220
236
  }
237
+ else if (singleTabModeEnabled) {
238
+ // Single-tab mode: replace the active non-launcher screen in place. From the launcher
239
+ // (home), append once so the launcher stays put and the new app becomes the single
240
+ // app slot.
241
+ const activeIndex = currentTabs.findIndex((t) => t.tabId === (0, exports.activeTabId)());
242
+ const canReplace = activeIndex >= 0 && currentTabs[activeIndex].tabId !== "launcher";
243
+ if (canReplace) {
244
+ const newTabs = [...currentTabs];
245
+ newTabs[activeIndex] = newTab;
246
+ (0, exports.activeTabs)(newTabs);
247
+ }
248
+ else {
249
+ (0, exports.activeTabs)([...currentTabs, newTab]);
250
+ }
251
+ (0, exports.activeTabId)(newTab.tabId);
252
+ }
221
253
  else {
222
254
  // Always add new tab (for app launcher or when forceNew is true)
223
255
  (0, exports.activeTabs)([...currentTabs, newTab]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.19.12",
3
+ "version": "0.19.13",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -28,7 +28,7 @@
28
28
  "lint:fix": "biome check --write ."
29
29
  },
30
30
  "peerDependencies": {
31
- "@peers-app/peers-sdk": "^0.19.12",
31
+ "@peers-app/peers-sdk": "^0.19.13",
32
32
  "bootstrap": "^5.3.3",
33
33
  "react": "^18.0.0",
34
34
  "react-dom": "^18.0.0"
@@ -39,7 +39,7 @@
39
39
  "@babel/preset-env": "^7.24.5",
40
40
  "@babel/preset-react": "^7.24.1",
41
41
  "@babel/preset-typescript": "^7.27.1",
42
- "@peers-app/peers-sdk": "0.19.12",
42
+ "@peers-app/peers-sdk": "0.19.13",
43
43
  "@testing-library/dom": "^10.4.0",
44
44
  "@testing-library/jest-dom": "^6.6.3",
45
45
  "@testing-library/react": "^16.3.0",
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Voice Playback Component
3
+ *
4
+ * Headless (renders nothing) component that hosts the browser-side voice TTS
5
+ * playback handlers. The voice server (peers-electron `tts-service`) emits
6
+ * `voice:speakText` / `voice:playAudio` / `voice:stopSpeaking` events and relies
7
+ * on a mounted renderer to actually produce sound and report completion via
8
+ * `voiceNotifyPlaybackComplete`. Mount this once near the app root so voice
9
+ * output works regardless of which (if any) voice UI is visible.
10
+ */
11
+
12
+ import { rpcServerCalls, subscribe } from "@peers-app/peers-sdk";
13
+ import { useEffect, useRef } from "react";
14
+ import type {
15
+ VoicePlayAudioPayload,
16
+ VoiceSpeakPayload,
17
+ VoiceSubscribeEvent,
18
+ } from "./voice-subscribe-events";
19
+
20
+ /**
21
+ * Subscribes to voice TTS events and plays them in the browser. Renders nothing.
22
+ */
23
+ export const VoicePlayback: React.FC = () => {
24
+ // Track the current cloud-TTS audio element so it can be stopped on demand.
25
+ const currentAudioRef = useRef<HTMLAudioElement | null>(null);
26
+
27
+ useEffect(() => {
28
+ const speakHandler = subscribe(
29
+ "voice:speakText",
30
+ (event: VoiceSubscribeEvent<VoiceSpeakPayload>) => {
31
+ const { text, voice, rate } = event.data;
32
+ if ("speechSynthesis" in window) {
33
+ const utterance = new SpeechSynthesisUtterance(text);
34
+ if (voice) {
35
+ const voices = speechSynthesis.getVoices();
36
+ const selectedVoice = voices.find((v) => v.name === voice);
37
+ if (selectedVoice) utterance.voice = selectedVoice;
38
+ }
39
+ if (rate) utterance.rate = rate;
40
+ utterance.onend = () => {
41
+ rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
42
+ };
43
+ utterance.onerror = () => {
44
+ rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
45
+ };
46
+ speechSynthesis.speak(utterance);
47
+ } else {
48
+ rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
49
+ }
50
+ },
51
+ );
52
+
53
+ const stopHandler = subscribe("voice:stopSpeaking", () => {
54
+ if ("speechSynthesis" in window) {
55
+ speechSynthesis.cancel();
56
+ }
57
+ if (currentAudioRef.current) {
58
+ currentAudioRef.current.pause();
59
+ currentAudioRef.current = null;
60
+ }
61
+ });
62
+
63
+ const playHandler = subscribe(
64
+ "voice:playAudio",
65
+ async (event: VoiceSubscribeEvent<VoicePlayAudioPayload>) => {
66
+ const { audioBase64, mimeType } = event.data;
67
+ try {
68
+ const audioData = Uint8Array.from(atob(audioBase64), (c) => c.charCodeAt(0));
69
+ const blob = new Blob([audioData], { type: mimeType });
70
+ const url = URL.createObjectURL(blob);
71
+ const audio = new Audio(url);
72
+ currentAudioRef.current = audio;
73
+ audio.onended = () => {
74
+ URL.revokeObjectURL(url);
75
+ currentAudioRef.current = null;
76
+ rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
77
+ };
78
+ await audio.play();
79
+ } catch (e) {
80
+ console.error("Failed to play audio:", e);
81
+ currentAudioRef.current = null;
82
+ rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
83
+ }
84
+ },
85
+ );
86
+
87
+ return () => {
88
+ speakHandler.unsubscribe();
89
+ stopHandler.unsubscribe();
90
+ playHandler.unsubscribe();
91
+ };
92
+ }, []);
93
+
94
+ return null;
95
+ };
96
+
97
+ export default VoicePlayback;
@@ -1,10 +1,6 @@
1
- import type { IMessage } from "@peers-app/peers-sdk";
2
-
3
1
  /** Shape passed to `subscribe` handlers for voice / chat IPC events */
4
2
  export type VoiceSubscribeEvent<T> = { data: T };
5
3
 
6
- export type ChatOpenWithMessagePayload = { message?: IMessage };
7
-
8
4
  export type VoiceStatePayload = { state: string };
9
5
 
10
6
  export type VoiceTranscriptionPayload = { text: string };
package/src/index.tsx CHANGED
@@ -1,4 +1,3 @@
1
- export * from "./components/chat-overlay";
2
1
  export * from "./components/inverse-lazy-list";
3
2
  export type { IEditorEffects, IMarkdownEditorProps } from "./components/markdown-editor/editor";
4
3
  export { MarkdownEditor } from "./components/markdown-editor/editor";
@@ -8,8 +7,31 @@ export * from "./components/sortable-list";
8
7
  export * from "./components/tabs";
9
8
  export { Typeahead, type TypeaheadItem } from "./components/typeahead";
10
9
  export * from "./components/voice-indicator";
11
- export { mainContentPath } from "./globals";
10
+ export * from "./components/voice-playback";
11
+ export { isDesktop, mainContentPath } from "./globals";
12
12
  export * from "./hooks";
13
+ export * from "./operator-console/operator-console";
14
+ export * from "./operator-console/operator-console-state";
15
+ export {
16
+ type GlobalOverlay,
17
+ globalOverlays,
18
+ registerGlobalOverlay,
19
+ unregisterGlobalOverlay,
20
+ } from "./root-layout/global-overlays";
21
+ export { LayoutPicker } from "./root-layout/layout-picker";
22
+ export type { RootLayout } from "./root-layout/root-layout";
23
+ export {
24
+ RootLayoutHost,
25
+ // Compat alias: the desktop/PWA/RN clients historically mounted `TabsLayoutApp`.
26
+ // The host now owns bootstrapping; the tabs UI is just the default layout body.
27
+ RootLayoutHost as TabsLayoutApp,
28
+ } from "./root-layout/root-layout-host";
29
+ export {
30
+ DEFAULT_ROOT_LAYOUT_ID,
31
+ getRootLayout,
32
+ getRootLayouts,
33
+ } from "./root-layout/root-layout-registry";
34
+ export { selectedRootLayoutId, setRootLayout } from "./root-layout/root-layout-state";
13
35
  export * from "./tabs-layout/tabs-layout";
14
36
  export {
15
37
  activeTabId,
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Operator Console — panel state
3
+ *
4
+ * Persistent, per-device state for the Operator Console shell plus the control
5
+ * helpers and the global keyboard shortcut that toggles it. The panel itself
6
+ * ({@link OperatorConsolePanel}) reads this state reactively via `useObservable`.
7
+ */
8
+
9
+ import { deviceVar } from "@peers-app/peers-sdk";
10
+ import { closeCommandPalette, registerCommand } from "../command-palette/command-palette";
11
+
12
+ /** Edge the console panel is docked to. */
13
+ export type ConsoleDock = "bottom" | "left" | "right";
14
+
15
+ /** Persisted layout/visibility state for the Operator Console panel. */
16
+ export interface ConsolePanelState {
17
+ /** Whether the panel is expanded (open) or collapsed to its edge strip. */
18
+ expanded: boolean;
19
+ /** Which screen edge the panel is docked to. */
20
+ dock: ConsoleDock;
21
+ /** Panel height in pixels when docked to the bottom. */
22
+ bottomHeight: number;
23
+ /** Panel width in pixels when docked to the left or right. */
24
+ sideWidth: number;
25
+ /** Whether the panel is maximized to fill the layout body (hiding the tabs panel). */
26
+ maximized: boolean;
27
+ }
28
+
29
+ /** Default panel state: collapsed, docked to the bottom. */
30
+ export const DEFAULT_CONSOLE_PANEL_STATE: ConsolePanelState = {
31
+ expanded: false,
32
+ dock: "bottom",
33
+ bottomHeight: 300,
34
+ sideWidth: 420,
35
+ maximized: false,
36
+ };
37
+
38
+ /**
39
+ * Per-device persisted state for the Operator Console panel. Layout/visibility
40
+ * is a per-device preference, so this uses `deviceVar` (not synced across devices).
41
+ */
42
+ export const operatorConsolePanel = deviceVar<ConsolePanelState>("operatorConsole:panel", {
43
+ defaultValue: DEFAULT_CONSOLE_PANEL_STATE,
44
+ });
45
+
46
+ function update(patch: Partial<ConsolePanelState>): void {
47
+ const current = operatorConsolePanel() ?? DEFAULT_CONSOLE_PANEL_STATE;
48
+ operatorConsolePanel({ ...current, ...patch });
49
+ }
50
+
51
+ /** Toggle the console between expanded and collapsed. */
52
+ export function toggleConsole(): void {
53
+ const current = operatorConsolePanel() ?? DEFAULT_CONSOLE_PANEL_STATE;
54
+ update({ expanded: !current.expanded });
55
+ }
56
+
57
+ /** Expand (open) the console. */
58
+ export function openConsole(): void {
59
+ update({ expanded: true });
60
+ }
61
+
62
+ /** Collapse (close) the console. */
63
+ export function closeConsole(): void {
64
+ update({ expanded: false });
65
+ }
66
+
67
+ /** Dock the console to the given edge. */
68
+ export function setConsoleDock(dock: ConsoleDock): void {
69
+ update({ dock });
70
+ }
71
+
72
+ /** Set whether the console is maximized (fills the layout body, hiding the tabs panel). */
73
+ export function setConsoleMaximized(maximized: boolean): void {
74
+ update({ maximized });
75
+ }
76
+
77
+ /**
78
+ * Persist a new panel size. Use `bottomHeight` when docked to the bottom and
79
+ * `sideWidth` when docked to a side.
80
+ */
81
+ export function setConsoleSize(size: { bottomHeight?: number; sideWidth?: number }): void {
82
+ update(size);
83
+ }
84
+
85
+ /**
86
+ * Global keyboard handler for the console toggle shortcut. Ctrl+` on all
87
+ * platforms (matching VS Code / Cursor). Intentionally not Cmd on macOS.
88
+ */
89
+ function handleKeyDown(event: KeyboardEvent): void {
90
+ if (event.ctrlKey && !event.metaKey && !event.altKey && event.code === "Backquote") {
91
+ event.preventDefault();
92
+ toggleConsole();
93
+ }
94
+ }
95
+
96
+ let initialized = false;
97
+
98
+ /** Install the global Ctrl+` listener and register the palette command (idempotent). */
99
+ export function initializeOperatorConsole(): void {
100
+ if (initialized || typeof window === "undefined") return;
101
+ initialized = true;
102
+
103
+ document.addEventListener("keydown", handleKeyDown);
104
+
105
+ registerCommand({
106
+ id: "toggle-operator-console",
107
+ label: "Toggle Console",
108
+ description: "Show or hide the Operator Console panel",
109
+ shortcut: "Ctrl+`",
110
+ iconClassName: "bi-terminal",
111
+ category: "View",
112
+ action: () => {
113
+ closeCommandPalette();
114
+ toggleConsole();
115
+ },
116
+ });
117
+ }
118
+
119
+ initializeOperatorConsole();