@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,58 @@
|
|
|
1
|
+
import type { SessionPhase } from '../../types/hub.ts';
|
|
2
|
+
|
|
3
|
+
/** The rail's view of one session's attach state; offline means the page lost the hub. */
|
|
4
|
+
export type AttachPhase = 'offline' | 'connecting' | 'attached' | 'refused' | 'detached' | 'closed';
|
|
5
|
+
|
|
6
|
+
export interface StatusLineInput {
|
|
7
|
+
attach: AttachPhase;
|
|
8
|
+
phase: SessionPhase;
|
|
9
|
+
/** ISO 8601 timestamp of the last phase change. */
|
|
10
|
+
phaseSince: string;
|
|
11
|
+
awaitingInput: boolean;
|
|
12
|
+
everPrompted: boolean;
|
|
13
|
+
/** Set once any run finished; a settled session is not "fresh" even unprompted. */
|
|
14
|
+
lastSettledAt?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MINUTE_MS = 60_000;
|
|
18
|
+
const HOUR_MS = 3_600_000;
|
|
19
|
+
|
|
20
|
+
export function formatRunDuration(elapsedMs: number): string {
|
|
21
|
+
if (elapsedMs < MINUTE_MS) return '<1m';
|
|
22
|
+
if (elapsedMs < HOUR_MS) return `${Math.floor(elapsedMs / MINUTE_MS)}m`;
|
|
23
|
+
const hours = Math.floor(elapsedMs / HOUR_MS);
|
|
24
|
+
const minutes = Math.floor((elapsedMs % HOUR_MS) / MINUTE_MS);
|
|
25
|
+
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The one mapping from session facts to the rail's status copy.
|
|
30
|
+
*
|
|
31
|
+
* Priority order: a refusal outranks everything because nothing else the card
|
|
32
|
+
* says can be trusted while another client holds the session; a question to
|
|
33
|
+
* the user outranks "running" because the run is blocked on them.
|
|
34
|
+
*/
|
|
35
|
+
export function sessionStatusLine(input: StatusLineInput, now: number): string {
|
|
36
|
+
if (input.attach === 'refused') return 'another cockpit holds this session';
|
|
37
|
+
if (input.awaitingInput) return 'waiting for your input';
|
|
38
|
+
if (input.phase !== 'idle') {
|
|
39
|
+
const since = Date.parse(input.phaseSince);
|
|
40
|
+
return `running · ${formatRunDuration(Number.isFinite(since) ? Math.max(0, now - since) : 0)}`;
|
|
41
|
+
}
|
|
42
|
+
if (!input.everPrompted && input.lastSettledAt === undefined) return 'fresh session · nothing sent yet';
|
|
43
|
+
return 'done · waiting for you';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Sessions counted as running by the rail header and the top bar chip. */
|
|
47
|
+
export function runningCount(phases: Iterable<SessionPhase>): number {
|
|
48
|
+
let count = 0;
|
|
49
|
+
for (const phase of phases) if (phase !== 'idle') count += 1;
|
|
50
|
+
return count;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Shortens a home-rooted cwd the way a shell prompt would. */
|
|
54
|
+
export function abbreviateCwd(cwd: string): string {
|
|
55
|
+
const match = /^\/(?:Users|home)\/[^/]+/u.exec(cwd);
|
|
56
|
+
if (!match) return cwd;
|
|
57
|
+
return `~${cwd.slice(match[0].length)}`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings menu, as data: the rail's gear and the settings page both
|
|
3
|
+
* need to name a section, and neither feature may import the other.
|
|
4
|
+
*/
|
|
5
|
+
export interface SettingsSection {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
detail: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
|
12
|
+
{ id: 'providers', label: 'providers', detail: 'sign in to the model providers Pi can use' },
|
|
13
|
+
{ id: 'appearance', label: 'appearance', detail: 'pick the theme the cockpit renders with' },
|
|
14
|
+
{ id: 'plugins', label: 'plugins', detail: 'the web plugins this bundle carries and what their install resolved' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_SETTINGS_SECTION = SETTINGS_SECTIONS[0].id;
|
|
18
|
+
|
|
19
|
+
export function settingsSection(id: string | undefined): SettingsSection | undefined {
|
|
20
|
+
return SETTINGS_SECTIONS.find((section) => section.id === id);
|
|
21
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export interface Selection {
|
|
2
|
+
/** Empty when the session runs without a profile, which is a valid choice. */
|
|
3
|
+
profile: string;
|
|
4
|
+
majorMode: string;
|
|
5
|
+
domains: string[];
|
|
6
|
+
/** True while a mode switch is journaled but not yet applied. */
|
|
7
|
+
pending: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const emptySelection: Selection = { profile: '', majorMode: '', domains: [], pending: false };
|
|
11
|
+
|
|
12
|
+
interface Segment {
|
|
13
|
+
text: string;
|
|
14
|
+
rgb: [number, number, number] | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The escape is the point of this parser, so the control character is
|
|
18
|
+
// deliberate; spelled as a Unicode escape to keep it readable.
|
|
19
|
+
// oxlint-disable-next-line no-control-regex
|
|
20
|
+
const SGR = /\u001B\[([0-9;]*)m/gu;
|
|
21
|
+
|
|
22
|
+
/** Splits an ANSI string into runs of text carrying the truecolor in force. */
|
|
23
|
+
export function ansiSegments(input: string): Segment[] {
|
|
24
|
+
const segments: Segment[] = [];
|
|
25
|
+
let current: [number, number, number] | null = null;
|
|
26
|
+
let index = 0;
|
|
27
|
+
|
|
28
|
+
SGR.lastIndex = 0;
|
|
29
|
+
for (let match = SGR.exec(input); match !== null; match = SGR.exec(input)) {
|
|
30
|
+
const text = input.slice(index, match.index);
|
|
31
|
+
if (text) segments.push({ text, rgb: current });
|
|
32
|
+
const codes = match[1].split(';').map((code) => Number.parseInt(code, 10));
|
|
33
|
+
if (codes[0] === 38 && codes[1] === 2) current = [codes[2], codes[3], codes[4]];
|
|
34
|
+
else if (codes[0] === 39 || codes[0] === 0 || Number.isNaN(codes[0])) current = null;
|
|
35
|
+
index = match.index + match[0].length;
|
|
36
|
+
}
|
|
37
|
+
const tail = input.slice(index);
|
|
38
|
+
if (tail) segments.push({ text: tail, rgb: current });
|
|
39
|
+
return segments;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function stripAnsi(input: string): string {
|
|
43
|
+
return input.replace(SGR, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The values doom-pi-dark.json binds to `warning` and `accent`. DoomPi ships one
|
|
47
|
+
// theme, so comparing against both and taking the nearer one reports pending
|
|
48
|
+
// accurately, and falls back to "not pending" when it cannot tell.
|
|
49
|
+
const WARNING: [number, number, number] = [236, 190, 123];
|
|
50
|
+
const ACCENT: [number, number, number] = [81, 175, 239];
|
|
51
|
+
|
|
52
|
+
function distance(a: [number, number, number], b: [number, number, number]): number {
|
|
53
|
+
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reads DoomPi's footer status line.
|
|
58
|
+
*
|
|
59
|
+
* The line is the only place the session publishes its selection, so this
|
|
60
|
+
* parses the shape `*profile*:[major-mode]:domains` structurally and uses the
|
|
61
|
+
* colour of the mode segment solely to tell a pending switch from a settled one.
|
|
62
|
+
*/
|
|
63
|
+
export function parseSelection(statusText: string): Selection {
|
|
64
|
+
const segments = ansiSegments(statusText);
|
|
65
|
+
const plain = stripAnsi(statusText).trim();
|
|
66
|
+
if (!plain) return emptySelection;
|
|
67
|
+
|
|
68
|
+
const profileMatch = /\*([^*]+)\*/u.exec(plain);
|
|
69
|
+
const modeMatch = /\[([^\]]+)\]/u.exec(plain);
|
|
70
|
+
|
|
71
|
+
const afterMode = modeMatch ? plain.slice(plain.indexOf(modeMatch[0]) + modeMatch[0].length) : '';
|
|
72
|
+
const domains = afterMode
|
|
73
|
+
.replace(/^:/u, '')
|
|
74
|
+
.split(',')
|
|
75
|
+
.map((entry) => entry.trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
|
|
78
|
+
const modeSegment = segments.find((segment) => segment.text.includes('['));
|
|
79
|
+
const pending =
|
|
80
|
+
modeSegment?.rgb !== undefined && modeSegment?.rgb !== null
|
|
81
|
+
? distance(modeSegment.rgb, WARNING) < distance(modeSegment.rgb, ACCENT)
|
|
82
|
+
: false;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
profile: profileMatch ? profileMatch[1] : '',
|
|
86
|
+
majorMode: modeMatch ? modeMatch[1] : '',
|
|
87
|
+
domains,
|
|
88
|
+
pending,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
type ThreadRenderer = (sessionId: string, threadId: string) => ReactNode;
|
|
4
|
+
|
|
5
|
+
let render: ThreadRenderer | undefined;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Holds the host's thread view for the plugin props to render.
|
|
9
|
+
*
|
|
10
|
+
* The view is a feature built on the timeline, while the props are assembled
|
|
11
|
+
* below it; the composition root binds the one to the other, the same
|
|
12
|
+
* indirection the transport uses for the socket.
|
|
13
|
+
*/
|
|
14
|
+
export function bindThreadRenderer(renderer: ThreadRenderer): void {
|
|
15
|
+
render = renderer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function releaseThreadRenderer(): void {
|
|
19
|
+
render = undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The bound view, or nothing while none is bound (before the app mounts, and in unit tests). */
|
|
23
|
+
export function renderThread(sessionId: string, threadId: string): ReactNode {
|
|
24
|
+
return render?.(sessionId, threadId) ?? null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ToolMessageRenderProps, WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
|
|
2
|
+
import type { ToolEntry } from './sessionModel.ts';
|
|
3
|
+
|
|
4
|
+
/** The props a tool's message renderer receives: the slot actions plus the call and its newest result. */
|
|
5
|
+
export function toolMessageProps(
|
|
6
|
+
slotProps: WebPluginSlotProps,
|
|
7
|
+
entry: ToolEntry,
|
|
8
|
+
statuses: Readonly<Record<string, string>>,
|
|
9
|
+
): ToolMessageRenderProps {
|
|
10
|
+
return {
|
|
11
|
+
...slotProps,
|
|
12
|
+
toolCallId: entry.toolCallId,
|
|
13
|
+
toolName: entry.name,
|
|
14
|
+
args: entry.args,
|
|
15
|
+
statuses,
|
|
16
|
+
result: entry.result,
|
|
17
|
+
output: entry.output,
|
|
18
|
+
running: entry.running,
|
|
19
|
+
isError: entry.isError,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sessionCommand } from '../../types/hub.ts';
|
|
2
|
+
|
|
3
|
+
type Frame = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
let send: ((frame: object) => void) | undefined;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Holds the live socket sender.
|
|
9
|
+
*
|
|
10
|
+
* The socket is owned by the composition root but commands are issued from
|
|
11
|
+
* feature code, and this keeps that one indirection explicit instead of
|
|
12
|
+
* threading a sender through every component.
|
|
13
|
+
*/
|
|
14
|
+
export function bindTransport(sender: (frame: object) => void): void {
|
|
15
|
+
send = sender;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function releaseTransport(): void {
|
|
19
|
+
send = undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Sends one command frame to a session's agent, enveloped for the hub. */
|
|
23
|
+
export function sendFrame(sessionId: string, frame: Frame): void {
|
|
24
|
+
send?.(sessionCommand(sessionId, frame));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Sends one hub-level frame (subscribe, unsubscribe) as-is. */
|
|
28
|
+
export function sendHubFrame(frame: object): void {
|
|
29
|
+
send?.(frame);
|
|
30
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
type Frame = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export interface SessionSocket {
|
|
4
|
+
send(frame: object): void;
|
|
5
|
+
close(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SessionSocketHandlers {
|
|
9
|
+
onFrame(frame: Frame): void;
|
|
10
|
+
onOpen(): void;
|
|
11
|
+
onClose(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RECONNECT_MS = 700;
|
|
15
|
+
|
|
16
|
+
export function sessionSocketUrl(location: { protocol: string; host: string }): string {
|
|
17
|
+
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
18
|
+
return `${scheme}//${location.host}/api/session`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Keeps a WebSocket to the bridge open for the life of the page.
|
|
23
|
+
*
|
|
24
|
+
* Reconnecting is unconditional because the bridge, not the browser, decides
|
|
25
|
+
* whether the session is still there; the page's job is to keep offering.
|
|
26
|
+
*/
|
|
27
|
+
export function createSessionSocket(url: string, handlers: SessionSocketHandlers): SessionSocket {
|
|
28
|
+
let socket: WebSocket | undefined;
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
30
|
+
let stopped = false;
|
|
31
|
+
|
|
32
|
+
const open = (): void => {
|
|
33
|
+
if (stopped) return;
|
|
34
|
+
const next = new WebSocket(url);
|
|
35
|
+
socket = next;
|
|
36
|
+
|
|
37
|
+
next.addEventListener('open', () => handlers.onOpen());
|
|
38
|
+
|
|
39
|
+
next.addEventListener('message', (event: MessageEvent<string>) => {
|
|
40
|
+
if (typeof event.data !== 'string') return;
|
|
41
|
+
let parsed: unknown;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(event.data);
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
48
|
+
handlers.onFrame(parsed as Frame);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
next.addEventListener('close', () => {
|
|
53
|
+
handlers.onClose();
|
|
54
|
+
if (stopped) return;
|
|
55
|
+
timer = setTimeout(open, RECONNECT_MS);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
next.addEventListener('error', () => next.close());
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
open();
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
send(frame) {
|
|
65
|
+
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame));
|
|
66
|
+
},
|
|
67
|
+
close() {
|
|
68
|
+
stopped = true;
|
|
69
|
+
if (timer) clearTimeout(timer);
|
|
70
|
+
socket?.close();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/web/main.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { Providers } from './app/Providers.tsx';
|
|
4
|
+
import { applyStoredTheme } from './stores/themeStore.ts';
|
|
5
|
+
import './styles/app.css';
|
|
6
|
+
|
|
7
|
+
const container = document.getElementById('root');
|
|
8
|
+
if (!container) throw new Error('The cockpit needs a #root element.');
|
|
9
|
+
|
|
10
|
+
// Before the first paint, so the remembered theme never flashes the default.
|
|
11
|
+
applyStoredTheme();
|
|
12
|
+
|
|
13
|
+
createRoot(container).render(
|
|
14
|
+
<StrictMode>
|
|
15
|
+
<Providers />
|
|
16
|
+
</StrictMode>,
|
|
17
|
+
);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="DoomPi">
|
|
2
|
+
<rect width="32" height="32" rx="7" fill="#1c1f24"/>
|
|
3
|
+
<g fill="#ecbe7b">
|
|
4
|
+
<path d="M7.0 10.0 Q6.2 4.2 9.3 3.0 Q10.4 6.3 12.1 9.6 Z"/>
|
|
5
|
+
<path d="M25.0 10.0 Q25.8 4.2 22.7 3.0 Q21.6 6.3 19.9 9.6 Z"/>
|
|
6
|
+
</g>
|
|
7
|
+
<g fill="#c678dd">
|
|
8
|
+
<rect x="3.2" y="8.6" width="25.6" height="7.6" rx="3.4"/>
|
|
9
|
+
<rect x="6.9" y="14.0" width="5.5" height="13.0" rx="2.5"/>
|
|
10
|
+
<rect x="19.6" y="14.0" width="5.5" height="13.0" rx="2.5"/>
|
|
11
|
+
</g>
|
|
12
|
+
<g fill="#1c1f24">
|
|
13
|
+
<circle cx="10.9" cy="12.3" r="1.9"/>
|
|
14
|
+
<circle cx="21.1" cy="12.3" r="1.9"/>
|
|
15
|
+
</g>
|
|
16
|
+
</svg>
|
|
Binary file
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Button } from '@agimon-ai/doompi-web-components';
|
|
2
|
+
import { useStore } from '@tanstack/react-store';
|
|
3
|
+
import { useNavigate, useParams } from '@tanstack/react-router';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
import { PluginSurface } from '../components/PluginSurface.tsx';
|
|
6
|
+
import { usePluginSlotProps } from '../stores/usePluginSlotProps.ts';
|
|
7
|
+
import { ActivityDock } from '../features/activity/ActivityDock.tsx';
|
|
8
|
+
import { RefusedCard } from '../features/connection/RefusedCard.tsx';
|
|
9
|
+
import { DialogOverlay } from '../features/dialogs/DialogOverlay.tsx';
|
|
10
|
+
import { CommandPalette } from '../features/leader/CommandPalette.tsx';
|
|
11
|
+
import { SelectionBar } from '../features/selection/SelectionBar.tsx';
|
|
12
|
+
import { Composer } from '../features/session/Composer.tsx';
|
|
13
|
+
import { SessionRail } from '../features/sessions/SessionRail.tsx';
|
|
14
|
+
import { Timeline } from '../features/session/Timeline.tsx';
|
|
15
|
+
import { TopBar } from '../features/status/TopBar.tsx';
|
|
16
|
+
import { HOST_SLOTS, webTabs } from '../lib/pluginRegistry.ts';
|
|
17
|
+
import { sessionsStore, setActiveSession } from '../stores/sessionsStore.ts';
|
|
18
|
+
import { findTransientTab, transientTabsStore } from '../stores/transientTabsStore.ts';
|
|
19
|
+
import { setDockOpen, uiStore } from '../stores/uiStore.ts';
|
|
20
|
+
|
|
21
|
+
export function CockpitPage() {
|
|
22
|
+
const dockOpen = useStore(uiStore, (state) => state.dockOpen);
|
|
23
|
+
const { sessionId, tabId } = useParams({ strict: false });
|
|
24
|
+
const navigate = useNavigate();
|
|
25
|
+
const slotProps = usePluginSlotProps(sessionId ?? null);
|
|
26
|
+
const order = useStore(sessionsStore, (state) => state.order);
|
|
27
|
+
const hydrated = useStore(sessionsStore, (state) => state.hydrated);
|
|
28
|
+
// A declared tab first, then one a plugin opened at runtime for this session.
|
|
29
|
+
const transientTab = useStore(transientTabsStore, (state) => findTransientTab(state, sessionId, tabId));
|
|
30
|
+
const tab = (tabId === undefined ? undefined : webTabs().find((entry) => entry.id === tabId)) ?? transientTab;
|
|
31
|
+
|
|
32
|
+
// The route is the source of focus; the store follows it.
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setActiveSession(sessionId ?? null);
|
|
35
|
+
}, [sessionId]);
|
|
36
|
+
|
|
37
|
+
// Landing on / focuses the first session; a focused session that
|
|
38
|
+
// disappeared falls back the same way, and an unknown tab id falls back to
|
|
39
|
+
// the conversation. Before hydration the URL is left alone so a deep link
|
|
40
|
+
// survives the socket connecting.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!hydrated) return;
|
|
43
|
+
if (sessionId !== undefined && order.includes(sessionId)) {
|
|
44
|
+
if (tabId !== undefined && tab === undefined) {
|
|
45
|
+
void navigate({ to: '/session/$sessionId', params: { sessionId }, replace: true });
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const first = order[0];
|
|
50
|
+
if (first !== undefined) {
|
|
51
|
+
void navigate({ to: '/session/$sessionId', params: { sessionId: first }, replace: true });
|
|
52
|
+
} else if (sessionId !== undefined) {
|
|
53
|
+
void navigate({ to: '/', replace: true });
|
|
54
|
+
}
|
|
55
|
+
}, [hydrated, sessionId, tabId, tab, order, navigate]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div data-testid="cockpit" className="relative flex h-full overflow-hidden">
|
|
59
|
+
<aside className="flex w-[300px] shrink-0 flex-col overflow-y-auto border-r border-doom-border bg-doom-rail">
|
|
60
|
+
<SessionRail />
|
|
61
|
+
</aside>
|
|
62
|
+
<main className="flex min-w-0 flex-1 flex-col">
|
|
63
|
+
<TopBar view={tab?.id ?? 'conversation'} />
|
|
64
|
+
{/* The composer and the selection bar address the session's agent, so
|
|
65
|
+
they belong to the conversation alone. A plugin panel is a view of
|
|
66
|
+
something else, and a prompt box under it would send somewhere the
|
|
67
|
+
reader is not looking. */}
|
|
68
|
+
{tab ? (
|
|
69
|
+
<tab.panel {...slotProps} />
|
|
70
|
+
) : (
|
|
71
|
+
<>
|
|
72
|
+
<Timeline />
|
|
73
|
+
<Composer />
|
|
74
|
+
<SelectionBar />
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
</main>
|
|
78
|
+
{dockOpen ? (
|
|
79
|
+
<ActivityDock onClose={() => setDockOpen(false)} />
|
|
80
|
+
) : (
|
|
81
|
+
<Button
|
|
82
|
+
variant="ghost"
|
|
83
|
+
data-testid="activity-show"
|
|
84
|
+
title="show the activity dock"
|
|
85
|
+
onClick={() => setDockOpen(true)}
|
|
86
|
+
className="h-auto shrink-0 rounded-none border-l border-doom-border bg-doom-rail px-2 py-3 text-[9px] tracking-widest text-doom-dim hover:bg-doom-rail"
|
|
87
|
+
style={{ writingMode: 'vertical-rl' }}
|
|
88
|
+
>
|
|
89
|
+
ACTIVITY
|
|
90
|
+
</Button>
|
|
91
|
+
)}
|
|
92
|
+
<DialogOverlay />
|
|
93
|
+
<RefusedCard />
|
|
94
|
+
<CommandPalette />
|
|
95
|
+
<PluginSurface slot={HOST_SLOTS.overlay} sessionId={sessionId ?? null} />
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Button } from '@agimon-ai/doompi-web-components';
|
|
2
|
+
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
|
3
|
+
import { useStore } from '@tanstack/react-store';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
import { SessionRail } from '../features/sessions/SessionRail.tsx';
|
|
6
|
+
import { AppearanceSettings } from '../features/settings/AppearanceSettings.tsx';
|
|
7
|
+
import { PluginSettings } from '../features/settings/PluginSettings.tsx';
|
|
8
|
+
import { ProviderSettings } from '../features/settings/ProviderSettings.tsx';
|
|
9
|
+
import { SettingsMenu } from '../features/settings/SettingsMenu.tsx';
|
|
10
|
+
import { DEFAULT_SETTINGS_SECTION, settingsSection } from '../lib/settingsSections.ts';
|
|
11
|
+
import { sessionsStore } from '../stores/sessionsStore.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The settings pages, in the cockpit's frame: the rail stays so a session is
|
|
15
|
+
* one click away, and the main column becomes a section menu and its page.
|
|
16
|
+
* An unknown section falls back to the first one.
|
|
17
|
+
*/
|
|
18
|
+
export function SettingsPage() {
|
|
19
|
+
const { section } = useParams({ strict: false });
|
|
20
|
+
const navigate = useNavigate();
|
|
21
|
+
const activeId = useStore(sessionsStore, (state) => state.activeId);
|
|
22
|
+
const current = settingsSection(section);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (current) return;
|
|
26
|
+
void navigate({ to: '/settings/$section', params: { section: DEFAULT_SETTINGS_SECTION }, replace: true });
|
|
27
|
+
}, [current, navigate]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div data-testid="settings" className="relative flex h-full overflow-hidden">
|
|
31
|
+
<aside className="flex w-[300px] shrink-0 flex-col overflow-y-auto border-r border-doom-border bg-doom-rail">
|
|
32
|
+
<SessionRail />
|
|
33
|
+
</aside>
|
|
34
|
+
<main className="flex min-w-0 flex-1 flex-col">
|
|
35
|
+
<header className="flex h-12 shrink-0 items-center justify-between border-b border-doom-border px-5">
|
|
36
|
+
<span className="text-[13px] font-bold text-doom-hi">settings</span>
|
|
37
|
+
<Button asChild variant="ghost" size="sm">
|
|
38
|
+
{activeId !== null ? (
|
|
39
|
+
<Link to="/session/$sessionId" params={{ sessionId: activeId }} data-testid="settings-close">
|
|
40
|
+
back to session
|
|
41
|
+
</Link>
|
|
42
|
+
) : (
|
|
43
|
+
<Link to="/" data-testid="settings-close">
|
|
44
|
+
back
|
|
45
|
+
</Link>
|
|
46
|
+
)}
|
|
47
|
+
</Button>
|
|
48
|
+
</header>
|
|
49
|
+
<div className="flex min-h-0 flex-1">
|
|
50
|
+
<SettingsMenu active={current?.id} />
|
|
51
|
+
<section data-testid="settings-content" className="min-w-0 flex-1 overflow-y-auto px-6 py-5">
|
|
52
|
+
{current?.id === 'providers' ? <ProviderSettings /> : null}
|
|
53
|
+
{current?.id === 'appearance' ? <AppearanceSettings /> : null}
|
|
54
|
+
{current?.id === 'plugins' ? <PluginSettings /> : null}
|
|
55
|
+
</section>
|
|
56
|
+
</div>
|
|
57
|
+
</main>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createRootRoute, createRoute, Outlet } from '@tanstack/react-router';
|
|
2
|
+
import { CockpitPage } from './CockpitPage.tsx';
|
|
3
|
+
import { SettingsPage } from './SettingsPage.tsx';
|
|
4
|
+
|
|
5
|
+
const rootRoute = createRootRoute({
|
|
6
|
+
component: () => <Outlet />,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const indexRoute = createRoute({
|
|
10
|
+
getParentRoute: () => rootRoute,
|
|
11
|
+
path: '/',
|
|
12
|
+
component: CockpitPage,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const sessionRoute = createRoute({
|
|
16
|
+
getParentRoute: () => rootRoute,
|
|
17
|
+
path: '/session/$sessionId',
|
|
18
|
+
component: CockpitPage,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Every plugin tab lives under one parameterized segment, so the route tree
|
|
22
|
+
// stays static while the tab set comes from the plugin registry.
|
|
23
|
+
const sessionTabRoute = createRoute({
|
|
24
|
+
getParentRoute: () => rootRoute,
|
|
25
|
+
path: '/session/$sessionId/$tabId',
|
|
26
|
+
component: CockpitPage,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Settings keep the same shape: one static segment, one parameter for the
|
|
30
|
+
// section, so adding a page never touches the route tree.
|
|
31
|
+
const settingsRoute = createRoute({
|
|
32
|
+
getParentRoute: () => rootRoute,
|
|
33
|
+
path: '/settings',
|
|
34
|
+
component: SettingsPage,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const settingsSectionRoute = createRoute({
|
|
38
|
+
getParentRoute: () => rootRoute,
|
|
39
|
+
path: '/settings/$section',
|
|
40
|
+
component: SettingsPage,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const routeTree = rootRoute.addChildren([
|
|
44
|
+
indexRoute,
|
|
45
|
+
sessionRoute,
|
|
46
|
+
sessionTabRoute,
|
|
47
|
+
settingsRoute,
|
|
48
|
+
settingsSectionRoute,
|
|
49
|
+
]);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Store } from '@tanstack/store';
|
|
2
|
+
|
|
3
|
+
/** A selection-bar axis name: the built-in 'mode'/'domains', or a plugin-declared axis such as 'profile'. */
|
|
4
|
+
export type MenuKind = string;
|
|
5
|
+
|
|
6
|
+
export interface MenuClaim {
|
|
7
|
+
/** The agent dialog rendered as the bar's popover instead of the modal. */
|
|
8
|
+
dialogId: string;
|
|
9
|
+
menu: MenuKind;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MenuState {
|
|
13
|
+
/** Selection-bar button whose command is in flight, if any. */
|
|
14
|
+
pending: MenuKind | null;
|
|
15
|
+
/** When it was clicked; a stale anchor must not restyle an unrelated dialog. */
|
|
16
|
+
at: number;
|
|
17
|
+
/** The dialog the last click claimed, so the bar and the overlay agree who renders it. */
|
|
18
|
+
claimed: MenuClaim | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MENU_FRESH_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
export const menuStore = new Store<MenuState>({ pending: null, at: 0, claimed: null });
|
|
24
|
+
|
|
25
|
+
let expiry: ReturnType<typeof setTimeout> | undefined;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Remembers which selection-bar button asked the agent a question.
|
|
29
|
+
*
|
|
30
|
+
* The agent answers with an ordinary select dialog; this is the only signal
|
|
31
|
+
* that lets the bar render it as its popover menu rather than a centered
|
|
32
|
+
* modal. The button shows a pending state until the dialog arrives, the run
|
|
33
|
+
* settles, or the anchor goes stale.
|
|
34
|
+
*/
|
|
35
|
+
export function setPendingMenu(kind: MenuKind): void {
|
|
36
|
+
menuStore.setState((state) => ({ ...state, pending: kind, at: Date.now() }));
|
|
37
|
+
if (expiry !== undefined) clearTimeout(expiry);
|
|
38
|
+
expiry = setTimeout(clearPendingMenu, MENU_FRESH_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function clearPendingMenu(): void {
|
|
42
|
+
if (expiry !== undefined) clearTimeout(expiry);
|
|
43
|
+
expiry = undefined;
|
|
44
|
+
menuStore.setState((state) => (state.pending === null ? state : { ...state, pending: null, at: 0 }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The menu behind an arriving dialog, or null when none was asked for recently. */
|
|
48
|
+
export function pendingMenuFor(now: number): MenuKind | null {
|
|
49
|
+
const { pending, at } = menuStore.state;
|
|
50
|
+
if (pending === null || now - at > MENU_FRESH_MS) return null;
|
|
51
|
+
return pending;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Spends the pending anchor on an arriving select dialog. Called once per
|
|
56
|
+
* dialog, at frame time, so every surface reads the same verdict on its
|
|
57
|
+
* first render and the modal never flashes before the popover takes over.
|
|
58
|
+
*/
|
|
59
|
+
export function claimDialogMenu(dialogId: string, now = Date.now()): MenuClaim | null {
|
|
60
|
+
const menu = pendingMenuFor(now);
|
|
61
|
+
const claimed = menu === null ? null : { dialogId, menu };
|
|
62
|
+
clearPendingMenu();
|
|
63
|
+
menuStore.setState((state) => ({ ...state, claimed }));
|
|
64
|
+
return claimed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Forgets a claim once its dialog is gone. */
|
|
68
|
+
export function releaseDialogMenu(dialogId: string): void {
|
|
69
|
+
menuStore.setState((state) =>
|
|
70
|
+
state.claimed !== null && state.claimed.dialogId === dialogId ? { ...state, claimed: null } : state,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Test seam. */
|
|
75
|
+
export function resetMenuStore(): void {
|
|
76
|
+
clearPendingMenu();
|
|
77
|
+
menuStore.setState(() => ({ pending: null, at: 0, claimed: null }));
|
|
78
|
+
}
|