@agimon-ai/doompi-web 0.0.1-alpha.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.
- package/LICENSE +29 -0
- package/README.md +196 -0
- package/dist/_virtual/_rolldown/runtime.cjs +1 -0
- package/dist/bin/serve.cjs +3 -0
- package/dist/bin/serve.cjs.map +1 -0
- package/dist/bin/serve.d.cts +1 -0
- package/dist/bin/serve.d.mts +1 -0
- package/dist/bin/serve.mjs +3 -0
- package/dist/bin/serve.mjs.map +1 -0
- package/dist/bundler.cjs +1 -0
- package/dist/bundler.d.cts +2 -0
- package/dist/bundler.d.mts +2 -0
- package/dist/bundler.mjs +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +7 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +1 -0
- package/dist/src/adapters/authRoutes.cjs +2 -0
- package/dist/src/adapters/authRoutes.cjs.map +1 -0
- package/dist/src/adapters/authRoutes.mjs +2 -0
- package/dist/src/adapters/authRoutes.mjs.map +1 -0
- package/dist/src/adapters/bundledServer.cjs +2 -0
- package/dist/src/adapters/bundledServer.cjs.map +1 -0
- package/dist/src/adapters/bundledServer.mjs +2 -0
- package/dist/src/adapters/bundledServer.mjs.map +1 -0
- package/dist/src/adapters/directoryListing.cjs +2 -0
- package/dist/src/adapters/directoryListing.cjs.map +1 -0
- package/dist/src/adapters/directoryListing.mjs +2 -0
- package/dist/src/adapters/directoryListing.mjs.map +1 -0
- package/dist/src/adapters/directorySearch.cjs +3 -0
- package/dist/src/adapters/directorySearch.cjs.map +1 -0
- package/dist/src/adapters/directorySearch.mjs +3 -0
- package/dist/src/adapters/directorySearch.mjs.map +1 -0
- package/dist/src/adapters/gitStatus.cjs +2 -0
- package/dist/src/adapters/gitStatus.cjs.map +1 -0
- package/dist/src/adapters/gitStatus.mjs +2 -0
- package/dist/src/adapters/gitStatus.mjs.map +1 -0
- package/dist/src/adapters/httpServer.cjs +2 -0
- package/dist/src/adapters/httpServer.cjs.map +1 -0
- package/dist/src/adapters/httpServer.d.cts +14 -0
- package/dist/src/adapters/httpServer.d.cts.map +1 -0
- package/dist/src/adapters/httpServer.d.mts +14 -0
- package/dist/src/adapters/httpServer.d.mts.map +1 -0
- package/dist/src/adapters/httpServer.mjs +2 -0
- package/dist/src/adapters/httpServer.mjs.map +1 -0
- package/dist/src/adapters/hubProbe.cjs +2 -0
- package/dist/src/adapters/hubProbe.cjs.map +1 -0
- package/dist/src/adapters/hubProbe.mjs +2 -0
- package/dist/src/adapters/hubProbe.mjs.map +1 -0
- package/dist/src/adapters/packageApiProxy.cjs +2 -0
- package/dist/src/adapters/packageApiProxy.cjs.map +1 -0
- package/dist/src/adapters/packageApiProxy.mjs +2 -0
- package/dist/src/adapters/packageApiProxy.mjs.map +1 -0
- package/dist/src/adapters/piHubService.cjs +2 -0
- package/dist/src/adapters/piHubService.cjs.map +1 -0
- package/dist/src/adapters/piHubService.mjs +2 -0
- package/dist/src/adapters/piHubService.mjs.map +1 -0
- package/dist/src/adapters/piWebSocketListener.cjs +2 -0
- package/dist/src/adapters/piWebSocketListener.cjs.map +1 -0
- package/dist/src/adapters/piWebSocketListener.mjs +2 -0
- package/dist/src/adapters/piWebSocketListener.mjs.map +1 -0
- package/dist/src/adapters/providerAuth.cjs +2 -0
- package/dist/src/adapters/providerAuth.cjs.map +1 -0
- package/dist/src/adapters/providerAuth.mjs +2 -0
- package/dist/src/adapters/providerAuth.mjs.map +1 -0
- package/dist/src/adapters/registryWatcher.cjs +2 -0
- package/dist/src/adapters/registryWatcher.cjs.map +1 -0
- package/dist/src/adapters/registryWatcher.mjs +2 -0
- package/dist/src/adapters/registryWatcher.mjs.map +1 -0
- package/dist/src/adapters/serverSpawner.cjs +2 -0
- package/dist/src/adapters/serverSpawner.cjs.map +1 -0
- package/dist/src/adapters/serverSpawner.mjs +2 -0
- package/dist/src/adapters/serverSpawner.mjs.map +1 -0
- package/dist/src/adapters/sessionFiles.cjs +3 -0
- package/dist/src/adapters/sessionFiles.cjs.map +1 -0
- package/dist/src/adapters/sessionFiles.mjs +3 -0
- package/dist/src/adapters/sessionFiles.mjs.map +1 -0
- package/dist/src/adapters/sessionHub.cjs +2 -0
- package/dist/src/adapters/sessionHub.cjs.map +1 -0
- package/dist/src/adapters/sessionHub.mjs +2 -0
- package/dist/src/adapters/sessionHub.mjs.map +1 -0
- package/dist/src/adapters/sessionSocketClient.cjs +2 -0
- package/dist/src/adapters/sessionSocketClient.cjs.map +1 -0
- package/dist/src/adapters/sessionSocketClient.mjs +2 -0
- package/dist/src/adapters/sessionSocketClient.mjs.map +1 -0
- package/dist/src/adapters/syncGuard.cjs +2 -0
- package/dist/src/adapters/syncGuard.cjs.map +1 -0
- package/dist/src/adapters/syncGuard.mjs +2 -0
- package/dist/src/adapters/syncGuard.mjs.map +1 -0
- package/dist/src/adapters/threadJournals.cjs +2 -0
- package/dist/src/adapters/threadJournals.cjs.map +1 -0
- package/dist/src/adapters/threadJournals.mjs +2 -0
- package/dist/src/adapters/threadJournals.mjs.map +1 -0
- package/dist/src/adapters/webBundler.cjs +2 -0
- package/dist/src/adapters/webBundler.cjs.map +1 -0
- package/dist/src/adapters/webBundler.d.cts +28 -0
- package/dist/src/adapters/webBundler.d.cts.map +1 -0
- package/dist/src/adapters/webBundler.d.mts +28 -0
- package/dist/src/adapters/webBundler.d.mts.map +1 -0
- package/dist/src/adapters/webBundler.mjs +2 -0
- package/dist/src/adapters/webBundler.mjs.map +1 -0
- package/dist/src/adapters/webHubPluginLoader.cjs +2 -0
- package/dist/src/adapters/webHubPluginLoader.cjs.map +1 -0
- package/dist/src/adapters/webHubPluginLoader.mjs +2 -0
- package/dist/src/adapters/webHubPluginLoader.mjs.map +1 -0
- package/dist/src/adapters/webHubPlugins.generated.cjs +2 -0
- package/dist/src/adapters/webHubPlugins.generated.cjs.map +1 -0
- package/dist/src/adapters/webHubPlugins.generated.mjs +2 -0
- package/dist/src/adapters/webHubPlugins.generated.mjs.map +1 -0
- package/dist/src/adapters/webPluginGenerate.cjs +4 -0
- package/dist/src/adapters/webPluginGenerate.cjs.map +1 -0
- package/dist/src/adapters/webPluginGenerate.mjs +4 -0
- package/dist/src/adapters/webPluginGenerate.mjs.map +1 -0
- package/dist/src/adapters/webPluginScan.cjs +2 -0
- package/dist/src/adapters/webPluginScan.cjs.map +1 -0
- package/dist/src/adapters/webPluginScan.mjs +2 -0
- package/dist/src/adapters/webPluginScan.mjs.map +1 -0
- package/dist/src/adapters/webPluginVite.cjs +2 -0
- package/dist/src/adapters/webPluginVite.cjs.map +1 -0
- package/dist/src/adapters/webPluginVite.mjs +2 -0
- package/dist/src/adapters/webPluginVite.mjs.map +1 -0
- package/dist/src/services/directoryMatch.cjs +2 -0
- package/dist/src/services/directoryMatch.cjs.map +1 -0
- package/dist/src/services/directoryMatch.mjs +2 -0
- package/dist/src/services/directoryMatch.mjs.map +1 -0
- package/dist/src/services/fileMatch.cjs +2 -0
- package/dist/src/services/fileMatch.cjs.map +1 -0
- package/dist/src/services/fileMatch.mjs +2 -0
- package/dist/src/services/fileMatch.mjs.map +1 -0
- package/dist/src/services/fileMedia.cjs +2 -0
- package/dist/src/services/fileMedia.cjs.map +1 -0
- package/dist/src/services/fileMedia.mjs +2 -0
- package/dist/src/services/fileMedia.mjs.map +1 -0
- package/dist/src/services/frameRing.cjs +2 -0
- package/dist/src/services/frameRing.cjs.map +1 -0
- package/dist/src/services/frameRing.mjs +2 -0
- package/dist/src/services/frameRing.mjs.map +1 -0
- package/dist/src/services/journalTail.cjs +3 -0
- package/dist/src/services/journalTail.cjs.map +1 -0
- package/dist/src/services/journalTail.mjs +3 -0
- package/dist/src/services/journalTail.mjs.map +1 -0
- package/dist/src/services/loginFlow.cjs +2 -0
- package/dist/src/services/loginFlow.cjs.map +1 -0
- package/dist/src/services/loginFlow.mjs +2 -0
- package/dist/src/services/loginFlow.mjs.map +1 -0
- package/dist/src/services/registryStore.cjs +2 -0
- package/dist/src/services/registryStore.cjs.map +1 -0
- package/dist/src/services/registryStore.mjs +2 -0
- package/dist/src/services/registryStore.mjs.map +1 -0
- package/dist/src/services/retryPolicy.cjs +2 -0
- package/dist/src/services/retryPolicy.cjs.map +1 -0
- package/dist/src/services/retryPolicy.mjs +2 -0
- package/dist/src/services/retryPolicy.mjs.map +1 -0
- package/dist/src/services/serveOptions.cjs +2 -0
- package/dist/src/services/serveOptions.cjs.map +1 -0
- package/dist/src/services/serveOptions.d.cts +21 -0
- package/dist/src/services/serveOptions.d.cts.map +1 -0
- package/dist/src/services/serveOptions.d.mts +21 -0
- package/dist/src/services/serveOptions.d.mts.map +1 -0
- package/dist/src/services/serveOptions.mjs +2 -0
- package/dist/src/services/serveOptions.mjs.map +1 -0
- package/dist/src/services/sessionFraming.cjs +4 -0
- package/dist/src/services/sessionFraming.cjs.map +1 -0
- package/dist/src/services/sessionFraming.mjs +4 -0
- package/dist/src/services/sessionFraming.mjs.map +1 -0
- package/dist/src/services/sessionPresence.cjs +2 -0
- package/dist/src/services/sessionPresence.cjs.map +1 -0
- package/dist/src/services/sessionPresence.mjs +2 -0
- package/dist/src/services/sessionPresence.mjs.map +1 -0
- package/dist/src/services/staticAssets.cjs +2 -0
- package/dist/src/services/staticAssets.cjs.map +1 -0
- package/dist/src/services/staticAssets.mjs +2 -0
- package/dist/src/services/staticAssets.mjs.map +1 -0
- package/dist/src/services/webDevRoots.cjs +2 -0
- package/dist/src/services/webDevRoots.cjs.map +1 -0
- package/dist/src/services/webDevRoots.mjs +2 -0
- package/dist/src/services/webDevRoots.mjs.map +1 -0
- package/dist/src/services/webPluginManifest.cjs +2 -0
- package/dist/src/services/webPluginManifest.cjs.map +1 -0
- package/dist/src/services/webPluginManifest.mjs +2 -0
- package/dist/src/services/webPluginManifest.mjs.map +1 -0
- package/dist/src/types/auth.cjs +2 -0
- package/dist/src/types/auth.cjs.map +1 -0
- package/dist/src/types/auth.d.cts +94 -0
- package/dist/src/types/auth.d.cts.map +1 -0
- package/dist/src/types/auth.d.mts +94 -0
- package/dist/src/types/auth.d.mts.map +1 -0
- package/dist/src/types/auth.mjs +2 -0
- package/dist/src/types/auth.mjs.map +1 -0
- package/dist/src/types/bridge.d.cts +31 -0
- package/dist/src/types/bridge.d.cts.map +1 -0
- package/dist/src/types/bridge.d.mts +31 -0
- package/dist/src/types/bridge.d.mts.map +1 -0
- package/dist/src/types/hub.cjs +2 -0
- package/dist/src/types/hub.cjs.map +1 -0
- package/dist/src/types/hub.d.cts +58 -0
- package/dist/src/types/hub.d.cts.map +1 -0
- package/dist/src/types/hub.d.mts +58 -0
- package/dist/src/types/hub.d.mts.map +1 -0
- package/dist/src/types/hub.mjs +2 -0
- package/dist/src/types/hub.mjs.map +1 -0
- package/dist/src/types/media.cjs +2 -0
- package/dist/src/types/media.cjs.map +1 -0
- package/dist/src/types/media.mjs +2 -0
- package/dist/src/types/media.mjs.map +1 -0
- package/dist/src/types/registry.cjs +1 -0
- package/dist/src/types/registry.d.cts +2 -0
- package/dist/src/types/registry.d.mts +2 -0
- package/dist/src/types/registry.mjs +1 -0
- package/dist/src/types/session.cjs +2 -0
- package/dist/src/types/session.cjs.map +1 -0
- package/dist/src/types/session.d.cts +24 -0
- package/dist/src/types/session.d.cts.map +1 -0
- package/dist/src/types/session.d.mts +24 -0
- package/dist/src/types/session.d.mts.map +1 -0
- package/dist/src/types/session.mjs +2 -0
- package/dist/src/types/session.mjs.map +1 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-Cg52ngKv.css +2 -0
- package/dist/web/assets/index-DQO7NTIP.js +90 -0
- package/dist/web/assets/index-DQO7NTIP.js.map +1 -0
- package/dist/web/favicon-16.png +0 -0
- package/dist/web/favicon-32.png +0 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/favicon.svg +16 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/index.html +17 -0
- package/llms.txt +8 -0
- package/package.json +106 -0
- package/src/adapters/authRoutes.ts +95 -0
- package/src/adapters/bundledServer.ts +64 -0
- package/src/adapters/directoryListing.ts +127 -0
- package/src/adapters/directorySearch.ts +124 -0
- package/src/adapters/gitStatus.ts +27 -0
- package/src/adapters/httpServer.ts +556 -0
- package/src/adapters/hubProbe.ts +27 -0
- package/src/adapters/packageApiProxy.ts +60 -0
- package/src/adapters/piHubService.ts +129 -0
- package/src/adapters/piWebSocketListener.ts +98 -0
- package/src/adapters/providerAuth.ts +181 -0
- package/src/adapters/registryWatcher.ts +112 -0
- package/src/adapters/serverSpawner.ts +181 -0
- package/src/adapters/sessionFiles.ts +99 -0
- package/src/adapters/sessionHub.ts +567 -0
- package/src/adapters/sessionSocketClient.ts +105 -0
- package/src/adapters/syncGuard.ts +119 -0
- package/src/adapters/threadJournals.ts +184 -0
- package/src/adapters/webBundler.ts +83 -0
- package/src/adapters/webHubPluginLoader.ts +67 -0
- package/src/adapters/webHubPlugins.generated.ts +4 -0
- package/src/adapters/webPluginGenerate.ts +172 -0
- package/src/adapters/webPluginScan.ts +62 -0
- package/src/adapters/webPluginVite.ts +51 -0
- package/src/bin/serve.ts +64 -0
- package/src/exports/bundler.ts +1 -0
- package/src/exports/index.ts +9 -0
- package/src/services/directoryMatch.ts +71 -0
- package/src/services/fileMatch.ts +37 -0
- package/src/services/fileMedia.ts +27 -0
- package/src/services/frameRing.ts +33 -0
- package/src/services/journalTail.ts +47 -0
- package/src/services/loginFlow.ts +144 -0
- package/src/services/registryStore.ts +83 -0
- package/src/services/retryPolicy.ts +15 -0
- package/src/services/serveOptions.ts +63 -0
- package/src/services/sessionFraming.ts +32 -0
- package/src/services/sessionPresence.ts +131 -0
- package/src/services/staticAssets.ts +37 -0
- package/src/services/webDevRoots.ts +36 -0
- package/src/services/webPluginManifest.ts +149 -0
- package/src/types/auth.ts +118 -0
- package/src/types/bridge.ts +41 -0
- package/src/types/hub.ts +290 -0
- package/src/types/media.ts +38 -0
- package/src/types/registry.ts +2 -0
- package/src/types/session.ts +36 -0
- package/src/web/app/Providers.tsx +46 -0
- package/src/web/app/protocolRuntime.ts +106 -0
- package/src/web/app/sessionRuntime.ts +189 -0
- package/src/web/app/webPlugins.generated.ts +4 -0
- package/src/web/components/MascotMark.tsx +46 -0
- package/src/web/components/PluginSurface.tsx +10 -0
- package/src/web/components/ToolRendererBoundary.tsx +32 -0
- package/src/web/features/activity/ActivityDock.tsx +125 -0
- package/src/web/features/connection/RefusedCard.tsx +63 -0
- package/src/web/features/dialogs/DialogOverlay.tsx +176 -0
- package/src/web/features/leader/CommandPalette.tsx +386 -0
- package/src/web/features/selection/SelectionBar.tsx +529 -0
- package/src/web/features/session/Composer.tsx +368 -0
- package/src/web/features/session/MentionPreviews.tsx +58 -0
- package/src/web/features/session/ThreadView.tsx +35 -0
- package/src/web/features/session/Timeline.tsx +382 -0
- package/src/web/features/session/ToolCard.tsx +79 -0
- package/src/web/features/sessions/NewSessionDialog.tsx +222 -0
- package/src/web/features/sessions/SessionRail.tsx +411 -0
- package/src/web/features/settings/AppearanceSettings.tsx +88 -0
- package/src/web/features/settings/LoginFlowDialog.tsx +221 -0
- package/src/web/features/settings/PluginSettings.tsx +92 -0
- package/src/web/features/settings/ProviderSettings.tsx +238 -0
- package/src/web/features/settings/SettingsMenu.tsx +32 -0
- package/src/web/features/status/TopBar.tsx +208 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/authApi.ts +90 -0
- package/src/web/lib/commands.ts +61 -0
- package/src/web/lib/composition.ts +225 -0
- package/src/web/lib/fileMentions.ts +32 -0
- package/src/web/lib/hubApi.ts +86 -0
- package/src/web/lib/leaderTree.ts +159 -0
- package/src/web/lib/piTransport.ts +61 -0
- package/src/web/lib/pluginRegistry.ts +488 -0
- package/src/web/lib/pluginSlotProps.ts +50 -0
- package/src/web/lib/promptFocus.ts +25 -0
- package/src/web/lib/protocolTimeline.ts +73 -0
- package/src/web/lib/sessionModel.ts +673 -0
- package/src/web/lib/sessionSummary.ts +58 -0
- package/src/web/lib/settingsSections.ts +21 -0
- package/src/web/lib/statusLine.ts +90 -0
- package/src/web/lib/threadRenderer.ts +25 -0
- package/src/web/lib/toolMessageProps.ts +21 -0
- package/src/web/lib/transport.ts +30 -0
- package/src/web/lib/wsClient.ts +73 -0
- package/src/web/main.tsx +17 -0
- package/src/web/public/apple-touch-icon.png +0 -0
- package/src/web/public/favicon-16.png +0 -0
- package/src/web/public/favicon-32.png +0 -0
- package/src/web/public/favicon.ico +0 -0
- package/src/web/public/favicon.svg +16 -0
- package/src/web/public/icon-512.png +0 -0
- package/src/web/routes/CockpitPage.tsx +98 -0
- package/src/web/routes/SettingsPage.tsx +60 -0
- package/src/web/routes/routeTree.tsx +49 -0
- package/src/web/stores/menuStore.ts +78 -0
- package/src/web/stores/paletteStore.ts +25 -0
- package/src/web/stores/sessionStore.ts +303 -0
- package/src/web/stores/sessionsStore.ts +155 -0
- package/src/web/stores/themeStore.ts +52 -0
- package/src/web/stores/threadStore.ts +68 -0
- package/src/web/stores/transientTabsStore.ts +63 -0
- package/src/web/stores/uiStore.ts +28 -0
- package/src/web/stores/useOpenTab.ts +19 -0
- package/src/web/stores/usePluginSlotProps.ts +22 -0
- package/src/web/styles/app.css +15 -0
- package/src/web/styles/webPluginSources.generated.css +1 -0
- package/src/web/vite-env.d.ts +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Store } from '@tanstack/store';
|
|
2
|
+
|
|
3
|
+
export interface PaletteState {
|
|
4
|
+
open: boolean;
|
|
5
|
+
/** The key path typed so far, mirroring Leader Space's prefix map. */
|
|
6
|
+
path: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const paletteStore = new Store<PaletteState>({ open: false, path: '' });
|
|
10
|
+
|
|
11
|
+
export function openPalette(): void {
|
|
12
|
+
paletteStore.setState(() => ({ open: true, path: '' }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function closePalette(): void {
|
|
16
|
+
paletteStore.setState(() => ({ open: false, path: '' }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function togglePalette(): void {
|
|
20
|
+
paletteStore.setState((state) => ({ open: !state.open, path: '' }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function setPalettePath(path: string): void {
|
|
24
|
+
paletteStore.setState((state) => ({ ...state, path }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { useStore } from '@tanstack/react-store';
|
|
2
|
+
import { Store } from '@tanstack/store';
|
|
3
|
+
import {
|
|
4
|
+
abortCommand,
|
|
5
|
+
dialogCancelled,
|
|
6
|
+
dialogConfirmed,
|
|
7
|
+
dialogValue,
|
|
8
|
+
followUpCommand,
|
|
9
|
+
getAvailableModelsCommand,
|
|
10
|
+
getAvailableThinkingLevelsCommand,
|
|
11
|
+
getCommandsCommand,
|
|
12
|
+
getSessionStatsCommand,
|
|
13
|
+
getStateCommand,
|
|
14
|
+
promptCommand,
|
|
15
|
+
setModelCommand,
|
|
16
|
+
setSessionNameCommand,
|
|
17
|
+
setThinkingLevelCommand,
|
|
18
|
+
steerCommand,
|
|
19
|
+
} from '../lib/commands.ts';
|
|
20
|
+
import {
|
|
21
|
+
appendQueued,
|
|
22
|
+
appendUserPrompt,
|
|
23
|
+
clearDialog,
|
|
24
|
+
initialSessionState,
|
|
25
|
+
prependHistory,
|
|
26
|
+
reduceSession,
|
|
27
|
+
type SessionState,
|
|
28
|
+
type TimelineEntry,
|
|
29
|
+
} from '../lib/sessionModel.ts';
|
|
30
|
+
import { HISTORY_REQUEST_TYPE } from '../../types/hub.ts';
|
|
31
|
+
import { sendFrame, sendHubFrame } from '../lib/transport.ts';
|
|
32
|
+
import { activeSessionId, sessionsStore } from './sessionsStore.ts';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One store per session, created on first touch.
|
|
36
|
+
*
|
|
37
|
+
* The heavyweight timeline state only exists for sessions this page has
|
|
38
|
+
* subscribed to; the rail runs on hub summaries alone.
|
|
39
|
+
*/
|
|
40
|
+
const stores = new Map<string, Store<SessionState>>();
|
|
41
|
+
|
|
42
|
+
/** Read-only stand-in while no session is focused, so hooks stay unconditional. */
|
|
43
|
+
const detachedStore = new Store<SessionState>(initialSessionState);
|
|
44
|
+
|
|
45
|
+
export function sessionStoreFor(sessionId: string | null): Store<SessionState> {
|
|
46
|
+
if (sessionId === null) return detachedStore;
|
|
47
|
+
const existing = stores.get(sessionId);
|
|
48
|
+
if (existing) return existing;
|
|
49
|
+
const created = new Store<SessionState>(initialSessionState);
|
|
50
|
+
stores.set(sessionId, created);
|
|
51
|
+
return created;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads the focused session's state; components stay on the useStore idiom. */
|
|
55
|
+
export function useActiveSession<T>(selector: (state: SessionState) => T): T {
|
|
56
|
+
const activeId = useStore(sessionsStore, (state) => state.activeId);
|
|
57
|
+
return useStore(sessionStoreFor(activeId), selector);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function applySessionFrame(sessionId: string, frame: Record<string, unknown>): void {
|
|
61
|
+
// The transcript arrives over Pi's protocol; this wire carries what the
|
|
62
|
+
// protocol has no shape for.
|
|
63
|
+
sessionStoreFor(sessionId).setState((state) => reduceSession(state, frame, { transcriptFromProtocol: true }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A thread has no protocol session, so its journal remains its authoritative transcript. */
|
|
67
|
+
export function applyThreadFrame(threadKey: string, frame: Record<string, unknown>): void {
|
|
68
|
+
sessionStoreFor(threadKey).setState((state) => reduceSession(state, frame));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One session's paging state: how far back the page has read, and whether a
|
|
73
|
+
* request is already in flight. Kept beside the timeline rather than inside it
|
|
74
|
+
* because it describes the reading, not the session.
|
|
75
|
+
*/
|
|
76
|
+
interface HistoryState {
|
|
77
|
+
cursor: string | null;
|
|
78
|
+
hasMore: boolean;
|
|
79
|
+
loading: boolean;
|
|
80
|
+
/** Pages taken so far; also what keeps prepended entry ids unique. */
|
|
81
|
+
pages: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const history = new Map<string, HistoryState>();
|
|
85
|
+
const historyStore = new Store<Record<string, HistoryState>>({});
|
|
86
|
+
const NO_HISTORY: HistoryState = { cursor: null, hasMore: true, loading: false, pages: 0 };
|
|
87
|
+
|
|
88
|
+
function historyFor(sessionId: string): HistoryState {
|
|
89
|
+
return history.get(sessionId) ?? NO_HISTORY;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setHistory(sessionId: string, next: HistoryState): void {
|
|
93
|
+
history.set(sessionId, next);
|
|
94
|
+
historyStore.setState((state) => ({ ...state, [sessionId]: next }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether more transcript exists above what this session's page holds. */
|
|
98
|
+
export function useHasOlderHistory(sessionId: string | null): boolean {
|
|
99
|
+
return useStore(historyStore, (state) => (sessionId === null ? false : (state[sessionId] ?? NO_HISTORY).hasMore));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Asks the hub for the window above what this page holds.
|
|
104
|
+
*
|
|
105
|
+
* One request at a time: a reader flicking upwards would otherwise ask for the
|
|
106
|
+
* same window repeatedly before the first answer lands, and every copy would
|
|
107
|
+
* be prepended.
|
|
108
|
+
*/
|
|
109
|
+
export function requestOlderHistory(sessionId: string | null): void {
|
|
110
|
+
if (sessionId === null) return;
|
|
111
|
+
const current = historyFor(sessionId);
|
|
112
|
+
if (current.loading || !current.hasMore) return;
|
|
113
|
+
setHistory(sessionId, { ...current, loading: true });
|
|
114
|
+
sendHubFrame({
|
|
115
|
+
type: HISTORY_REQUEST_TYPE,
|
|
116
|
+
sessionId,
|
|
117
|
+
...(current.cursor === null ? {} : { before: current.cursor }),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Folds one answered window above the timeline and records how far back it reached. */
|
|
122
|
+
export function applyHistoryPage(sessionId: string, frames: Record<string, unknown>[], next: HistoryPage): void {
|
|
123
|
+
const current = historyFor(sessionId);
|
|
124
|
+
const pages = current.pages + 1;
|
|
125
|
+
sessionStoreFor(sessionId).setState((state) => prependHistory(state, frames, pages));
|
|
126
|
+
setHistory(sessionId, { cursor: next.cursor, hasMore: next.hasMore, loading: false, pages });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface HistoryPage {
|
|
130
|
+
cursor: string | null;
|
|
131
|
+
hasMore: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The oldest entry the page holds, which is where the next window ends. */
|
|
135
|
+
export function seedHistoryCursor(sessionId: string, cursor: string | null): void {
|
|
136
|
+
setHistory(sessionId, { ...NO_HISTORY, cursor });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Keeps DoomPi-only timeline entries beside the protocol items that preceded them. */
|
|
140
|
+
function mergeProtocolEntries(previous: TimelineEntry[], protocol: TimelineEntry[]): TimelineEntry[] {
|
|
141
|
+
const protocolKinds = new Set(['user', 'assistant', 'tool']);
|
|
142
|
+
const localByAnchor = new Map<string | null, TimelineEntry[]>();
|
|
143
|
+
let anchor: string | null = null;
|
|
144
|
+
for (const entry of previous) {
|
|
145
|
+
if (protocolKinds.has(entry.kind)) {
|
|
146
|
+
anchor = entry.id;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const local = localByAnchor.get(anchor) ?? [];
|
|
150
|
+
local.push(entry);
|
|
151
|
+
localByAnchor.set(anchor, local);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const merged = [...(localByAnchor.get(null) ?? [])];
|
|
155
|
+
const used = new Set<string | null>([null]);
|
|
156
|
+
for (const entry of protocol) {
|
|
157
|
+
merged.push(entry, ...(localByAnchor.get(entry.id) ?? []));
|
|
158
|
+
used.add(entry.id);
|
|
159
|
+
}
|
|
160
|
+
for (const [entryAnchor, local] of localByAnchor) {
|
|
161
|
+
if (!used.has(entryAnchor)) merged.push(...local);
|
|
162
|
+
}
|
|
163
|
+
return merged;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Replaces protocol-owned transcript items with the authoritative snapshot.
|
|
168
|
+
*
|
|
169
|
+
* Local echoes disappear when the protocol publishes them. DoomPi-only entries,
|
|
170
|
+
* such as notices, queued messages, and run dividers, retain their place beside
|
|
171
|
+
* the protocol item that preceded them.
|
|
172
|
+
*/
|
|
173
|
+
export function applyProtocolTranscript(sessionId: string, entries: TimelineEntry[], streaming: boolean): void {
|
|
174
|
+
sessionStoreFor(sessionId).setState((state) => ({
|
|
175
|
+
...state,
|
|
176
|
+
entries: mergeProtocolEntries(state.entries, entries),
|
|
177
|
+
streaming,
|
|
178
|
+
settled: !streaming,
|
|
179
|
+
pendingUserId: null,
|
|
180
|
+
pendingUserText: '',
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function resetSessionStore(sessionId: string): void {
|
|
185
|
+
sessionStoreFor(sessionId).setState(() => initialSessionState);
|
|
186
|
+
history.delete(sessionId);
|
|
187
|
+
historyStore.setState((state) => {
|
|
188
|
+
const { [sessionId]: _dropped, ...rest } = state;
|
|
189
|
+
return rest;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function dropSessionStore(sessionId: string): void {
|
|
194
|
+
stores.delete(sessionId);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function resetSessionStores(): void {
|
|
198
|
+
stores.clear();
|
|
199
|
+
detachedStore.setState(() => initialSessionState);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Asks for the facts the cockpit shows but Pi only reports on request. */
|
|
203
|
+
export function refreshSessionFacts(sessionId: string): void {
|
|
204
|
+
sendFrame(sessionId, getStateCommand());
|
|
205
|
+
sendFrame(sessionId, getSessionStatsCommand());
|
|
206
|
+
sendFrame(sessionId, getCommandsCommand());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Asks what the model picker lists; Pi's answer depends on the current model. */
|
|
210
|
+
export function loadModelChoices(sessionId: string | null = activeSessionId()): void {
|
|
211
|
+
if (sessionId === null) return;
|
|
212
|
+
sendFrame(sessionId, getAvailableModelsCommand());
|
|
213
|
+
sendFrame(sessionId, getAvailableThinkingLevelsCommand());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Switches the model, then re-reads the facts Pi adjusts alongside it: the
|
|
218
|
+
* thinking level gets clamped to what the new model accepts, and the level
|
|
219
|
+
* list changes with it.
|
|
220
|
+
*/
|
|
221
|
+
export function selectModel(provider: string, modelId: string, sessionId: string | null = activeSessionId()): void {
|
|
222
|
+
if (sessionId === null) return;
|
|
223
|
+
sendFrame(sessionId, setModelCommand(provider, modelId));
|
|
224
|
+
sendFrame(sessionId, getStateCommand());
|
|
225
|
+
sendFrame(sessionId, getAvailableThinkingLevelsCommand());
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The set reply carries nothing back, so get_state is what updates the chip. */
|
|
229
|
+
export function selectThinkingLevel(level: string, sessionId: string | null = activeSessionId()): void {
|
|
230
|
+
if (sessionId === null) return;
|
|
231
|
+
sendFrame(sessionId, setThinkingLevelCommand(level));
|
|
232
|
+
sendFrame(sessionId, getStateCommand());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Sends a prompt, or steers the run when one is already in flight.
|
|
237
|
+
*
|
|
238
|
+
* Pi rejects a prompt while the agent is working, so the composer picks the
|
|
239
|
+
* verb from the run state rather than making the reader remember which is
|
|
240
|
+
* legal. Every action defaults to the focused session and is a no-op when
|
|
241
|
+
* nothing is focused.
|
|
242
|
+
*/
|
|
243
|
+
export function submitMessage(text: string, sessionId: string | null = activeSessionId()): void {
|
|
244
|
+
const trimmed = text.trim();
|
|
245
|
+
if (!trimmed || sessionId === null) return;
|
|
246
|
+
const store = sessionStoreFor(sessionId);
|
|
247
|
+
const streaming = store.state.streaming;
|
|
248
|
+
store.setState((state) => appendUserPrompt(state, trimmed));
|
|
249
|
+
sendFrame(sessionId, streaming ? steerCommand(trimmed) : promptCommand(trimmed));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function queueFollowUp(text: string, sessionId: string | null = activeSessionId()): void {
|
|
253
|
+
const trimmed = text.trim();
|
|
254
|
+
if (!trimmed || sessionId === null) return;
|
|
255
|
+
sessionStoreFor(sessionId).setState((state) => appendQueued(state, trimmed));
|
|
256
|
+
sendFrame(sessionId, followUpCommand(trimmed));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Renames the session where the name actually lives: in the agent. Pi keeps
|
|
261
|
+
* it in the session file and reports it back through get_state, which is
|
|
262
|
+
* what the hub folds into every page's rail card.
|
|
263
|
+
*/
|
|
264
|
+
export function renameSession(name: string, sessionId: string | null = activeSessionId()): void {
|
|
265
|
+
const trimmed = name.trim();
|
|
266
|
+
if (!trimmed || sessionId === null) return;
|
|
267
|
+
sendFrame(sessionId, setSessionNameCommand(trimmed));
|
|
268
|
+
sendFrame(sessionId, getStateCommand());
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function abortRun(sessionId: string | null = activeSessionId()): void {
|
|
272
|
+
if (sessionId === null) return;
|
|
273
|
+
sendFrame(sessionId, abortCommand());
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function runCommand(name: string, sessionId: string | null = activeSessionId()): void {
|
|
277
|
+
if (sessionId === null) return;
|
|
278
|
+
const slashed = name.startsWith('/') ? name : `/${name}`;
|
|
279
|
+
sessionStoreFor(sessionId).setState((state) => appendUserPrompt(state, slashed));
|
|
280
|
+
sendFrame(sessionId, promptCommand(slashed));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function answerDialogValue(id: string, value: string, sessionId: string | null = activeSessionId()): void {
|
|
284
|
+
if (sessionId === null) return;
|
|
285
|
+
sendFrame(sessionId, dialogValue(id, value));
|
|
286
|
+
sessionStoreFor(sessionId).setState(clearDialog);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function answerDialogConfirm(
|
|
290
|
+
id: string,
|
|
291
|
+
confirmed: boolean,
|
|
292
|
+
sessionId: string | null = activeSessionId(),
|
|
293
|
+
): void {
|
|
294
|
+
if (sessionId === null) return;
|
|
295
|
+
sendFrame(sessionId, dialogConfirmed(id, confirmed));
|
|
296
|
+
sessionStoreFor(sessionId).setState(clearDialog);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function cancelDialog(id: string, sessionId: string | null = activeSessionId()): void {
|
|
300
|
+
if (sessionId === null) return;
|
|
301
|
+
sendFrame(sessionId, dialogCancelled(id));
|
|
302
|
+
sessionStoreFor(sessionId).setState(clearDialog);
|
|
303
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useStore } from '@tanstack/react-store';
|
|
2
|
+
import { Store } from '@tanstack/store';
|
|
3
|
+
import type { SessionSummary } from '../../types/hub.ts';
|
|
4
|
+
import type { AttachPhase } from '../lib/sessionSummary.ts';
|
|
5
|
+
|
|
6
|
+
/** One rail entry: the hub's summary plus this page's view of its connection. */
|
|
7
|
+
export interface SessionMeta {
|
|
8
|
+
summary: SessionSummary;
|
|
9
|
+
/** summary.attach adjusted for sticky refusals and a lost page socket. */
|
|
10
|
+
attach: AttachPhase;
|
|
11
|
+
reason: string;
|
|
12
|
+
/** Frames replayed into this page's timeline on the last subscribe. */
|
|
13
|
+
replayed: number;
|
|
14
|
+
/** Frames the hub's bounded ring lost before this page subscribed. */
|
|
15
|
+
dropped: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SessionsState {
|
|
19
|
+
/** Session ids sorted by creation, the rail's order and the ordinal shortcuts. */
|
|
20
|
+
order: string[];
|
|
21
|
+
byId: Record<string, SessionMeta>;
|
|
22
|
+
activeId: string | null;
|
|
23
|
+
/** False until the first snapshot; routing waits for it before redirecting. */
|
|
24
|
+
hydrated: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const initialState: SessionsState = { order: [], byId: {}, activeId: null, hydrated: false };
|
|
28
|
+
|
|
29
|
+
export const sessionsStore = new Store<SessionsState>(initialState);
|
|
30
|
+
|
|
31
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function asSummary(value: unknown): SessionSummary | undefined {
|
|
36
|
+
if (!isRecord(value) || typeof value.id !== 'string' || value.id === '') return undefined;
|
|
37
|
+
return value as unknown as SessionSummary;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sortedOrder(byId: Record<string, SessionMeta>): string[] {
|
|
41
|
+
return Object.keys(byId).sort((left, right) => {
|
|
42
|
+
const a = byId[left].summary;
|
|
43
|
+
const b = byId[right].summary;
|
|
44
|
+
return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mergeSummary(previous: SessionMeta | undefined, summary: SessionSummary): SessionMeta {
|
|
49
|
+
// A refusal is sticky: the hub keeps retrying underneath, and reporting each
|
|
50
|
+
// attempt would flicker the card between "refused" and "connecting" while
|
|
51
|
+
// nothing has actually changed. Only a successful attach clears it.
|
|
52
|
+
const sticky = previous?.attach === 'refused' && summary.attach === 'connecting';
|
|
53
|
+
return {
|
|
54
|
+
summary,
|
|
55
|
+
attach: sticky ? 'refused' : summary.attach,
|
|
56
|
+
reason: sticky ? previous.reason : (summary.attachReason ?? ''),
|
|
57
|
+
replayed: previous?.replayed ?? 0,
|
|
58
|
+
dropped: previous?.dropped ?? 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function applySessionsSnapshot(frame: Record<string, unknown>): void {
|
|
63
|
+
if (!Array.isArray(frame.sessions)) return;
|
|
64
|
+
const summaries = frame.sessions.map(asSummary).filter((summary): summary is SessionSummary => Boolean(summary));
|
|
65
|
+
sessionsStore.setState((state) => {
|
|
66
|
+
const byId: Record<string, SessionMeta> = {};
|
|
67
|
+
for (const summary of summaries) byId[summary.id] = mergeSummary(state.byId[summary.id], summary);
|
|
68
|
+
return { ...state, byId, order: sortedOrder(byId), hydrated: true };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function applySessionUpsert(frame: Record<string, unknown>): void {
|
|
73
|
+
const summary = asSummary(frame.session);
|
|
74
|
+
if (!summary) return;
|
|
75
|
+
sessionsStore.setState((state) => {
|
|
76
|
+
const byId = { ...state.byId, [summary.id]: mergeSummary(state.byId[summary.id], summary) };
|
|
77
|
+
return { ...state, byId, order: sortedOrder(byId) };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function applySessionRemoved(frame: Record<string, unknown>): void {
|
|
82
|
+
const sessionId = frame.sessionId;
|
|
83
|
+
if (typeof sessionId !== 'string') return;
|
|
84
|
+
sessionsStore.setState((state) => {
|
|
85
|
+
if (!(sessionId in state.byId)) return state;
|
|
86
|
+
const byId = { ...state.byId };
|
|
87
|
+
delete byId[sessionId];
|
|
88
|
+
return { ...state, byId, order: sortedOrder(byId) };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Records what the hub replayed into this page when a subscription started. */
|
|
93
|
+
export function applySessionBacklog(sessionId: string, replayed: number, dropped: number): void {
|
|
94
|
+
sessionsStore.setState((state) => {
|
|
95
|
+
const meta = state.byId[sessionId];
|
|
96
|
+
if (!meta) return state;
|
|
97
|
+
return { ...state, byId: { ...state.byId, [sessionId]: { ...meta, replayed, dropped } } };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The page socket died; every session is unreachable until it comes back.
|
|
103
|
+
* Self-healing: the snapshot on reconnect restores the real states.
|
|
104
|
+
*/
|
|
105
|
+
export function markSocketClosed(): void {
|
|
106
|
+
sessionsStore.setState((state) => {
|
|
107
|
+
const byId: Record<string, SessionMeta> = {};
|
|
108
|
+
for (const [id, meta] of Object.entries(state.byId)) {
|
|
109
|
+
byId[id] = { ...meta, attach: 'offline', reason: 'The cockpit lost its bridge.' };
|
|
110
|
+
}
|
|
111
|
+
return { ...state, byId };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const APPEAR_TIMEOUT_MS = 15_000;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolves once the session shows up in the rail, or false on timeout.
|
|
119
|
+
*
|
|
120
|
+
* A freshly created session exists on disk before its upsert reaches this
|
|
121
|
+
* page; navigating to it too early trips the unknown-session fallback and
|
|
122
|
+
* bounces the user back. Waiting here is what makes "create, then land on it"
|
|
123
|
+
* one motion.
|
|
124
|
+
*/
|
|
125
|
+
export function waitForSession(sessionId: string, timeoutMs = APPEAR_TIMEOUT_MS): Promise<boolean> {
|
|
126
|
+
if (sessionId in sessionsStore.state.byId) return Promise.resolve(true);
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
const finish = (found: boolean): void => {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
subscription.unsubscribe();
|
|
131
|
+
resolve(found);
|
|
132
|
+
};
|
|
133
|
+
const subscription = sessionsStore.subscribe(() => {
|
|
134
|
+
if (sessionId in sessionsStore.state.byId) finish(true);
|
|
135
|
+
});
|
|
136
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Reads the focused session's rail entry, or null while nothing is focused. */
|
|
141
|
+
export function useActiveSessionMeta(): SessionMeta | null {
|
|
142
|
+
return useStore(sessionsStore, (state) => (state.activeId !== null ? (state.byId[state.activeId] ?? null) : null));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function setActiveSession(sessionId: string | null): void {
|
|
146
|
+
sessionsStore.setState((state) => (state.activeId === sessionId ? state : { ...state, activeId: sessionId }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function activeSessionId(): string | null {
|
|
150
|
+
return sessionsStore.state.activeId;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function resetSessions(): void {
|
|
154
|
+
sessionsStore.setState(() => initialState);
|
|
155
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyTheme,
|
|
3
|
+
BUILTIN_THEMES,
|
|
4
|
+
builtinTheme,
|
|
5
|
+
DEFAULT_THEME,
|
|
6
|
+
readThemePreference,
|
|
7
|
+
type ThemeConfig,
|
|
8
|
+
writeThemePreference,
|
|
9
|
+
} from '@agimon-ai/doompi-web-components/theme';
|
|
10
|
+
import { Store } from '@tanstack/store';
|
|
11
|
+
|
|
12
|
+
export interface ThemeState {
|
|
13
|
+
/** The name of the theme on the page. */
|
|
14
|
+
name: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const themeStore = new Store<ThemeState>({ name: DEFAULT_THEME.name });
|
|
18
|
+
|
|
19
|
+
/** The themes the appearance page offers. */
|
|
20
|
+
export function availableThemes(): readonly ThemeConfig[] {
|
|
21
|
+
return BUILTIN_THEMES;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function storage(): Storage | undefined {
|
|
25
|
+
try {
|
|
26
|
+
return window.localStorage;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Puts the remembered theme on the page. Called before the first render so
|
|
34
|
+
* the cockpit never flashes the default; a stored name nothing ships under
|
|
35
|
+
* falls back to the default and is forgotten.
|
|
36
|
+
*/
|
|
37
|
+
export function applyStoredTheme(): void {
|
|
38
|
+
const stored = readThemePreference(storage());
|
|
39
|
+
const theme = (stored === null ? undefined : builtinTheme(stored)) ?? DEFAULT_THEME;
|
|
40
|
+
if (stored !== null && theme.name !== stored) writeThemePreference(storage(), null);
|
|
41
|
+
applyTheme(theme, document.documentElement);
|
|
42
|
+
themeStore.setState(() => ({ name: theme.name }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Switches the page to a shipped theme and remembers it. */
|
|
46
|
+
export function selectTheme(name: string): void {
|
|
47
|
+
const theme = builtinTheme(name);
|
|
48
|
+
if (theme === undefined) return;
|
|
49
|
+
applyTheme(theme, document.documentElement);
|
|
50
|
+
writeThemePreference(storage(), name);
|
|
51
|
+
themeStore.setState(() => ({ name }));
|
|
52
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { subscribeThreadFrame, unsubscribeThreadFrame } from '../../types/hub.ts';
|
|
2
|
+
import { sendHubFrame } from '../lib/transport.ts';
|
|
3
|
+
import { dropSessionStore } from './sessionStore.ts';
|
|
4
|
+
|
|
5
|
+
/** A session id is a registry id and never starts with this, so a thread's fold cannot shadow a session's. */
|
|
6
|
+
const THREAD_KEY_PREFIX = 'thread:';
|
|
7
|
+
|
|
8
|
+
interface ThreadHold {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
threadId: string;
|
|
11
|
+
refs: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The page's holds on threads, counted per thread so two views of one thread
|
|
16
|
+
* share a subscription. The hub-side subscription dies with the socket, so
|
|
17
|
+
* this is also what a reconnect replays.
|
|
18
|
+
*/
|
|
19
|
+
const holds = new Map<string, ThreadHold>();
|
|
20
|
+
|
|
21
|
+
/** Where a thread's fold lives in the session store map. */
|
|
22
|
+
export function threadStoreKey(sessionId: string, threadId: string): string {
|
|
23
|
+
return `${THREAD_KEY_PREFIX}${sessionId}:${threadId}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function subscribeThread(sessionId: string, threadId: string): void {
|
|
27
|
+
const key = threadStoreKey(sessionId, threadId);
|
|
28
|
+
const held = holds.get(key);
|
|
29
|
+
if (held !== undefined) {
|
|
30
|
+
held.refs += 1;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
holds.set(key, { sessionId, threadId, refs: 1 });
|
|
34
|
+
sendHubFrame(subscribeThreadFrame(sessionId, threadId));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function unsubscribeThread(sessionId: string, threadId: string): void {
|
|
38
|
+
const key = threadStoreKey(sessionId, threadId);
|
|
39
|
+
const held = holds.get(key);
|
|
40
|
+
if (held === undefined) return;
|
|
41
|
+
held.refs -= 1;
|
|
42
|
+
if (held.refs > 0) return;
|
|
43
|
+
holds.delete(key);
|
|
44
|
+
sendHubFrame(unsubscribeThreadFrame(sessionId, threadId));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A fresh socket knows nothing; every held thread is asked for again. */
|
|
48
|
+
export function resubscribeThreads(): void {
|
|
49
|
+
for (const held of holds.values()) sendHubFrame(subscribeThreadFrame(held.sessionId, held.threadId));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A session that left takes its threads' folds and holds with it. */
|
|
53
|
+
export function dropThreads(sessionId: string): void {
|
|
54
|
+
for (const [key, held] of holds) {
|
|
55
|
+
if (held.sessionId !== sessionId) continue;
|
|
56
|
+
holds.delete(key);
|
|
57
|
+
dropSessionStore(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The threads currently held, for tests and diagnostics. */
|
|
62
|
+
export function heldThreads(): Array<{ sessionId: string; threadId: string; refs: number }> {
|
|
63
|
+
return [...holds.values()].map((held) => ({ ...held }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resetThreads(): void {
|
|
67
|
+
holds.clear();
|
|
68
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { TransientTab } from '@agimon-ai/doompi-web-contracts';
|
|
2
|
+
import { useStore } from '@tanstack/react-store';
|
|
3
|
+
import { Store } from '@tanstack/store';
|
|
4
|
+
|
|
5
|
+
type TransientTabsState = Partial<Record<string, TransientTab[]>>;
|
|
6
|
+
|
|
7
|
+
/** One stable empty list, so a selector for a session with no tabs never re-renders its reader. */
|
|
8
|
+
const NO_TABS: readonly TransientTab[] = [];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The tabs plugins opened at runtime, per session. A tab keeps the panel it
|
|
12
|
+
* was opened with: opening the same id again focuses it rather than swapping
|
|
13
|
+
* the component out from under React. Nothing here survives a reload, which
|
|
14
|
+
* is what makes the tabs temporary.
|
|
15
|
+
*/
|
|
16
|
+
export const transientTabsStore = new Store<TransientTabsState>({});
|
|
17
|
+
|
|
18
|
+
export function transientTabsOf(state: TransientTabsState, sessionId: string | null): readonly TransientTab[] {
|
|
19
|
+
return (sessionId === null ? undefined : state[sessionId]) ?? NO_TABS;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function findTransientTab(
|
|
23
|
+
state: TransientTabsState,
|
|
24
|
+
sessionId: string | undefined,
|
|
25
|
+
tabId: string | undefined,
|
|
26
|
+
): TransientTab | undefined {
|
|
27
|
+
if (sessionId === undefined || tabId === undefined) return undefined;
|
|
28
|
+
return transientTabsOf(state, sessionId).find((tab) => tab.id === tabId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function openTransientTab(sessionId: string, tab: TransientTab): void {
|
|
32
|
+
transientTabsStore.setState((state) => {
|
|
33
|
+
const current = state[sessionId] ?? [];
|
|
34
|
+
if (current.some((existing) => existing.id === tab.id)) return state;
|
|
35
|
+
return { ...state, [sessionId]: [...current, tab] };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function closeTransientTab(sessionId: string, tabId: string): void {
|
|
40
|
+
transientTabsStore.setState((state) => {
|
|
41
|
+
const current = state[sessionId];
|
|
42
|
+
if (current === undefined || !current.some((tab) => tab.id === tabId)) return state;
|
|
43
|
+
return { ...state, [sessionId]: current.filter((tab) => tab.id !== tabId) };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A session that left takes its tabs with it. */
|
|
48
|
+
export function dropTransientTabs(sessionId: string): void {
|
|
49
|
+
transientTabsStore.setState((state) => {
|
|
50
|
+
if (!(sessionId in state)) return state;
|
|
51
|
+
const next = { ...state };
|
|
52
|
+
delete next[sessionId];
|
|
53
|
+
return next;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resetTransientTabs(): void {
|
|
58
|
+
transientTabsStore.setState(() => ({}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useTransientTabs(sessionId: string | null): readonly TransientTab[] {
|
|
62
|
+
return useStore(transientTabsStore, (state) => transientTabsOf(state, sessionId));
|
|
63
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Store } from '@tanstack/store';
|
|
2
|
+
|
|
3
|
+
/** Where the page remembers its chrome layout; one key per machine. */
|
|
4
|
+
const DOCK_STORAGE_KEY = 'doompi.web.dock';
|
|
5
|
+
|
|
6
|
+
export interface UiState {
|
|
7
|
+
/** Whether the activity dock is shown; survives route changes and reloads. */
|
|
8
|
+
dockOpen: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readDock(): boolean {
|
|
12
|
+
try {
|
|
13
|
+
return window.localStorage.getItem(DOCK_STORAGE_KEY) !== 'closed';
|
|
14
|
+
} catch {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const uiStore = new Store<UiState>({ dockOpen: readDock() });
|
|
20
|
+
|
|
21
|
+
export function setDockOpen(open: boolean): void {
|
|
22
|
+
uiStore.setState((state) => (state.dockOpen === open ? state : { ...state, dockOpen: open }));
|
|
23
|
+
try {
|
|
24
|
+
window.localStorage.setItem(DOCK_STORAGE_KEY, open ? 'open' : 'closed');
|
|
25
|
+
} catch {
|
|
26
|
+
// A layout preference is a convenience; losing it costs one click.
|
|
27
|
+
}
|
|
28
|
+
}
|