@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,382 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
Button,
|
|
4
|
+
ChevronDownIcon,
|
|
5
|
+
EmptyState,
|
|
6
|
+
ExternalLinkIcon,
|
|
7
|
+
Markdown,
|
|
8
|
+
RefreshIcon,
|
|
9
|
+
Separator,
|
|
10
|
+
StreamCursor,
|
|
11
|
+
} from '@agimon-ai/doompi-web-components';
|
|
12
|
+
import { memo, type ReactNode, useLayoutEffect, useRef, useState } from 'react';
|
|
13
|
+
import { useStore } from '@tanstack/react-store';
|
|
14
|
+
import type { Store } from '@tanstack/store';
|
|
15
|
+
import { parseFileMentions } from '../../lib/fileMentions.ts';
|
|
16
|
+
import type { SessionState, TimelineEntry } from '../../lib/sessionModel.ts';
|
|
17
|
+
import { requestOlderHistory, sessionStoreFor, submitMessage, useHasOlderHistory } from '../../stores/sessionStore.ts';
|
|
18
|
+
import { sessionsStore } from '../../stores/sessionsStore.ts';
|
|
19
|
+
import { MentionPreviews } from './MentionPreviews.tsx';
|
|
20
|
+
import { ToolCard } from './ToolCard.tsx';
|
|
21
|
+
|
|
22
|
+
const SUGGESTIONS = [
|
|
23
|
+
'review the working tree and summarise the diff',
|
|
24
|
+
'run the affected nx targets and report failures',
|
|
25
|
+
'explain how the session socket handshake works',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Closer than this to the end counts as reading the latest, so new frames keep the view pinned. */
|
|
29
|
+
const PINNED_THRESHOLD_PX = 200;
|
|
30
|
+
/** Closer than this to the top asks for the window above, before the reader hits the edge. */
|
|
31
|
+
const PAGE_BACK_THRESHOLD_PX = 400;
|
|
32
|
+
/**
|
|
33
|
+
* Entries at the end that are never skipped.
|
|
34
|
+
*
|
|
35
|
+
* Containment makes a skipped entry report its placeholder height, so a
|
|
36
|
+
* transcript whose tail is contained has a scroll height that is only an
|
|
37
|
+
* estimate, and following the newest line lands short of it. The tail is also
|
|
38
|
+
* the part a reader is nearly always looking at, so there is nothing to skip
|
|
39
|
+
* there; everything above it is what makes a long session slow.
|
|
40
|
+
*/
|
|
41
|
+
const LIVE_TAIL_ENTRIES = 40;
|
|
42
|
+
/** How quickly the viewport catches a moving streaming tail without snapping to it. */
|
|
43
|
+
const FOLLOW_EASING = 0.32;
|
|
44
|
+
const FOLLOW_MIN_STEP_PX = 2;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The speaker's label.
|
|
48
|
+
*
|
|
49
|
+
* Leading, it holds a fixed column so every entry's text starts at the same
|
|
50
|
+
* left edge and the labels line up down the page. Trailing, that column would
|
|
51
|
+
* only push the label away from the block it names, so it sizes to its own
|
|
52
|
+
* text and sits beside it.
|
|
53
|
+
*/
|
|
54
|
+
function Gutter({ label, tone, trailing = false }: { label: string; tone: string; trailing?: boolean }) {
|
|
55
|
+
return (
|
|
56
|
+
<span className={`shrink-0 text-[10px] font-bold ${trailing ? 'text-left' : 'w-11 pt-0.5 text-right'} ${tone}`}>
|
|
57
|
+
{label}
|
|
58
|
+
</span>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const Entry = memo(function Entry({ entry, sessionId }: { entry: TimelineEntry; sessionId: string | null }) {
|
|
63
|
+
if (entry.kind === 'user') {
|
|
64
|
+
return (
|
|
65
|
+
// What the reader said sits inboard of what the session said, on its own
|
|
66
|
+
// surface: the transcript is a conversation, and two lanes tell the two
|
|
67
|
+
// voices apart faster than a gutter label alone.
|
|
68
|
+
<div data-testid="entry-user" className="flex items-center gap-3 pl-10">
|
|
69
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2 rounded-md border border-doom-border-soft bg-doom-deep px-3.5 py-2.5 text-[13px] text-doom-hi">
|
|
70
|
+
<Markdown text={entry.text} />
|
|
71
|
+
{sessionId ? <MentionPreviews sessionId={sessionId} mentions={parseFileMentions(entry.text)} /> : null}
|
|
72
|
+
</div>
|
|
73
|
+
{/* The label trails what the reader said and the session's leads what
|
|
74
|
+
it answered, so the two voices read as two lanes at a glance. */}
|
|
75
|
+
<Gutter label="you" tone="text-doom-cyan" trailing />
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (entry.kind === 'assistant') {
|
|
81
|
+
return (
|
|
82
|
+
<div data-testid="entry-assistant" data-streaming={entry.streaming} className="flex gap-3">
|
|
83
|
+
<Gutter label="pi" tone="text-doom-magenta" />
|
|
84
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
85
|
+
{entry.thinking ? (
|
|
86
|
+
<div
|
|
87
|
+
data-testid="entry-thinking"
|
|
88
|
+
className="text-[11px] text-doom-violet/80 [&_strong]:text-doom-violet [&_p]:whitespace-pre-wrap"
|
|
89
|
+
>
|
|
90
|
+
<Markdown text={entry.thinking} />
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
<div className="text-[13px] text-doom-text">
|
|
94
|
+
<Markdown text={entry.text} />
|
|
95
|
+
{entry.streaming ? <StreamCursor /> : null}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (entry.kind === 'tool') {
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex gap-3">
|
|
105
|
+
<Gutter label="tool" tone="text-doom-faint" />
|
|
106
|
+
<div className="min-w-0 flex-1">
|
|
107
|
+
<ToolCard entry={entry} sessionId={sessionId} />
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (entry.kind === 'settled') {
|
|
114
|
+
return (
|
|
115
|
+
<div data-testid="entry-settled" className="flex items-center gap-3 pl-14">
|
|
116
|
+
<Separator className="flex-1" />
|
|
117
|
+
<span className="text-[10px] text-doom-faint">
|
|
118
|
+
agent settled{entry.tools > 0 ? ` · ${entry.tools} tool${entry.tools === 1 ? '' : 's'}` : ''}
|
|
119
|
+
</span>
|
|
120
|
+
<Separator className="flex-1" />
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (entry.kind === 'queued') {
|
|
126
|
+
return (
|
|
127
|
+
<div data-testid="entry-queued" className="flex pl-[58px]">
|
|
128
|
+
<Badge size="md" className="self-start bg-doom-panel text-[10px]">
|
|
129
|
+
<RefreshIcon className="h-3 w-3 shrink-0 text-doom-cyan" />
|
|
130
|
+
queued follow-up: "{entry.text}"
|
|
131
|
+
</Badge>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A notice: the agent's own aside (a mode switch, a refusal). Only an error
|
|
137
|
+
// shouts; an informational one reads as a quiet system line.
|
|
138
|
+
const isError = entry.tone === 'error';
|
|
139
|
+
return (
|
|
140
|
+
<div data-testid="entry-notice" data-tone={entry.tone} className="flex gap-3">
|
|
141
|
+
<Gutter label={isError ? '!' : '·'} tone={isError ? 'text-doom-red' : 'text-doom-faint'} />
|
|
142
|
+
<p className={`min-w-0 flex-1 break-words text-[12px] ${isError ? 'text-doom-red' : 'text-doom-dim'}`}>
|
|
143
|
+
{entry.text}
|
|
144
|
+
</p>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* One transcript, whichever fold it reads: the focused session's own, or a
|
|
151
|
+
* thread of it. Owns the scroll pinning; the caller owns the empty state and
|
|
152
|
+
* the session the entries' tool cards act on.
|
|
153
|
+
*/
|
|
154
|
+
export function Transcript({
|
|
155
|
+
store,
|
|
156
|
+
sessionId,
|
|
157
|
+
empty,
|
|
158
|
+
testId = 'timeline',
|
|
159
|
+
}: {
|
|
160
|
+
store: Store<SessionState>;
|
|
161
|
+
sessionId: string | null;
|
|
162
|
+
empty: ReactNode;
|
|
163
|
+
testId?: string;
|
|
164
|
+
}) {
|
|
165
|
+
const entries = useStore(store, (state) => state.entries);
|
|
166
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
167
|
+
// The transcript's height as of the last entry. Whether to follow the newest
|
|
168
|
+
// line is decided against this rather than against a scroll event, because
|
|
169
|
+
// an event fires after the fact and a fast run can grow the transcript
|
|
170
|
+
// before the browser has reported the reader's scroll.
|
|
171
|
+
const lastHeight = useRef(0);
|
|
172
|
+
const following = useRef(true);
|
|
173
|
+
const followFrame = useRef<number | null>(null);
|
|
174
|
+
const animatingFollow = useRef(false);
|
|
175
|
+
const [unread, setUnread] = useState(false);
|
|
176
|
+
const hasOlder = useHasOlderHistory(sessionId);
|
|
177
|
+
// Where the bottom of the transcript sat before a window was prepended.
|
|
178
|
+
// Restoring against this is what keeps the reader's place: prepending grows
|
|
179
|
+
// the scroll height above them, and the browser would otherwise leave the
|
|
180
|
+
// viewport where it was and the content would appear to jump.
|
|
181
|
+
const anchor = useRef<{ height: number; top: number } | null>(null);
|
|
182
|
+
// The first entry's key. A window prepended above the reader changes it, and
|
|
183
|
+
// nothing else does: an entry arriving at the bottom, or the streaming one
|
|
184
|
+
// growing, leaves the top of the transcript alone. That is the difference
|
|
185
|
+
// between holding the reader's place and refusing to follow a live run.
|
|
186
|
+
const firstId = useRef<string | null>(null);
|
|
187
|
+
|
|
188
|
+
const atBottom = (element: HTMLDivElement, height: number): boolean =>
|
|
189
|
+
element.scrollTop + element.clientHeight >= height - PINNED_THRESHOLD_PX;
|
|
190
|
+
|
|
191
|
+
const cancelFollowAnimation = (): void => {
|
|
192
|
+
if (followFrame.current !== null) cancelAnimationFrame(followFrame.current);
|
|
193
|
+
followFrame.current = null;
|
|
194
|
+
animatingFollow.current = false;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const followLatest = (): void => {
|
|
198
|
+
const element = scroller.current;
|
|
199
|
+
if (!element || followFrame.current !== null) return;
|
|
200
|
+
animatingFollow.current = true;
|
|
201
|
+
const step = (): void => {
|
|
202
|
+
const current = scroller.current;
|
|
203
|
+
if (!current || !following.current) {
|
|
204
|
+
followFrame.current = null;
|
|
205
|
+
animatingFollow.current = false;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const target = Math.max(0, current.scrollHeight - current.clientHeight);
|
|
209
|
+
const distance = target - current.scrollTop;
|
|
210
|
+
if (Math.abs(distance) <= 1) {
|
|
211
|
+
current.scrollTop = target;
|
|
212
|
+
followFrame.current = null;
|
|
213
|
+
animatingFollow.current = false;
|
|
214
|
+
setUnread(false);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const movement = Math.max(FOLLOW_MIN_STEP_PX, Math.abs(distance) * FOLLOW_EASING);
|
|
218
|
+
current.scrollTop += Math.sign(distance) * Math.min(Math.abs(distance), movement);
|
|
219
|
+
followFrame.current = requestAnimationFrame(step);
|
|
220
|
+
};
|
|
221
|
+
followFrame.current = requestAnimationFrame(step);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const jumpToLatest = (): void => {
|
|
225
|
+
following.current = true;
|
|
226
|
+
setUnread(false);
|
|
227
|
+
followLatest();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** Reaching the bottom by hand is the same as never having left it. */
|
|
231
|
+
const onScroll = (): void => {
|
|
232
|
+
const element = scroller.current;
|
|
233
|
+
if (!element) return;
|
|
234
|
+
const bottom = atBottom(element, element.scrollHeight);
|
|
235
|
+
if (!animatingFollow.current) following.current = bottom;
|
|
236
|
+
if (bottom) setUnread(false);
|
|
237
|
+
if (element.scrollTop <= PAGE_BACK_THRESHOLD_PX && hasOlder) {
|
|
238
|
+
anchor.current = { height: element.scrollHeight, top: element.scrollTop };
|
|
239
|
+
requestOlderHistory(sessionId);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** Wheel input is explicit intent to leave an in-flight live follow. */
|
|
244
|
+
const onWheel = (): void => {
|
|
245
|
+
cancelFollowAnimation();
|
|
246
|
+
following.current = false;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** Pointer input may become a scrollbar drag; its scroll event decides pinning. */
|
|
250
|
+
const onPointerDown = (): void => {
|
|
251
|
+
cancelFollowAnimation();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// A different fold starts at the bottom of its transcript.
|
|
255
|
+
useLayoutEffect(() => {
|
|
256
|
+
cancelFollowAnimation();
|
|
257
|
+
lastHeight.current = 0;
|
|
258
|
+
firstId.current = null;
|
|
259
|
+
anchor.current = null;
|
|
260
|
+
following.current = true;
|
|
261
|
+
setUnread(false);
|
|
262
|
+
return cancelFollowAnimation;
|
|
263
|
+
}, [store]);
|
|
264
|
+
|
|
265
|
+
useLayoutEffect(() => {
|
|
266
|
+
const element = scroller.current;
|
|
267
|
+
if (!element) return;
|
|
268
|
+
// A prepended window grew the transcript upwards; hold the reader where
|
|
269
|
+
// they were by moving down by exactly what appeared above them.
|
|
270
|
+
const held = anchor.current;
|
|
271
|
+
const prepended = entries.length > 0 && firstId.current !== null && entries[0]?.id !== firstId.current;
|
|
272
|
+
firstId.current = entries[0]?.id ?? null;
|
|
273
|
+
if (held !== null && prepended) {
|
|
274
|
+
anchor.current = null;
|
|
275
|
+
const grew = element.scrollHeight - held.height;
|
|
276
|
+
if (grew > 0) {
|
|
277
|
+
element.scrollTop = held.top + grew;
|
|
278
|
+
lastHeight.current = element.scrollHeight;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// The first layout starts at the end without animating through the whole
|
|
283
|
+
// transcript. Later growth chases the live tail one frame at a time.
|
|
284
|
+
if (lastHeight.current === 0) {
|
|
285
|
+
element.scrollTop = element.scrollHeight;
|
|
286
|
+
lastHeight.current = element.scrollHeight;
|
|
287
|
+
following.current = true;
|
|
288
|
+
setUnread(false);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const shouldFollow = animatingFollow.current || atBottom(element, lastHeight.current);
|
|
292
|
+
lastHeight.current = element.scrollHeight;
|
|
293
|
+
if (shouldFollow) {
|
|
294
|
+
following.current = true;
|
|
295
|
+
setUnread(false);
|
|
296
|
+
followLatest();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
setUnread(true);
|
|
300
|
+
}, [entries]);
|
|
301
|
+
|
|
302
|
+
if (entries.length === 0) return <>{empty}</>;
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<div className="relative flex min-h-0 flex-1 flex-col">
|
|
306
|
+
<div
|
|
307
|
+
ref={scroller}
|
|
308
|
+
onScroll={onScroll}
|
|
309
|
+
onWheel={onWheel}
|
|
310
|
+
onPointerDown={onPointerDown}
|
|
311
|
+
data-testid={testId}
|
|
312
|
+
className="flex flex-1 flex-col gap-[18px] overflow-y-auto px-[26px] py-[22px]"
|
|
313
|
+
>
|
|
314
|
+
{entries.map((entry, index) => (
|
|
315
|
+
// Entries above the live tail are skipped for layout and paint until
|
|
316
|
+
// they are scrolled near. A long transcript is thousands of markdown
|
|
317
|
+
// blocks, diffs and tool cards, and laying all of them out on every
|
|
318
|
+
// scroll is what makes an old session crawl. The browser does this
|
|
319
|
+
// rather than a measured list in JS, because entry heights vary by an
|
|
320
|
+
// order of magnitude and a list that guesses them wrong moves the
|
|
321
|
+
// reader's place under them.
|
|
322
|
+
<div
|
|
323
|
+
key={entry.id}
|
|
324
|
+
className={
|
|
325
|
+
index < entries.length - LIVE_TAIL_ENTRIES
|
|
326
|
+
? '[contain-intrinsic-size:auto_64px] [content-visibility:auto]'
|
|
327
|
+
: undefined
|
|
328
|
+
}
|
|
329
|
+
>
|
|
330
|
+
<Entry entry={entry} sessionId={sessionId} />
|
|
331
|
+
</div>
|
|
332
|
+
))}
|
|
333
|
+
</div>
|
|
334
|
+
{unread ? (
|
|
335
|
+
<Button
|
|
336
|
+
variant="subtle"
|
|
337
|
+
size="sm"
|
|
338
|
+
data-testid="timeline-jump"
|
|
339
|
+
onClick={jumpToLatest}
|
|
340
|
+
className="absolute bottom-3 left-1/2 -translate-x-1/2 border border-doom-border shadow-lg animate-doom-rise"
|
|
341
|
+
>
|
|
342
|
+
<ChevronDownIcon className="h-3 w-3" />
|
|
343
|
+
new activity below
|
|
344
|
+
</Button>
|
|
345
|
+
) : null}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The focused session's conversation; an empty one offers a few openers. */
|
|
351
|
+
export function Timeline() {
|
|
352
|
+
const activeId = useStore(sessionsStore, (state) => state.activeId);
|
|
353
|
+
return (
|
|
354
|
+
<Transcript
|
|
355
|
+
store={sessionStoreFor(activeId)}
|
|
356
|
+
sessionId={activeId}
|
|
357
|
+
empty={
|
|
358
|
+
<EmptyState
|
|
359
|
+
data-testid="timeline"
|
|
360
|
+
title="no messages yet"
|
|
361
|
+
description="this session is attached and waiting. anything you send goes straight to the supervised agent."
|
|
362
|
+
>
|
|
363
|
+
<div data-testid="timeline-empty" className="mt-2 flex w-full flex-col gap-1.5">
|
|
364
|
+
{SUGGESTIONS.map((suggestion, index) => (
|
|
365
|
+
<Button
|
|
366
|
+
key={suggestion}
|
|
367
|
+
variant="outline"
|
|
368
|
+
size="lg"
|
|
369
|
+
data-testid={`suggestion-${index}`}
|
|
370
|
+
onClick={() => submitMessage(suggestion)}
|
|
371
|
+
className="h-auto justify-between bg-doom-panel px-3 py-2 text-left font-normal text-doom-text"
|
|
372
|
+
>
|
|
373
|
+
<span className="flex-1 truncate">{suggestion}</span>
|
|
374
|
+
<ExternalLinkIcon className="h-3 w-3 shrink-0 text-doom-faint" />
|
|
375
|
+
</Button>
|
|
376
|
+
))}
|
|
377
|
+
</div>
|
|
378
|
+
</EmptyState>
|
|
379
|
+
}
|
|
380
|
+
/>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collapseLines,
|
|
3
|
+
MessageItem,
|
|
4
|
+
MessageItemBody,
|
|
5
|
+
MessageItemHeader,
|
|
6
|
+
MessageItemStatus,
|
|
7
|
+
type StatusTone,
|
|
8
|
+
} from '@agimon-ai/doompi-web-components';
|
|
9
|
+
import { ToolRendererBoundary } from '../../components/ToolRendererBoundary.tsx';
|
|
10
|
+
import { pluginToolRenderer } from '../../lib/pluginRegistry.ts';
|
|
11
|
+
import type { ToolEntry } from '../../lib/sessionModel.ts';
|
|
12
|
+
import { toolMessageProps } from '../../lib/toolMessageProps.ts';
|
|
13
|
+
import { useActiveSession } from '../../stores/sessionStore.ts';
|
|
14
|
+
import { usePluginSlotProps } from '../../stores/usePluginSlotProps.ts';
|
|
15
|
+
|
|
16
|
+
const MAX_PREVIEW_LINES = 12;
|
|
17
|
+
|
|
18
|
+
function toneOf(entry: ToolEntry): StatusTone {
|
|
19
|
+
return entry.running ? 'running' : entry.isError ? 'error' : 'ok';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The host's own item for a tool no plugin claims: the argument summary in
|
|
24
|
+
* the header and the result's text clipped until expanded, on the same shell
|
|
25
|
+
* every plugin message is built from.
|
|
26
|
+
*/
|
|
27
|
+
function HostToolMessage({ entry }: { entry: ToolEntry }) {
|
|
28
|
+
const lines = entry.output.length > 0 ? entry.output.split('\n') : [];
|
|
29
|
+
return (
|
|
30
|
+
<MessageItem tone={toneOf(entry)} expandable={lines.length > MAX_PREVIEW_LINES}>
|
|
31
|
+
{({ expanded }) => {
|
|
32
|
+
const { shown, hidden } = collapseLines(lines, MAX_PREVIEW_LINES, expanded);
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<MessageItemHeader title={entry.name}>
|
|
36
|
+
<span className="min-w-0 flex-1 truncate">{entry.argSummary}</span>
|
|
37
|
+
</MessageItemHeader>
|
|
38
|
+
{shown.length > 0 ? (
|
|
39
|
+
<MessageItemBody className="flex flex-col gap-1">
|
|
40
|
+
<pre data-testid="tool-output" className="whitespace-pre-wrap break-words">
|
|
41
|
+
{shown.join('\n')}
|
|
42
|
+
</pre>
|
|
43
|
+
{hidden > 0 ? <MessageItemStatus expands>show {hidden} more line(s)</MessageItemStatus> : null}
|
|
44
|
+
</MessageItemBody>
|
|
45
|
+
) : null}
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}}
|
|
49
|
+
</MessageItem>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One tool call in the timeline. The plugin that registered the tool owns
|
|
55
|
+
* the whole item through its `message` renderer, the web half of the TUI's
|
|
56
|
+
* renderShell 'self'; the host only marks the row, catches a renderer that
|
|
57
|
+
* throws, and stands in for a tool nobody claims.
|
|
58
|
+
*/
|
|
59
|
+
export function ToolCard({ entry, sessionId }: { entry: ToolEntry; sessionId: string | null }) {
|
|
60
|
+
const statuses = useActiveSession((state) => state.statuses);
|
|
61
|
+
const slotProps = usePluginSlotProps(sessionId);
|
|
62
|
+
const renderer = pluginToolRenderer(entry.name, statuses);
|
|
63
|
+
const state = toneOf(entry);
|
|
64
|
+
const props = toolMessageProps(slotProps, entry, statuses);
|
|
65
|
+
return (
|
|
66
|
+
<ToolRendererBoundary key={entry.toolCallId} toolName={entry.name}>
|
|
67
|
+
{(failed) => (
|
|
68
|
+
<div
|
|
69
|
+
data-testid="entry-tool"
|
|
70
|
+
data-tool-name={entry.name}
|
|
71
|
+
data-tool-state={state}
|
|
72
|
+
data-tool-renderer={failed ? 'failed' : renderer ? 'plugin' : 'host'}
|
|
73
|
+
>
|
|
74
|
+
{renderer && !failed ? <renderer.message {...props} /> : <HostToolMessage entry={entry} />}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
</ToolRendererBoundary>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogBody,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
Input,
|
|
10
|
+
OptionRow,
|
|
11
|
+
} from '@agimon-ai/doompi-web-components';
|
|
12
|
+
import { useNavigate } from '@tanstack/react-router';
|
|
13
|
+
import { useStore } from '@tanstack/react-store';
|
|
14
|
+
import { useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { createSession, searchDirectories } from '../../lib/hubApi.ts';
|
|
16
|
+
import { sessionsStore, waitForSession } from '../../stores/sessionsStore.ts';
|
|
17
|
+
|
|
18
|
+
const PATH_SEPARATOR = '/';
|
|
19
|
+
/** Keystrokes settle for this long before the hub is asked for directories. */
|
|
20
|
+
const SUGGESTION_DEBOUNCE_MS = 120;
|
|
21
|
+
const NO_HIGHLIGHT = -1;
|
|
22
|
+
const TRAILING_SEPARATORS = /\/+$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates a session the way ctrl+t promises: pick a directory, get an agent.
|
|
26
|
+
*
|
|
27
|
+
* The directory box completes as it is typed: the hub lists the children of
|
|
28
|
+
* the parent directory whose names match the trailing segment as a regex,
|
|
29
|
+
* and picking one appends a slash so the next level narrows the same way.
|
|
30
|
+
* The recent-cwd chips are just the distinct directories of live sessions;
|
|
31
|
+
* nothing is persisted, because the rail already is the user's working set.
|
|
32
|
+
*/
|
|
33
|
+
export function NewSessionDialog({ onClose }: { onClose: () => void }) {
|
|
34
|
+
const navigate = useNavigate();
|
|
35
|
+
const byId = useStore(sessionsStore, (state) => state.byId);
|
|
36
|
+
const activeId = useStore(sessionsStore, (state) => state.activeId);
|
|
37
|
+
const [cwd, setCwd] = useState(() => (activeId !== null ? (byId[activeId]?.summary.cwd ?? '') : ''));
|
|
38
|
+
const [name, setName] = useState('');
|
|
39
|
+
const [error, setError] = useState('');
|
|
40
|
+
const [busy, setBusy] = useState(false);
|
|
41
|
+
const [suggestions, setSuggestions] = useState<string[]>([]);
|
|
42
|
+
const [highlight, setHighlight] = useState(NO_HIGHLIGHT);
|
|
43
|
+
const cwdInput = useRef<HTMLInputElement>(null);
|
|
44
|
+
|
|
45
|
+
const recentCwds = [...new Set(Object.values(byId).map((meta) => meta.summary.cwd))].slice(0, 4);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const typed = cwd.trim();
|
|
49
|
+
// Any typing is worth a lookup now: the hub completes a path being drilled
|
|
50
|
+
// into and searches for anything else, so a bare folder name finds itself.
|
|
51
|
+
if (typed === '') {
|
|
52
|
+
setSuggestions([]);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
let stale = false;
|
|
56
|
+
const timer = setTimeout(async () => {
|
|
57
|
+
const found = await searchDirectories(typed);
|
|
58
|
+
if (stale) return;
|
|
59
|
+
// The path already typed in full is not a suggestion, only its siblings are.
|
|
60
|
+
setSuggestions(found.filter((directory) => directory !== typed));
|
|
61
|
+
setHighlight(NO_HIGHLIGHT);
|
|
62
|
+
}, SUGGESTION_DEBOUNCE_MS);
|
|
63
|
+
return () => {
|
|
64
|
+
stale = true;
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
};
|
|
67
|
+
}, [cwd]);
|
|
68
|
+
|
|
69
|
+
const pick = (directory: string): void => {
|
|
70
|
+
setCwd(`${directory}${PATH_SEPARATOR}`);
|
|
71
|
+
cwdInput.current?.focus();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const submit = async (): Promise<void> => {
|
|
75
|
+
const directory = cwd.trim().replace(TRAILING_SEPARATORS, '') || PATH_SEPARATOR;
|
|
76
|
+
if (busy || !cwd.trim()) return;
|
|
77
|
+
setBusy(true);
|
|
78
|
+
setError('');
|
|
79
|
+
const outcome = await createSession({ cwd: directory, name: name.trim() || undefined });
|
|
80
|
+
if ('sessionId' in outcome) {
|
|
81
|
+
// Navigating before the hub's upsert lands would bounce off the
|
|
82
|
+
// unknown-session fallback; wait until the rail knows the session.
|
|
83
|
+
const appeared = await waitForSession(outcome.sessionId);
|
|
84
|
+
if (appeared) {
|
|
85
|
+
onClose();
|
|
86
|
+
await navigate({ to: '/session/$sessionId', params: { sessionId: outcome.sessionId } });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setError('The session was created but has not appeared yet; it will show up in the rail.');
|
|
90
|
+
setBusy(false);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setError(outcome.error);
|
|
94
|
+
setBusy(false);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const onCwdKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
|
98
|
+
if (event.key === 'ArrowDown' && suggestions.length > 0) {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
setHighlight((current) => (current + 1) % suggestions.length);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (event.key === 'ArrowUp' && suggestions.length > 0) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
setHighlight((current) => (current <= 0 ? suggestions.length - 1 : current - 1));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (event.key === 'Enter') {
|
|
109
|
+
const chosen = highlight === NO_HIGHLIGHT ? undefined : suggestions[highlight];
|
|
110
|
+
if (chosen !== undefined) pick(chosen);
|
|
111
|
+
else void submit();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Dialog
|
|
117
|
+
open
|
|
118
|
+
onOpenChange={(next) => {
|
|
119
|
+
if (!next) onClose();
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<DialogContent width="md" data-testid="new-session-dialog" aria-describedby={undefined} className="w-[440px]">
|
|
123
|
+
<DialogHeader>
|
|
124
|
+
<DialogTitle>new session</DialogTitle>
|
|
125
|
+
</DialogHeader>
|
|
126
|
+
<DialogBody>
|
|
127
|
+
<label className="flex flex-col gap-1">
|
|
128
|
+
<span className="text-[10px] text-doom-faint">
|
|
129
|
+
working directory <span className="text-doom-faint/70">(type a folder name or paste a path)</span>
|
|
130
|
+
</span>
|
|
131
|
+
<Input
|
|
132
|
+
ref={cwdInput}
|
|
133
|
+
data-testid="new-session-cwd"
|
|
134
|
+
value={cwd}
|
|
135
|
+
autoFocus
|
|
136
|
+
onChange={(event) => setCwd(event.target.value)}
|
|
137
|
+
onKeyDown={onCwdKeyDown}
|
|
138
|
+
placeholder="agirepo, or /absolute/path/to/project"
|
|
139
|
+
/>
|
|
140
|
+
</label>
|
|
141
|
+
{suggestions.length > 0 ? (
|
|
142
|
+
<div
|
|
143
|
+
role="listbox"
|
|
144
|
+
aria-label="matching directories"
|
|
145
|
+
data-testid="new-session-suggestions"
|
|
146
|
+
className="max-h-40 overflow-y-auto rounded border border-doom-border bg-doom-deep p-1"
|
|
147
|
+
>
|
|
148
|
+
{suggestions.map((directory, index) => (
|
|
149
|
+
<OptionRow
|
|
150
|
+
key={directory}
|
|
151
|
+
density="compact"
|
|
152
|
+
active={index === highlight}
|
|
153
|
+
data-testid="new-session-suggestion"
|
|
154
|
+
data-highlighted={index === highlight}
|
|
155
|
+
title={directory}
|
|
156
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
157
|
+
onMouseEnter={() => setHighlight(index)}
|
|
158
|
+
onClick={() => pick(directory)}
|
|
159
|
+
className="w-full px-2.5 py-1 text-[11px]"
|
|
160
|
+
>
|
|
161
|
+
{directory}
|
|
162
|
+
</OptionRow>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
) : null}
|
|
166
|
+
{recentCwds.length > 0 ? (
|
|
167
|
+
<div className="flex flex-wrap gap-1">
|
|
168
|
+
{recentCwds.map((recent) => (
|
|
169
|
+
<Button
|
|
170
|
+
key={recent}
|
|
171
|
+
variant="outline"
|
|
172
|
+
size="xs"
|
|
173
|
+
data-testid="new-session-recent"
|
|
174
|
+
onClick={() => setCwd(recent)}
|
|
175
|
+
className="font-normal"
|
|
176
|
+
>
|
|
177
|
+
{recent}
|
|
178
|
+
</Button>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
) : null}
|
|
182
|
+
<label className="flex flex-col gap-1">
|
|
183
|
+
<span className="text-[10px] text-doom-faint">name (optional)</span>
|
|
184
|
+
<Input
|
|
185
|
+
data-testid="new-session-name"
|
|
186
|
+
value={name}
|
|
187
|
+
onChange={(event) => setName(event.target.value)}
|
|
188
|
+
onKeyDown={(event) => {
|
|
189
|
+
if (event.key === 'Enter') void submit();
|
|
190
|
+
}}
|
|
191
|
+
placeholder="untitled"
|
|
192
|
+
/>
|
|
193
|
+
</label>
|
|
194
|
+
{error ? (
|
|
195
|
+
// A spawn failure arrives as the child's whole stderr, stack trace
|
|
196
|
+
// and all. It is worth showing (it names the real cause) but not
|
|
197
|
+
// worth resizing the dialog for, so it lives in its own scroller.
|
|
198
|
+
<pre
|
|
199
|
+
data-testid="new-session-error"
|
|
200
|
+
className="max-h-28 overflow-y-auto rounded border border-doom-edge-red bg-doom-tint-red/40 px-2.5 py-2 text-[10px] leading-relaxed whitespace-pre-wrap break-words text-doom-red"
|
|
201
|
+
>
|
|
202
|
+
{error}
|
|
203
|
+
</pre>
|
|
204
|
+
) : null}
|
|
205
|
+
<DialogFooter>
|
|
206
|
+
<Button variant="outline" data-testid="new-session-cancel" onClick={onClose}>
|
|
207
|
+
cancel
|
|
208
|
+
</Button>
|
|
209
|
+
<Button
|
|
210
|
+
variant="primary"
|
|
211
|
+
data-testid="new-session-create"
|
|
212
|
+
onClick={() => void submit()}
|
|
213
|
+
disabled={busy || !cwd.trim()}
|
|
214
|
+
>
|
|
215
|
+
{busy ? 'creating…' : 'create'}
|
|
216
|
+
</Button>
|
|
217
|
+
</DialogFooter>
|
|
218
|
+
</DialogBody>
|
|
219
|
+
</DialogContent>
|
|
220
|
+
</Dialog>
|
|
221
|
+
);
|
|
222
|
+
}
|