v0.5.0: NomadArch - Binary-Free Mode Release
Some checks failed
Release Binaries / release (push) Has been cancelled
Some checks failed
Release Binaries / release (push) Has been cancelled
Features: - Binary-Free Mode: No OpenCode binary required - NomadArch Native mode with free Zen models - Native session management - Provider routing (Zen, Qwen, Z.AI) - Fixed MCP connection with explicit connectAll() - Updated installers and launchers for all platforms - UI binary selector with Native option Free Models Available: - GPT-5 Nano (400K context) - Grok Code Fast 1 (256K context) - GLM-4.7 (205K context) - Doubao Seed Code (256K context) - Big Pickle (200K context)
This commit is contained in:
3
packages/ui/.gitignore
vendored
Normal file
3
packages/ui/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
54
packages/ui/README.md
Normal file
54
packages/ui/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# CodeNomad UI
|
||||
|
||||
This package contains the frontend user interface for CodeNomad, built with [SolidJS](https://www.solidjs.com/) and [Tailwind CSS](https://tailwindcss.com/).
|
||||
|
||||
## Overview
|
||||
|
||||
The UI is designed to be a high-performance, low-latency cockpit for managing OpenCode sessions. It connects to the CodeNomad server (either running locally via CLI or embedded in the Electron app).
|
||||
|
||||
## Features
|
||||
|
||||
- **SolidJS**: Fine-grained reactivity for high performance.
|
||||
- **Tailwind CSS**: Utility-first styling for rapid development.
|
||||
- **Vite**: Fast build tool and dev server.
|
||||
|
||||
## Development
|
||||
|
||||
To run the UI in standalone mode (connected to a running server):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts the Vite dev server at `http://localhost:3000`.
|
||||
|
||||
## Building
|
||||
|
||||
To build the production assets:
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
|
||||
|
||||
## Debug Logging
|
||||
|
||||
The UI now routes all logging through a lightweight wrapper around [`debug`](https://github.com/debug-js/debug). The logger exposes four namespaces that can be toggled at runtime:
|
||||
|
||||
- `sse` – Server-sent event transport and handlers
|
||||
- `api` – HTTP/API calls and workspace lifecycle
|
||||
- `session` – Session/model state, prompt handling, tool calls
|
||||
- `actions` – User-driven interactions in UI components
|
||||
|
||||
You can enable or disable namespaces from DevTools (in dev or production builds) via the global `window.codenomadLogger` helpers:
|
||||
|
||||
```js
|
||||
window.codenomadLogger?.listLoggerNamespaces() // => [{ name: "sse", enabled: false }, ...]
|
||||
window.codenomadLogger?.enableLogger("sse") // turn on SSE logs
|
||||
window.codenomadLogger?.disableLogger("sse") // turn them off again
|
||||
window.codenomadLogger?.enableAllLoggers() // optional helper
|
||||
```
|
||||
|
||||
Enabled namespaces are persisted in `localStorage` under `opencode:logger:namespaces`, so your preference survives reloads.
|
||||
|
||||
40
packages/ui/package.json
Normal file
40
packages/ui/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "node --test --experimental-strip-types src/lib/__tests__/*.test.ts src/stores/__tests__/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "^1.0.138",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "10.4.21",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-solid": "^2.10.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
11
packages/ui/postcss.config.js
Normal file
11
packages/ui/postcss.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { fileURLToPath } from "url"
|
||||
import { dirname, resolve } from "path"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: { config: resolve(__dirname, "tailwind.config.js") },
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
480
packages/ui/src/App.tsx
Normal file
480
packages/ui/src/App.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Toaster } from "solid-toast"
|
||||
import AlertDialog from "./components/alert-dialog"
|
||||
import FolderSelectionView from "./components/folder-selection-view"
|
||||
import { showConfirmDialog } from "./stores/alerts"
|
||||
import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import QwenOAuthCallback from "./pages/QwenOAuthCallback"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
import { getLogger } from "./lib/logger"
|
||||
import { initReleaseNotifications } from "./stores/releases"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
showFolderSelectionOnStart,
|
||||
setShowFolderSelectionOnStart,
|
||||
} from "./stores/ui"
|
||||
import { useConfig } from "./stores/preferences"
|
||||
import {
|
||||
createInstance,
|
||||
instances,
|
||||
activeInstanceId,
|
||||
setActiveInstanceId,
|
||||
stopInstance,
|
||||
getActiveInstance,
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
} from "./stores/instances"
|
||||
import {
|
||||
getSessions,
|
||||
activeSessionId,
|
||||
setActiveParentSession,
|
||||
clearActiveParentSession,
|
||||
createSession,
|
||||
fetchSessions,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
} from "./stores/sessions"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
preferences,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleShowTimelineTools,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
interface LaunchErrorState {
|
||||
message: string
|
||||
binaryPath: string
|
||||
missingBinary: boolean
|
||||
}
|
||||
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
const shouldShowFolderSelection = () => !hasInstances() || showFolderSelectionOnStart()
|
||||
|
||||
const updateInstanceTabBarHeight = () => {
|
||||
if (typeof document === "undefined") return
|
||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
initReleaseNotifications()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
instances()
|
||||
hasInstances()
|
||||
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
updateInstanceTabBarHeight()
|
||||
const handleResize = () => updateInstanceTabBarHeight()
|
||||
window.addEventListener("resize", handleResize)
|
||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => getActiveInstance())
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return null
|
||||
return activeSessionId().get(instance.id) || null
|
||||
})
|
||||
|
||||
const launchErrorPath = () => {
|
||||
const value = launchError()?.binaryPath
|
||||
if (!value) return "opencode"
|
||||
return value.trim() || "opencode"
|
||||
}
|
||||
|
||||
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||
|
||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||
if (!error) {
|
||||
return "Failed to launch workspace"
|
||||
}
|
||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed.error === "string") {
|
||||
return parsed.error
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
const isMissingBinaryMessage = (message: string): boolean => {
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
normalized.includes("opencode binary not found") ||
|
||||
normalized.includes("binary not found") ||
|
||||
normalized.includes("no such file or directory") ||
|
||||
normalized.includes("binary is not executable") ||
|
||||
normalized.includes("enoent")
|
||||
)
|
||||
}
|
||||
|
||||
const clearLaunchError = () => setLaunchError(null)
|
||||
|
||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||
if (!folderPath) {
|
||||
return
|
||||
}
|
||||
setIsSelectingFolder(true)
|
||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||
try {
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
setShowFolderSelection(false)
|
||||
setShowFolderSelectionOnStart(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
log.info("Created instance", {
|
||||
instanceId,
|
||||
port: instances().get(instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = formatLaunchErrorMessage(error)
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
setLaunchError({
|
||||
message,
|
||||
binaryPath: selectedBinary,
|
||||
missingBinary,
|
||||
})
|
||||
log.error("Failed to create instance", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLaunchErrorClose() {
|
||||
clearLaunchError()
|
||||
}
|
||||
|
||||
function handleLaunchErrorAdvanced() {
|
||||
clearLaunchError()
|
||||
setIsAdvancedSettingsOpen(true)
|
||||
}
|
||||
|
||||
function handleNewInstanceRequest() {
|
||||
if (hasInstances()) {
|
||||
setShowFolderSelection(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnectedInstanceClose() {
|
||||
try {
|
||||
await acknowledgeDisconnectedInstance()
|
||||
} catch (error) {
|
||||
log.error("Failed to finalize disconnected instance", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseInstance(instanceId: string) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
"Stop OpenCode instance? This will stop the server.",
|
||||
{
|
||||
title: "Stop instance",
|
||||
variant: "warning",
|
||||
confirmLabel: "Stop",
|
||||
cancelLabel: "Keep running",
|
||||
},
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
await stopInstance(instanceId)
|
||||
}
|
||||
|
||||
async function handleNewSession(instanceId: string) {
|
||||
try {
|
||||
const session = await createSession(instanceId)
|
||||
setActiveParentSession(instanceId, session.id)
|
||||
} catch (error) {
|
||||
log.error("Failed to create session", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseSession(instanceId: string, sessionId: string) {
|
||||
const sessions = getSessions(instanceId)
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentSessionId = session.parentId ?? session.id
|
||||
const parentSession = sessions.find((s) => s.id === parentSessionId)
|
||||
|
||||
if (!parentSession || parentSession.parentId !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
clearActiveParentSession(instanceId)
|
||||
|
||||
try {
|
||||
await fetchSessions(instanceId)
|
||||
} catch (error) {
|
||||
log.error("Failed to refresh sessions after closing", error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
||||
if (!instanceId || !sessionId || sessionId === "info") return
|
||||
await updateSessionAgent(instanceId, sessionId, agent)
|
||||
}
|
||||
|
||||
const handleSidebarModelChange = async (
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
model: { providerId: string; modelId: string },
|
||||
) => {
|
||||
if (!instanceId || !sessionId || sessionId === "info") return
|
||||
await updateSessionModel(instanceId, sessionId, model)
|
||||
}
|
||||
|
||||
const { commands: paletteCommands, executeCommand } = useCommands({
|
||||
preferences,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
getActiveInstance: activeInstance,
|
||||
getActiveSessionIdForInstance: activeSessionIdForInstance,
|
||||
})
|
||||
|
||||
useAppLifecycle({
|
||||
setEscapeInDebounce,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
getActiveInstance: activeInstance,
|
||||
getActiveSessionIdForInstance: activeSessionIdForInstance,
|
||||
})
|
||||
|
||||
// Listen for Tauri menu events
|
||||
onMount(() => {
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
||||
if (tauriBridge?.event) {
|
||||
let unlistenMenu: (() => void) | null = null
|
||||
|
||||
tauriBridge.event.listen("menu:newInstance", () => {
|
||||
handleNewInstanceRequest()
|
||||
}).then((unlisten) => {
|
||||
unlistenMenu = unlisten
|
||||
}).catch((error) => {
|
||||
log.error("Failed to listen for menu:newInstance event", error)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
unlistenMenu?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Check if this is OAuth callback
|
||||
const isOAuthCallback = window.location.pathname === '/auth/qwen/callback'
|
||||
|
||||
if (isOAuthCallback) {
|
||||
return <QwenOAuthCallback />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InstanceDisconnectedModal
|
||||
open={Boolean(disconnectedInstance())}
|
||||
folder={disconnectedInstance()?.folder}
|
||||
reason={disconnectedInstance()?.reason}
|
||||
onClose={handleDisconnectedInstanceClose}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(launchError())} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
|
||||
binary from Advanced Settings.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Binary path</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Show when={launchError()?.missingBinary}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleLaunchErrorAdvanced}
|
||||
>
|
||||
Open Advanced Settings
|
||||
</button>
|
||||
</Show>
|
||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
<div class="h-screen w-screen flex flex-col">
|
||||
<Show
|
||||
when={shouldShowFolderSelection()}
|
||||
fallback={
|
||||
<>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
|
||||
<For each={Array.from(instances().values())}>
|
||||
{(instance) => {
|
||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||
return (
|
||||
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={instanceTabBarHeight()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
}}
|
||||
</For>
|
||||
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={showFolderSelection()}>
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||
<div class="w-full h-full relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFolderSelection(false)
|
||||
setShowFolderSelectionOnStart(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
clearLaunchError()
|
||||
}}
|
||||
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
||||
|
||||
<AlertDialog />
|
||||
|
||||
<Toaster
|
||||
position="top-right"
|
||||
gutter={16}
|
||||
toastOptions={{
|
||||
duration: 8000,
|
||||
className: "bg-transparent border-none shadow-none p-0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default App
|
||||
136
packages/ui/src/components/advanced-settings-modal.tsx
Normal file
136
packages/ui/src/components/advanced-settings-modal.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Component, createSignal, Show } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
||||
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
||||
import OllamaCloudSettings from "./settings/OllamaCloudSettings"
|
||||
import QwenCodeSettings from "./settings/QwenCodeSettings"
|
||||
import ZAISettings from "./settings/ZAISettings"
|
||||
import OpenCodeZenSettings from "./settings/OpenCodeZenSettings"
|
||||
|
||||
interface AdvancedSettingsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
selectedBinary: string
|
||||
onBinaryChange: (binary: string) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
||||
const [activeTab, setActiveTab] = createSignal("general")
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-6xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title>
|
||||
</header>
|
||||
|
||||
<div class="border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||
<div class="flex w-full px-6 overflow-x-auto">
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap ${activeTab() === "zen"
|
||||
? "border-orange-500 text-orange-400"
|
||||
: "border-transparent hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("zen")}
|
||||
>
|
||||
🆓 Free Models
|
||||
</button>
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap ${activeTab() === "general"
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("general")}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap ${activeTab() === "ollama"
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("ollama")}
|
||||
>
|
||||
Ollama Cloud
|
||||
</button>
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap ${activeTab() === "qwen"
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("qwen")}
|
||||
>
|
||||
Qwen Code
|
||||
</button>
|
||||
<button
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap ${activeTab() === "zai"
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveTab("zai")}
|
||||
>
|
||||
Z.AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<Show when={activeTab() === "zen"}>
|
||||
<OpenCodeZenSettings />
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "general"}>
|
||||
<div class="p-6 space-y-6">
|
||||
<OpenCodeBinarySelector
|
||||
selectedBinary={props.selectedBinary}
|
||||
onBinaryChange={props.onBinaryChange}
|
||||
disabled={Boolean(props.isLoading)}
|
||||
isVisible={props.open}
|
||||
/>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Environment Variables</h3>
|
||||
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "ollama"}>
|
||||
<OllamaCloudSettings />
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "qwen"}>
|
||||
<QwenCodeSettings />
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "zai"}>
|
||||
<ZAISettings />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdvancedSettingsModal
|
||||
327
packages/ui/src/components/agent-creator-dialog.tsx
Normal file
327
packages/ui/src/components/agent-creator-dialog.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Bot, Loader2, Sparkles, X } from "lucide-solid"
|
||||
import { Component, Show, createSignal } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { updateInstanceConfig } from "../stores/instance-config"
|
||||
import { fetchAgents } from "../stores/sessions"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("agent-creator")
|
||||
|
||||
const MAX_PROMPT_LENGTH = 30000
|
||||
|
||||
interface AgentCreatorDialogProps {
|
||||
instanceId: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AgentCreatorDialog: Component<AgentCreatorDialogProps> = (props) => {
|
||||
const [name, setName] = createSignal("")
|
||||
const [description, setDescription] = createSignal("")
|
||||
const [prompt, setPrompt] = createSignal("")
|
||||
const [isGenerating, setIsGenerating] = createSignal(false)
|
||||
const [isSaving, setIsSaving] = createSignal(false)
|
||||
const [useAiGeneration, setUseAiGeneration] = createSignal(true)
|
||||
|
||||
const resetForm = () => {
|
||||
setName("")
|
||||
setDescription("")
|
||||
setPrompt("")
|
||||
setIsGenerating(false)
|
||||
setUseAiGeneration(true)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const generatePromptWithAI = async () => {
|
||||
if (!name().trim() || !description().trim()) {
|
||||
showToastNotification({
|
||||
title: "Missing Information",
|
||||
message: "Please provide both name and description to generate an agent prompt.",
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
// Use Z.AI or another endpoint to generate the prompt
|
||||
const response = await fetch("/api/zai/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "glm-4.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an expert AI agent prompt designer. Generate a comprehensive, detailed system prompt for an AI coding assistant agent based on the user's requirements. The prompt should:
|
||||
1. Define the agent's role and expertise
|
||||
2. Specify its capabilities and limitations
|
||||
3. Include guidelines for code style and best practices
|
||||
4. Define how it should interact with users
|
||||
5. Include any domain-specific knowledge relevant to the description
|
||||
|
||||
Output ONLY the agent system prompt, no explanations or markdown formatting.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Create a system prompt for an AI coding agent with the following details:
|
||||
|
||||
Name: ${name()}
|
||||
Purpose: ${description()}
|
||||
|
||||
Generate a comprehensive system prompt that will make this agent effective at its purpose.`,
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
max_tokens: 4096,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Generation failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const generatedPrompt = data?.choices?.[0]?.message?.content || data?.message?.content || ""
|
||||
|
||||
if (generatedPrompt) {
|
||||
setPrompt(generatedPrompt)
|
||||
showToastNotification({
|
||||
title: "Prompt Generated",
|
||||
message: "AI has generated a system prompt for your agent. Review and edit as needed.",
|
||||
variant: "success",
|
||||
duration: 5000,
|
||||
})
|
||||
} else {
|
||||
throw new Error("No prompt content in response")
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to generate agent prompt", error)
|
||||
showToastNotification({
|
||||
title: "Generation Failed",
|
||||
message: "Could not generate prompt. Please write one manually or check your Z.AI configuration.",
|
||||
variant: "error",
|
||||
duration: 8000,
|
||||
})
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name().trim()) {
|
||||
showToastNotification({
|
||||
title: "Name Required",
|
||||
message: "Please provide a name for the agent.",
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!prompt().trim()) {
|
||||
showToastNotification({
|
||||
title: "Prompt Required",
|
||||
message: "Please provide a system prompt for the agent.",
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await updateInstanceConfig(props.instanceId, (draft) => {
|
||||
if (!draft.customAgents) {
|
||||
draft.customAgents = []
|
||||
}
|
||||
// Check for duplicate names
|
||||
const existing = draft.customAgents.findIndex((a) => a.name.toLowerCase() === name().toLowerCase())
|
||||
if (existing >= 0) {
|
||||
// Update existing
|
||||
draft.customAgents[existing] = {
|
||||
name: name().trim(),
|
||||
description: description().trim() || undefined,
|
||||
prompt: prompt().trim(),
|
||||
}
|
||||
} else {
|
||||
// Add new
|
||||
draft.customAgents.push({
|
||||
name: name().trim(),
|
||||
description: description().trim() || undefined,
|
||||
prompt: prompt().trim(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Refresh agents list
|
||||
await fetchAgents(props.instanceId)
|
||||
|
||||
showToastNotification({
|
||||
title: "Agent Created",
|
||||
message: `Custom agent "${name()}" has been saved and is ready to use.`,
|
||||
variant: "success",
|
||||
duration: 5000,
|
||||
})
|
||||
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
log.error("Failed to save custom agent", error)
|
||||
showToastNotification({
|
||||
title: "Save Failed",
|
||||
message: "Could not save the agent. Please try again.",
|
||||
variant: "error",
|
||||
duration: 8000,
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[9998]" />
|
||||
<div class="fixed inset-0 flex items-center justify-center z-[9999] p-4">
|
||||
<Dialog.Content class="bg-zinc-900 border border-zinc-700 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 border-b border-zinc-700/50">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-indigo-500/20 rounded-lg">
|
||||
<Bot size={20} class="text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Dialog.Title class="text-lg font-semibold text-white">Create Custom Agent</Dialog.Title>
|
||||
<Dialog.Description class="text-xs text-zinc-400">
|
||||
Define a new AI agent with custom behavior and expertise
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700/50 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Name Input */}
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Agent Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name()}
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
placeholder="e.g., React Specialist, Python Expert, Code Reviewer..."
|
||||
class="w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description Input */}
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-zinc-300">Brief Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description()}
|
||||
onInput={(e) => setDescription(e.currentTarget.value)}
|
||||
placeholder="A few words about what this agent specializes in..."
|
||||
class="w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Generation Mode Toggle */}
|
||||
<div class="flex items-center gap-4 p-3 bg-zinc-800/50 rounded-lg border border-zinc-700/50">
|
||||
<button
|
||||
onClick={() => setUseAiGeneration(true)}
|
||||
class={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all ${useAiGeneration()
|
||||
? "bg-indigo-500 text-white"
|
||||
: "text-zinc-400 hover:text-white hover:bg-zinc-700/50"
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={14} class="inline-block mr-1.5" />
|
||||
AI Generate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUseAiGeneration(false)}
|
||||
class={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all ${!useAiGeneration()
|
||||
? "bg-indigo-500 text-white"
|
||||
: "text-zinc-400 hover:text-white hover:bg-zinc-700/50"
|
||||
}`}
|
||||
>
|
||||
Write Manually
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Generation Button */}
|
||||
<Show when={useAiGeneration()}>
|
||||
<button
|
||||
onClick={generatePromptWithAI}
|
||||
disabled={isGenerating() || !name().trim() || !description().trim()}
|
||||
class="w-full py-2.5 px-4 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white rounded-lg font-medium text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Show when={isGenerating()} fallback={<><Sparkles size={16} /> Generate Agent Prompt with AI</>}>
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
Generating...
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* Prompt Textarea */}
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-zinc-300">System Prompt *</label>
|
||||
<span class="text-xs text-zinc-500">
|
||||
{prompt().length.toLocaleString()} / {MAX_PROMPT_LENGTH.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={prompt()}
|
||||
onInput={(e) => {
|
||||
const value = e.currentTarget.value
|
||||
if (value.length <= MAX_PROMPT_LENGTH) {
|
||||
setPrompt(value)
|
||||
}
|
||||
}}
|
||||
placeholder="Enter the system prompt that defines this agent's behavior, expertise, and guidelines..."
|
||||
rows={12}
|
||||
class="w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500 transition-colors resize-none font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex items-center justify-end gap-3 p-4 border-t border-zinc-700/50 bg-zinc-800/30">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
class="px-4 py-2 text-zinc-400 hover:text-white hover:bg-zinc-700/50 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving() || !name().trim() || !prompt().trim()}
|
||||
class="px-4 py-2 bg-indigo-500 hover:bg-indigo-400 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Show when={isSaving()} fallback={<>Save Agent</>}>
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
Saving...
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgentCreatorDialog
|
||||
148
packages/ui/src/components/agent-selector.tsx
Normal file
148
packages/ui/src/components/agent-selector.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Select } from "@kobalte/core/select"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||
import { ChevronDown, Plus } from "lucide-solid"
|
||||
import type { Agent } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import AgentCreatorDialog from "./agent-creator-dialog"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
interface AgentSelectorProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
currentAgent: string
|
||||
onAgentChange: (agent: string) => Promise<void>
|
||||
}
|
||||
|
||||
export default function AgentSelector(props: AgentSelectorProps) {
|
||||
const instanceAgents = () => agents().get(props.instanceId) || []
|
||||
const [showCreator, setShowCreator] = createSignal(false)
|
||||
|
||||
const session = createMemo(() => {
|
||||
const instanceSessions = sessions().get(props.instanceId)
|
||||
return instanceSessions?.get(props.sessionId)
|
||||
})
|
||||
|
||||
const isChildSession = createMemo(() => {
|
||||
return session()?.parentId !== null && session()?.parentId !== undefined
|
||||
})
|
||||
|
||||
const availableAgents = createMemo(() => {
|
||||
const allAgents = instanceAgents()
|
||||
if (isChildSession()) {
|
||||
return allAgents
|
||||
}
|
||||
|
||||
const filtered = allAgents.filter((agent) => agent.mode !== "subagent")
|
||||
|
||||
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
|
||||
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
|
||||
return [currentAgent, ...filtered]
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const list = availableAgents()
|
||||
if (list.length === 0) return
|
||||
if (!list.some((agent) => agent.name === props.currentAgent)) {
|
||||
void props.onAgentChange(list[0].name)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceAgents().length === 0) {
|
||||
fetchAgents(props.instanceId).catch((error) => log.error("Failed to fetch agents", error))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const handleChange = async (value: Agent | null) => {
|
||||
if (value && value.name !== props.currentAgent) {
|
||||
await props.onAgentChange(value.name)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="sidebar-selector flex items-center gap-1">
|
||||
<Select
|
||||
value={availableAgents().find((a) => a.name === props.currentAgent)}
|
||||
onChange={handleChange}
|
||||
options={availableAgents()}
|
||||
optionValue="name"
|
||||
optionTextValue="name"
|
||||
placeholder="Select agent..."
|
||||
itemComponent={(itemProps) => (
|
||||
<Select.Item
|
||||
item={itemProps.item}
|
||||
class="selector-option"
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
|
||||
<span>{itemProps.item.rawValue.name}</span>
|
||||
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
||||
<span class="neutral-badge">subagent</span>
|
||||
</Show>
|
||||
<Show when={itemProps.item.rawValue.mode === "custom"}>
|
||||
<span class="text-[9px] px-1.5 py-0.5 bg-indigo-500/20 text-indigo-400 rounded-full font-medium">custom</span>
|
||||
</Show>
|
||||
</Select.ItemLabel>
|
||||
<Show when={itemProps.item.rawValue.description}>
|
||||
<Select.ItemDescription class="selector-option-description">
|
||||
{itemProps.item.rawValue.description.length > 50
|
||||
? itemProps.item.rawValue.description.slice(0, 50) + "..."
|
||||
: itemProps.item.rawValue.description}
|
||||
</Select.ItemDescription>
|
||||
</Show>
|
||||
</div>
|
||||
</Select.Item>
|
||||
)}
|
||||
>
|
||||
<Select.Trigger
|
||||
data-agent-selector
|
||||
class="selector-trigger"
|
||||
>
|
||||
<Select.Value<Agent>>
|
||||
{(state) => (
|
||||
<div class="selector-trigger-label">
|
||||
<span class="selector-trigger-primary">
|
||||
Agent: {state.selectedOption()?.name ?? "None"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Select.Value>
|
||||
<Select.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
|
||||
<Select.Listbox class="selector-listbox" />
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
|
||||
{/* Add Agent Button */}
|
||||
<button
|
||||
onClick={() => setShowCreator(true)}
|
||||
class="p-1.5 text-zinc-500 hover:text-indigo-400 hover:bg-indigo-500/10 rounded-lg transition-all shrink-0"
|
||||
title="Create custom agent"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Agent Creator Dialog */}
|
||||
<AgentCreatorDialog
|
||||
instanceId={props.instanceId}
|
||||
open={showCreator()}
|
||||
onClose={() => setShowCreator(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
132
packages/ui/src/components/alert-dialog.tsx
Normal file
132
packages/ui/src/components/alert-dialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Component, Show, createEffect } from "solid-js"
|
||||
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
|
||||
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
|
||||
|
||||
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
|
||||
info: {
|
||||
badgeBg: "var(--badge-neutral-bg)",
|
||||
badgeBorder: "var(--border-base)",
|
||||
badgeText: "var(--accent-primary)",
|
||||
symbol: "i",
|
||||
fallbackTitle: "Heads up",
|
||||
},
|
||||
warning: {
|
||||
badgeBg: "rgba(255, 152, 0, 0.14)",
|
||||
badgeBorder: "var(--status-warning)",
|
||||
badgeText: "var(--status-warning)",
|
||||
symbol: "!",
|
||||
fallbackTitle: "Please review",
|
||||
},
|
||||
error: {
|
||||
badgeBg: "var(--danger-soft-bg)",
|
||||
badgeBorder: "var(--status-error)",
|
||||
badgeText: "var(--status-error)",
|
||||
symbol: "!",
|
||||
fallbackTitle: "Something went wrong",
|
||||
},
|
||||
}
|
||||
|
||||
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
|
||||
const current = payload ?? alertDialogState()
|
||||
if (current?.type === "confirm") {
|
||||
if (confirmed) {
|
||||
current.onConfirm?.()
|
||||
} else {
|
||||
current.onCancel?.()
|
||||
}
|
||||
current.resolve?.(confirmed)
|
||||
} else if (confirmed) {
|
||||
current?.onConfirm?.()
|
||||
}
|
||||
dismissAlertDialog()
|
||||
}
|
||||
|
||||
const AlertDialog: Component = () => {
|
||||
let primaryButtonRef: HTMLButtonElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (alertDialogState()) {
|
||||
queueMicrotask(() => {
|
||||
primaryButtonRef?.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={alertDialogState()} keyed>
|
||||
{(payload) => {
|
||||
const variant = payload.variant ?? "info"
|
||||
const accent = variantAccent[variant]
|
||||
const title = payload.title || accent.fallbackTitle
|
||||
const isConfirm = payload.type === "confirm"
|
||||
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
|
||||
const cancelLabel = payload.cancelLabel || "Cancel"
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
modal
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
dismiss(false, payload)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
style={{
|
||||
"background-color": accent.badgeBg,
|
||||
"border-color": accent.badgeBorder,
|
||||
color: accent.badgeText,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{accent.symbol}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
||||
{payload.message}
|
||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
{isConfirm && (
|
||||
<button
|
||||
type="button"
|
||||
class="button-secondary"
|
||||
onClick={() => dismiss(false, payload)}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary"
|
||||
ref={(el) => {
|
||||
primaryButtonRef = el
|
||||
}}
|
||||
onClick={() => dismiss(true, payload)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlertDialog
|
||||
27
packages/ui/src/components/attachment-chip.tsx
Normal file
27
packages/ui/src/components/attachment-chip.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Component } from "solid-js"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
|
||||
interface AttachmentChipProps {
|
||||
attachment: Attachment
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class="attachment-chip"
|
||||
title={props.attachment.source.type === "file" ? props.attachment.source.path : undefined}
|
||||
>
|
||||
<span class="font-mono">{props.attachment.display}</span>
|
||||
<button
|
||||
onClick={props.onRemove}
|
||||
class="attachment-remove"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentChip
|
||||
320
packages/ui/src/components/chat/minimal-chat.tsx
Normal file
320
packages/ui/src/components/chat/minimal-chat.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* MINIMAL CHAT BYPASS
|
||||
*
|
||||
* This is a stripped-down chat component that:
|
||||
* - Uses minimal store access (just for model/session info)
|
||||
* - Makes direct fetch calls
|
||||
* - Has NO complex effects/memos
|
||||
* - Renders messages as a simple list
|
||||
*
|
||||
* Purpose: Test if the UI responsiveness issue is in the
|
||||
* reactivity system or something else entirely.
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show, onMount } from "solid-js"
|
||||
import { sessions } from "@/stores/session-state"
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
timestamp: number
|
||||
status: "sending" | "streaming" | "complete" | "error"
|
||||
}
|
||||
|
||||
interface MinimalChatProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export function MinimalChat(props: MinimalChatProps) {
|
||||
const [messages, setMessages] = createSignal<Message[]>([])
|
||||
const [inputText, setInputText] = createSignal("")
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [currentModel, setCurrentModel] = createSignal("minimax-m1")
|
||||
|
||||
let scrollContainer: HTMLDivElement | undefined
|
||||
let inputRef: HTMLTextAreaElement | undefined
|
||||
|
||||
function generateId() {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
// Get model from session on mount (one-time read, no reactive dependency)
|
||||
onMount(() => {
|
||||
try {
|
||||
const instanceSessions = sessions().get(props.instanceId)
|
||||
const session = instanceSessions?.get(props.sessionId)
|
||||
if (session?.model?.modelId) {
|
||||
setCurrentModel(session.model.modelId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Could not get session model, using default", e)
|
||||
}
|
||||
inputRef?.focus()
|
||||
})
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputText().trim()
|
||||
if (!text || isLoading()) return
|
||||
|
||||
setError(null)
|
||||
setInputText("")
|
||||
setIsLoading(true)
|
||||
|
||||
const userMessage: Message = {
|
||||
id: generateId(),
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
status: "complete"
|
||||
}
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: generateId(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: Date.now(),
|
||||
status: "streaming"
|
||||
}
|
||||
|
||||
// Add messages to state
|
||||
setMessages(prev => [...prev, userMessage, assistantMessage])
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
// Direct fetch with streaming
|
||||
const response = await fetch("/api/ollama/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: currentModel(),
|
||||
messages: [
|
||||
...messages().filter(m => m.status === "complete").map(m => ({ role: m.role, content: m.content })),
|
||||
{ role: "user", content: text }
|
||||
],
|
||||
stream: true
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error("No response body")
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let fullContent = ""
|
||||
let buffer = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith("data:")) continue
|
||||
const data = trimmed.slice(5).trim()
|
||||
if (!data || data === "[DONE]") continue
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(data)
|
||||
const delta = chunk?.message?.content
|
||||
if (typeof delta === "string" && delta.length > 0) {
|
||||
fullContent += delta
|
||||
|
||||
// Update assistant message content (simple state update)
|
||||
setMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: fullContent }
|
||||
: m
|
||||
)
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as complete
|
||||
setMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, status: "complete" }
|
||||
: m
|
||||
)
|
||||
)
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : "Unknown error"
|
||||
setError(errorMsg)
|
||||
|
||||
// Mark as error
|
||||
setMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, status: "error", content: `Error: ${errorMsg}` }
|
||||
: m
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
height: "100%",
|
||||
background: "#0a0a0b",
|
||||
color: "#e4e4e7"
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: "16px",
|
||||
"border-bottom": "1px solid #27272a",
|
||||
background: "#18181b"
|
||||
}}>
|
||||
<h2 style={{ margin: 0, "font-size": "16px" }}>
|
||||
🧪 Minimal Chat (Bypass Mode)
|
||||
</h2>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#71717a" }}>
|
||||
Model: {currentModel()} | Testing UI responsiveness
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollContainer}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
padding: "16px"
|
||||
}}
|
||||
>
|
||||
<Show when={messages().length === 0}>
|
||||
<div style={{
|
||||
"text-align": "center",
|
||||
color: "#71717a",
|
||||
padding: "48px"
|
||||
}}>
|
||||
Send a message to test UI responsiveness
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<div style={{
|
||||
"margin-bottom": "16px",
|
||||
padding: "12px",
|
||||
background: message.role === "user" ? "#27272a" : "#18181b",
|
||||
"border-radius": "8px",
|
||||
"border-left": message.role === "assistant" ? "3px solid #6366f1" : "none"
|
||||
}}>
|
||||
<div style={{
|
||||
"font-size": "11px",
|
||||
color: "#71717a",
|
||||
"margin-bottom": "8px"
|
||||
}}>
|
||||
{message.role === "user" ? "You" : "Assistant"}
|
||||
{message.status === "streaming" && " (streaming...)"}
|
||||
{message.status === "error" && " (error)"}
|
||||
</div>
|
||||
<div style={{
|
||||
"white-space": "pre-wrap",
|
||||
"word-break": "break-word",
|
||||
"font-size": "14px",
|
||||
"line-height": "1.6"
|
||||
}}>
|
||||
{message.content || (message.status === "streaming" ? "▋" : "")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
<Show when={error()}>
|
||||
<div style={{
|
||||
padding: "8px 16px",
|
||||
background: "#7f1d1d",
|
||||
color: "#fecaca",
|
||||
"font-size": "12px"
|
||||
}}>
|
||||
Error: {error()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Input area */}
|
||||
<div style={{
|
||||
padding: "16px",
|
||||
"border-top": "1px solid #27272a",
|
||||
background: "#18181b"
|
||||
}}>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputText()}
|
||||
onInput={(e) => setInputText(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message... (Enter to send)"
|
||||
disabled={isLoading()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
background: "#27272a",
|
||||
border: "1px solid #3f3f46",
|
||||
"border-radius": "8px",
|
||||
color: "#e4e4e7",
|
||||
resize: "none",
|
||||
"font-size": "14px",
|
||||
"min-height": "48px",
|
||||
"max-height": "150px"
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={isLoading() || !inputText().trim()}
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
background: isLoading() ? "#3f3f46" : "#6366f1",
|
||||
color: "white",
|
||||
border: "none",
|
||||
"border-radius": "8px",
|
||||
cursor: isLoading() ? "wait" : "pointer",
|
||||
"font-weight": "600"
|
||||
}}
|
||||
>
|
||||
{isLoading() ? "..." : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MinimalChat
|
||||
334
packages/ui/src/components/chat/multi-task-chat-backup.tsx
Normal file
334
packages/ui/src/components/chat/multi-task-chat-backup.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { createSignal, createMemo, Show, For, onMount } from "solid-js";
|
||||
import { sessions, withSession, setActiveSession } from "@/stores/session-state";
|
||||
import { instances } from "@/stores/instances";
|
||||
import { sendMessage } from "@/stores/session-actions";
|
||||
import { addTask, setActiveTask } from "@/stores/task-actions";
|
||||
import { messageStoreBus } from "@/stores/message-v2/bus";
|
||||
import MessageBlockList from "@/components/message-block-list";
|
||||
import {
|
||||
Command,
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
MoreHorizontal,
|
||||
PanelRight,
|
||||
ListTodo,
|
||||
AtSign,
|
||||
Hash,
|
||||
Mic,
|
||||
ArrowUp,
|
||||
Terminal,
|
||||
FileCode2,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Code2,
|
||||
} from "lucide-solid";
|
||||
import type { Task, TaskStatus } from "@/types/session";
|
||||
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
|
||||
|
||||
interface MultiTaskChatProps {
|
||||
instanceId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
type PanelView = "tasks" | "active";
|
||||
|
||||
export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
const session = () => {
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
return instanceSessions?.get(props.sessionId);
|
||||
};
|
||||
const selectedTaskId = () => session()?.activeTaskId || null;
|
||||
const setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
|
||||
const [isCreatingTask, setIsCreatingTask] = createSignal(false);
|
||||
const [chatInput, setChatInput] = createSignal("");
|
||||
const [isSending, setIsSending] = createSignal(false);
|
||||
let scrollContainer: HTMLDivElement | undefined;
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
|
||||
|
||||
// Message store integration for chat display
|
||||
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
|
||||
const messageIds = () => messageStore().getSessionMessageIds(props.sessionId);
|
||||
const lastAssistantIndex = () => {
|
||||
const ids = messageIds();
|
||||
const store = messageStore();
|
||||
for (let i = ids.length - 1; i >= 0; i--) {
|
||||
const msg = store.getMessage(ids[i]);
|
||||
if (msg?.role === "assistant") return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
// Handle message sending with comprehensive error handling
|
||||
const handleSendMessage = async () => {
|
||||
const message = chatInput().trim();
|
||||
if (!message || isSending()) return;
|
||||
|
||||
const currentInstance = instances().get(props.instanceId);
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
const currentSession = instanceSessions?.get(props.sessionId);
|
||||
const sessionTasks = currentSession?.tasks || [];
|
||||
const selectedTask = sessionTasks.find((task: Task) => task.id === selectedTaskId());
|
||||
|
||||
if (!currentInstance || !currentSession) {
|
||||
console.error("[MultiTaskChat] Instance or session not available");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
const messageId = await sendMessage(
|
||||
props.instanceId,
|
||||
props.sessionId,
|
||||
message,
|
||||
[], // No attachments for now
|
||||
selectedTask?.id
|
||||
);
|
||||
|
||||
// Clear input after successful send
|
||||
setChatInput("");
|
||||
console.log("[MultiTaskChat] Message sent successfully:", messageId);
|
||||
} catch (error) {
|
||||
console.error("[MultiTaskChat] Failed to send message:", error);
|
||||
// TODO: Show toast notification to user
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard shortcuts (Cmd/Ctrl+Enter to send)
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle artifact opening via code streamer
|
||||
const handleArtifactOpen = (artifact: any) => {
|
||||
console.log("[MultiTaskChat] Opening artifact:", artifact);
|
||||
// TODO: Implement code streamer integration
|
||||
// For now, we'll log artifact and show a placeholder message
|
||||
console.log(`[MultiTaskChat] Would open ${artifact.name} (${artifact.type})`);
|
||||
// TODO: Show toast notification to user
|
||||
};
|
||||
|
||||
const tasks = () => {
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
const currentSession = instanceSessions?.get(props.sessionId);
|
||||
return currentSession?.tasks || [];
|
||||
};
|
||||
|
||||
const handleAddTask = () => {
|
||||
const taskTitle = `Task ${tasks().length + 1}`;
|
||||
addTask(props.instanceId, props.sessionId, taskTitle);
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
};
|
||||
|
||||
const selectedTask = () => {
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
const currentSession = instanceSessions?.get(props.sessionId);
|
||||
const sessionTasks = currentSession?.tasks || [];
|
||||
return sessionTasks.find(task => task.id === selectedTaskId());
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="h-full flex flex-col bg-[#0a0a0b]">
|
||||
{/* Header */}
|
||||
<div class="h-12 px-4 flex items-center justify-between bg-zinc-900/40 backdrop-blur-md border-b border-white/5 relative z-20">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Show when={!selectedTaskId()} fallback={
|
||||
<div class="flex items-center bg-indigo-500/10 border border-indigo-500/20 rounded-md px-2 py-1 shadow-[0_0_15px_rgba(99,102,241,0.1)] transition-all hover:bg-indigo-500/15">
|
||||
<span class="text-[10px] font-black text-indigo-400 mr-2 tracking-tighter uppercase">MULTIX</span>
|
||||
<div class="bg-indigo-500 rounded-sm w-3.5 h-3.5 flex items-center justify-center p-[1px]">
|
||||
<div class="flex flex-col space-y-[1px] w-full items-center">
|
||||
<div class="flex space-x-[1px]">
|
||||
<div class="w-0.5 h-0.5 bg-black rounded-full" />
|
||||
<div class="w-0.5 h-0.5 bg-black rounded-full" />
|
||||
</div>
|
||||
<div class="w-full h-[0.5px] bg-black rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(null)}
|
||||
class="flex items-center space-x-2 text-zinc-400 hover:text-white transition-all duration-200 group active:scale-95"
|
||||
>
|
||||
<ChevronRight size={16} class="rotate-180 group-hover:-translate-x-0.5 transition-transform" />
|
||||
<span class="text-xs font-semibold tracking-tight">Pipeline</span>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedTaskId()}>
|
||||
<div class="flex items-center space-x-1.5 px-2 py-1 bg-zinc-800/50 rounded-lg border border-white/5">
|
||||
<ListTodo size={14} class="text-indigo-400" />
|
||||
<span class="text-[10px] font-bold text-zinc-400">{tasks().length}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="p-1.5 text-zinc-500 hover:text-zinc-200 transition-colors hover:bg-zinc-800/50 rounded-md active:scale-90">
|
||||
<Command size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(null)}
|
||||
class={`p-1.5 rounded-md transition-all duration-200 group ${
|
||||
selectedTaskId()
|
||||
? "bg-indigo-500/10 border-indigo-500/20 text-white"
|
||||
: "text-zinc-500 hover:text-white hover:bg-zinc-800/50"
|
||||
}`}
|
||||
>
|
||||
<PanelRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 relative overflow-hidden flex flex-col">
|
||||
<Show when={!selectedTaskId()}>
|
||||
{/* TASK LIST VIEW - CODEX 5.1 Styled */}
|
||||
<div class="flex-1 flex flex-col bg-zinc-900/20 animate-in fade-in slide-in-from-left-4 duration-300">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-xl font-bold text-zinc-100 tracking-tight">Project Pipeline</h2>
|
||||
<p class="text-xs text-zinc-500">Manage and orchestrate agentic tasks</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddTask}
|
||||
class="px-3 py-1.5 bg-indigo-500 text-white rounded-xl flex items-center justify-center hover:bg-indigo-600 active:scale-[0.97] transition-all shadow-lg shadow-indigo-500/20 font-bold text-xs"
|
||||
>
|
||||
<Plus size={14} class="mr-2" strokeWidth={3} />
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div class="space-y-3">
|
||||
<For each={tasks()}>
|
||||
{(task) => (
|
||||
<div
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
class={`p-4 rounded-xl border transition-all cursor-pointer ${
|
||||
task.id === selectedTaskId()
|
||||
? "bg-indigo-500/10 border-indigo-500/20"
|
||||
: "bg-zinc-800/40 border-white/5 hover:border-indigo-500/20 hover:bg-indigo-500/5"
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
||||
task.status === "completed"
|
||||
? "bg-emerald-500/10"
|
||||
: task.status === "in-progress"
|
||||
? "bg-amber-500/10"
|
||||
: "bg-zinc-700/50"
|
||||
}`}>
|
||||
{task.status === "completed" ? (
|
||||
<CheckCircle2 size={16} class="text-emerald-500" />
|
||||
) : task.status === "in-progress" ? (
|
||||
<Loader2 size={16} class="text-amber-500 animate-spin" />
|
||||
) : (
|
||||
<AlertCircle size={16} class="text-zinc-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-white font-semibold text-sm">{task.title}</h3>
|
||||
<p class="text-zinc-400 text-xs">{formatTimestamp(task.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} class="text-zinc-600" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedTaskId()}>
|
||||
{/* TASK CHAT VIEW - When task is selected */}
|
||||
<div class="flex-1 flex flex-col relative animate-in fade-in slide-in-from-right-4 duration-300">
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 pb-32">
|
||||
<MessageBlockList
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={messageStore}
|
||||
messageIds={messageIds}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
scrollContainer={() => scrollContainer}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
showThinking={() => true}
|
||||
thinkingDefaultExpanded={() => true}
|
||||
showUsageMetrics={() => true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CODEX 5.1 Chat Input Area */}
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-[#0a0a0b] via-[#0a0a0b]/95 to-transparent backdrop-blur-md">
|
||||
<div class="bg-zinc-900/80 border border-white/10 rounded-2xl shadow-2xl p-4 space-y-4 transition-all focus-within:border-indigo-500/40 focus-within:ring-4 focus-within:ring-indigo-500/5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-5 h-5 rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
||||
<AtSign size={10} class="text-white" />
|
||||
</div>
|
||||
<span class="text-[11px] font-bold text-zinc-400 tracking-tight">TASK ASSISTANT</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="px-1.5 py-0.5 bg-zinc-800 text-[9px] font-black text-zinc-500 uppercase tracking-tighter rounded border border-white/5">
|
||||
{selectedTask()?.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="relative">
|
||||
<textarea
|
||||
value={chatInput()}
|
||||
onInput={(e) => setChatInput(e.currentTarget.value)}
|
||||
placeholder="Message assistant about this task..."
|
||||
class="w-full bg-transparent border-none focus:ring-0 text-sm text-zinc-200 placeholder-zinc-600 resize-none min-h-[44px] max-h-32 custom-scrollbar leading-relaxed disabled:opacity-50"
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isSending()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-3 border-t border-white/5">
|
||||
<div class="flex items-center space-x-4 text-zinc-500">
|
||||
<button class="hover:text-indigo-400 transition-colors active:scale-90"><Hash size={16} /></button>
|
||||
<button class="hover:text-indigo-400 transition-colors active:scale-90"><Mic size={16} /></button>
|
||||
<div class="w-px h-4 bg-zinc-800" />
|
||||
<span class="text-[10px] font-bold text-zinc-600 tracking-widest">CMD + ENTER</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!chatInput().trim() || isSending()}
|
||||
class="px-4 py-1.5 bg-zinc-100 text-zinc-950 rounded-xl flex items-center justify-center hover:bg-white active:scale-[0.97] transition-all shadow-lg shadow-white/5 font-bold text-xs disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-100"
|
||||
>
|
||||
{isSending() ? (
|
||||
<>
|
||||
<div class="w-3 h-3 border-2 border-zinc-950 border-t-transparent rounded-full animate-spin mr-2" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Execute
|
||||
<ArrowUp size={14} class="ml-2" strokeWidth={3} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1024
packages/ui/src/components/chat/multi-task-chat.tsx
Normal file
1024
packages/ui/src/components/chat/multi-task-chat.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1007
packages/ui/src/components/chat/multix-chat-v2.tsx
Normal file
1007
packages/ui/src/components/chat/multix-chat-v2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* SimpleMessageBlock - Polling-based message renderer
|
||||
*
|
||||
* Updates content via interval, not reactive cascade.
|
||||
* This prevents the freeze during streaming.
|
||||
*/
|
||||
|
||||
import { createSignal, Show, onMount, onCleanup } from "solid-js";
|
||||
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
|
||||
|
||||
interface SimpleMessageBlockProps {
|
||||
messageId: string;
|
||||
store: () => InstanceMessageStore;
|
||||
}
|
||||
|
||||
export function SimpleMessageBlock(props: SimpleMessageBlockProps) {
|
||||
const [content, setContent] = createSignal("");
|
||||
const [isStreaming, setIsStreaming] = createSignal(false);
|
||||
const [isUser, setIsUser] = createSignal(false);
|
||||
const [timestamp, setTimestamp] = createSignal("");
|
||||
const [tokenCount, setTokenCount] = createSignal(0);
|
||||
|
||||
function updateFromStore() {
|
||||
const message = props.store().getMessage(props.messageId);
|
||||
if (!message) return;
|
||||
|
||||
setIsUser(message.role === "user");
|
||||
setIsStreaming(message.status === "streaming" || message.status === "sending");
|
||||
|
||||
// Extract text content from parts
|
||||
const parts = message.parts || {};
|
||||
let text = "";
|
||||
for (const partId of Object.keys(parts)) {
|
||||
const partRecord = parts[partId];
|
||||
if (partRecord?.data?.type === "text") {
|
||||
text = (partRecord.data as any).text || "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct content
|
||||
if (!text && (message as any).content) {
|
||||
text = (message as any).content;
|
||||
}
|
||||
|
||||
setContent(text);
|
||||
setTokenCount(Math.ceil(text.length / 4));
|
||||
|
||||
// Note: MessageRecord doesn't have time property, skip timestamp
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateFromStore();
|
||||
|
||||
// Poll for updates during streaming (every 100ms)
|
||||
const interval = setInterval(() => {
|
||||
const msg = props.store().getMessage(props.messageId);
|
||||
if (msg?.status === "streaming" || msg?.status === "sending" || isStreaming()) {
|
||||
updateFromStore();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
onCleanup(() => clearInterval(interval));
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`message-anchor-${props.messageId}`}
|
||||
class={`rounded-xl p-4 transition-all min-w-0 overflow-hidden ${isUser()
|
||||
? "bg-zinc-800/50 border border-zinc-700/50"
|
||||
: "bg-zinc-900/50 border border-indigo-500/20"
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class={`text-[10px] font-bold uppercase tracking-wide ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
|
||||
{isUser() ? "You" : "Assistant"}
|
||||
</div>
|
||||
<Show when={isStreaming()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1 text-[9px] text-violet-400">
|
||||
<div class="w-1.5 h-1.5 bg-violet-400 rounded-full animate-pulse" />
|
||||
<span>Thinking...</span>
|
||||
</div>
|
||||
<span class="text-[9px] font-mono text-zinc-500 bg-zinc-800/50 px-1 rounded">
|
||||
{tokenCount()} tks
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-[9px] text-zinc-600">{timestamp()}</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm text-zinc-100 leading-relaxed whitespace-pre-wrap break-words overflow-hidden"
|
||||
style={{ "word-break": "break-word", "overflow-wrap": "anywhere" }}
|
||||
>
|
||||
{content() || (isStreaming() ? "▋" : "")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
packages/ui/src/components/chat/multix-v2/exports.ts
Normal file
8
packages/ui/src/components/chat/multix-v2/exports.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Re-export all MultiX v2 components
|
||||
export { default as MultiXV2 } from "./index";
|
||||
export { SimpleMessageBlock } from "./core/SimpleMessageBlock";
|
||||
export { PipelineView } from "./features/PipelineView";
|
||||
export { MessageNavSidebar } from "./features/MessageNavSidebar";
|
||||
export { LiteAgentSelector } from "./features/LiteAgentSelector";
|
||||
export { LiteModelSelector } from "./features/LiteModelSelector";
|
||||
export { enhancePrompt, getQuickTips } from "./features/PromptEnhancer";
|
||||
@@ -0,0 +1,637 @@
|
||||
/**
|
||||
* LiteAgentSelector - Non-reactive agent selector for MultiX v2
|
||||
*
|
||||
* Uses polling instead of reactive subscriptions to prevent cascading updates.
|
||||
* Includes AI Agent Generator feature.
|
||||
*/
|
||||
|
||||
import { createSignal, For, onMount, onCleanup, Show } from "solid-js";
|
||||
import { agents, setAgents, providers } from "@/stores/session-state";
|
||||
import { fetchAgents } from "@/stores/session-api";
|
||||
import { updateInstanceConfig } from "@/stores/instance-config";
|
||||
import { toast } from "solid-toast";
|
||||
import { ChevronDown, Bot, Plus, Sparkles, Loader2, Save, X, RefreshCw } from "lucide-solid";
|
||||
import { serverApi } from "@/lib/api-client";
|
||||
|
||||
interface LiteAgentSelectorProps {
|
||||
instanceId: string;
|
||||
sessionId: string;
|
||||
currentAgent: string;
|
||||
onAgentChange: (agent: string) => void;
|
||||
}
|
||||
|
||||
interface AgentInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
export function LiteAgentSelector(props: LiteAgentSelectorProps) {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [agentList, setAgentList] = createSignal<AgentInfo[]>([]);
|
||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||
const [showGenerator, setShowGenerator] = createSignal(false);
|
||||
const [generatorInput, setGeneratorInput] = createSignal("");
|
||||
const [generatedAgent, setGeneratedAgent] = createSignal<AgentInfo | null>(null);
|
||||
const [isSaving, setIsSaving] = createSignal(false);
|
||||
const [selectedModel, setSelectedModel] = createSignal("glm-4");
|
||||
const [availableModels, setAvailableModels] = createSignal<{ id: string, name: string, provider: string }[]>([]);
|
||||
|
||||
// Load agents once on mount, then poll
|
||||
function loadAgents() {
|
||||
try {
|
||||
const instanceAgents = agents().get(props.instanceId) || [];
|
||||
const nonSubagents = instanceAgents.filter((a: any) => a.mode !== "subagent");
|
||||
setAgentList(nonSubagents.map((a: any) => ({
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
systemPrompt: a.systemPrompt
|
||||
})));
|
||||
} catch (e) {
|
||||
console.warn("Failed to load agents", e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadAgents();
|
||||
|
||||
// Populate available models
|
||||
const allProviders = providers().get(props.instanceId) || [];
|
||||
const models: { id: string, name: string, provider: string }[] = [];
|
||||
allProviders.forEach(p => {
|
||||
p.models.forEach(m => {
|
||||
models.push({ id: m.id, name: m.name || m.id, provider: p.id });
|
||||
});
|
||||
});
|
||||
|
||||
// Add defaults if none found
|
||||
if (models.length === 0) {
|
||||
models.push({ id: "glm-4", name: "GLM-4 (Z.AI)", provider: "zai" });
|
||||
models.push({ id: "qwen-coder-plus-latest", name: "Qwen Coder Plus (Zen)", provider: "opencode-zen" });
|
||||
models.push({ id: "minimax-m1", name: "MiniMax M1 (Ollama)", provider: "ollama" });
|
||||
}
|
||||
setAvailableModels(models);
|
||||
|
||||
// Poll every 5 seconds (agents don't change often)
|
||||
const interval = setInterval(loadAgents, 5000);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
});
|
||||
|
||||
const handleSelect = (agentName: string) => {
|
||||
props.onAgentChange(agentName);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleGenerateAgent = async () => {
|
||||
const input = generatorInput().trim();
|
||||
if (!input || isGenerating()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
const modelInfo = availableModels().find(m => m.id === selectedModel());
|
||||
|
||||
// Normalize provider ID - handle variants like "ollama-cloud" -> "ollama"
|
||||
let provider = modelInfo?.provider || "zai";
|
||||
if (provider.includes("ollama")) provider = "ollama";
|
||||
if (provider.includes("zen")) provider = "opencode-zen";
|
||||
|
||||
console.log(`[AgentGenerator] Using provider: ${provider}, model: ${selectedModel()}`);
|
||||
|
||||
// AI generation prompt - focused on unique, creative output
|
||||
const generationPrompt = `Create a unique AI coding assistant agent based on: "${input}"
|
||||
|
||||
RULES:
|
||||
1. NAME: Create a catchy, memorable 1-3 word name (e.g., "Neon Architect", "Logic Ghost", "Cortex", "Syntax Specter"). BE CREATIVE!
|
||||
2. DESCRIPTION: One powerful sentence about their unique paradigm or specialty.
|
||||
3. SYSTEM PROMPT: Write a 400+ word deep-dive into their psyche, expertise, and operational style.
|
||||
- DO NOT be generic.
|
||||
- Give them a clear VOICE and philosophy.
|
||||
- Professional, yet distinct.
|
||||
- Mention specific methodologies they favor.
|
||||
- Explain how they view the relationship between code and problem-solving.
|
||||
|
||||
IMPORTANT: Return ONLY valid JSON in this format:
|
||||
{"name": "...", "description": "...", "systemPrompt": "..."}`;
|
||||
|
||||
const endpoints: Record<string, string> = {
|
||||
"zai": "/api/zai/chat",
|
||||
"opencode-zen": "/api/opencode-zen/chat",
|
||||
"ollama": "/api/ollama/chat"
|
||||
};
|
||||
|
||||
// Timeout wrapper for fetch with 60 second limit
|
||||
const fetchWithTimeout = async (url: string, options: RequestInit, timeoutMs: number = 60000) => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const tryEndpoint = async (prov: string, model: string) => {
|
||||
try {
|
||||
console.log(`[AgentGenerator] Attempting generation with ${prov}/${model}...`);
|
||||
|
||||
// Use absolute URL from serverApi to avoid port issues
|
||||
const baseUrl = serverApi.getApiBase();
|
||||
const endpoint = `${baseUrl}${endpoints[prov]}`;
|
||||
|
||||
if (!endpoints[prov]) {
|
||||
console.warn(`[AgentGenerator] No endpoint configured for provider: ${prov}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: [{ role: "user", content: generationPrompt }],
|
||||
stream: false
|
||||
})
|
||||
}, 60000); // 60 second timeout
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const content = prov === "zai" || prov === "opencode-zen"
|
||||
? (data?.choices?.[0]?.message?.content || data?.message?.content || "")
|
||||
: (data?.message?.content || "");
|
||||
|
||||
console.log(`[AgentGenerator] Received content from ${prov}:`, content.substring(0, 100) + "...");
|
||||
|
||||
const result = tryParseAgentJson(content, input);
|
||||
if (result) return result;
|
||||
|
||||
console.warn(`[AgentGenerator] Failed to parse JSON from ${prov} response`);
|
||||
} else {
|
||||
const errText = await response.text();
|
||||
console.error(`[AgentGenerator] Endpoint ${prov} returned ${response.status}:`, errText);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
console.warn(`[AgentGenerator] Request to ${prov} timed out after 60s`);
|
||||
toast.error(`Generation timed out. Try a faster model.`, { duration: 5000 });
|
||||
} else {
|
||||
console.warn(`[AgentGenerator] Endpoint ${prov} failed:`, e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 1. Try selected model
|
||||
let parsed = await tryEndpoint(provider, selectedModel());
|
||||
|
||||
// 2. Fallbacks if selected fails - try faster models
|
||||
if (!parsed) {
|
||||
console.log("[AgentGenerator] Selected model failed, trying fallbacks...");
|
||||
const fallbacks = [
|
||||
{ prov: "ollama", model: "qwen3:8b" },
|
||||
{ prov: "opencode-zen", model: "qwen-coder-plus-latest" },
|
||||
{ prov: "zai", model: "glm-4" },
|
||||
].filter(f => f.model !== selectedModel());
|
||||
|
||||
for (const f of fallbacks) {
|
||||
parsed = await tryEndpoint(f.prov, f.model);
|
||||
if (parsed) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed) {
|
||||
setGeneratedAgent(parsed);
|
||||
toast.success("Agent generated!", { icon: "🎉", duration: 3000 });
|
||||
} else {
|
||||
console.warn("[AgentGenerator] All AI endpoints failed, using smart fallback");
|
||||
setGeneratedAgent(generateSmartFallback(input));
|
||||
toast.success("Agent created (local fallback)", { duration: 3000 });
|
||||
}
|
||||
|
||||
setIsGenerating(false);
|
||||
};
|
||||
|
||||
// Try to parse JSON from AI response
|
||||
const tryParseAgentJson = (content: string, input: string): { name: string; description: string; systemPrompt: string } | null => {
|
||||
try {
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (parsed.name && parsed.systemPrompt && parsed.systemPrompt.length > 100) {
|
||||
return {
|
||||
name: parsed.name,
|
||||
description: parsed.description || input,
|
||||
systemPrompt: parsed.systemPrompt
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("JSON parse error:", e);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Generate a smart fallback that actually feels unique
|
||||
const generateSmartFallback = (input: string): { name: string; description: string; systemPrompt: string } => {
|
||||
const name = generateFallbackName(input);
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Create unique content based on input analysis
|
||||
const inputLower = input.toLowerCase();
|
||||
const isFrontend = /react|vue|angular|css|html|ui|frontend|web/.test(inputLower);
|
||||
const isBackend = /api|server|node|python|database|backend/.test(inputLower);
|
||||
const isFullStack = /full.?stack|complete|everything/.test(inputLower);
|
||||
const isAI = /ai|ml|machine|learning|neural|gpt|claude|llm/.test(inputLower);
|
||||
const isDevOps = /devops|docker|kubernetes|ci|cd|deploy/.test(inputLower);
|
||||
|
||||
let specialty = "general software development";
|
||||
let techStack = "JavaScript, TypeScript, Python";
|
||||
let uniqueTrait = "methodical approach to problem-solving";
|
||||
|
||||
if (isFrontend) {
|
||||
specialty = "frontend architecture and user experience";
|
||||
techStack = "React, Vue, TypeScript, CSS, Tailwind";
|
||||
uniqueTrait = "pixel-perfect attention to detail and smooth animations";
|
||||
} else if (isBackend) {
|
||||
specialty = "backend systems and API design";
|
||||
techStack = "Node.js, Python, PostgreSQL, Redis, GraphQL";
|
||||
uniqueTrait = "building scalable, fault-tolerant services";
|
||||
} else if (isFullStack) {
|
||||
specialty = "end-to-end application development";
|
||||
techStack = "React, Node.js, PostgreSQL, Docker, AWS";
|
||||
uniqueTrait = "seamless integration between frontend and backend";
|
||||
} else if (isAI) {
|
||||
specialty = "AI/ML integration and prompt engineering";
|
||||
techStack = "Python, LangChain, OpenAI, HuggingFace, Vector DBs";
|
||||
uniqueTrait = "crafting intelligent, context-aware AI solutions";
|
||||
} else if (isDevOps) {
|
||||
specialty = "infrastructure and deployment automation";
|
||||
techStack = "Docker, Kubernetes, Terraform, GitHub Actions, AWS";
|
||||
uniqueTrait = "zero-downtime deployments and infrastructure as code";
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description: `Expert in ${specialty} with ${uniqueTrait}`,
|
||||
systemPrompt: `You are ${name}, a senior software engineer with 10+ years of expertise in ${specialty}.
|
||||
|
||||
## Your Personality
|
||||
You are confident but humble, always explaining your reasoning clearly. You prefer elegant, maintainable solutions over clever hacks. When you don't know something, you say so honestly and suggest ways to find the answer.
|
||||
|
||||
## Technical Expertise
|
||||
Your primary stack: ${techStack}
|
||||
Your specialty: ${specialty}
|
||||
Your unique strength: ${uniqueTrait}
|
||||
|
||||
## How You Work
|
||||
1. **Understand First**: Before writing code, you analyze the existing codebase structure, patterns, and conventions
|
||||
2. **Plan Carefully**: You outline your approach before implementing, considering edge cases and potential issues
|
||||
3. **Code Quality**: Every line you write follows best practices - clean naming, proper error handling, comprehensive types
|
||||
4. **Test Thinking**: You consider how code will be tested, even if tests aren't explicitly requested
|
||||
5. **Documentation**: You add meaningful comments for complex logic, not obvious operations
|
||||
|
||||
## Code Standards You Follow
|
||||
- Use descriptive variable and function names that reveal intent
|
||||
- Keep functions small and focused (single responsibility)
|
||||
- Handle errors gracefully with informative messages
|
||||
- Prefer composition over inheritance
|
||||
- Write self-documenting code, supplement with comments only where needed
|
||||
- Always consider performance implications
|
||||
|
||||
## Communication Style
|
||||
- Be direct and actionable in your responses
|
||||
- When suggesting changes, explain WHY not just WHAT
|
||||
- If multiple approaches exist, briefly mention pros/cons
|
||||
- Celebrate good code when you see it
|
||||
- Provide constructive feedback on improvements
|
||||
|
||||
## Tool Usage
|
||||
- Use read_file to understand existing code before modifying
|
||||
- Use list_files to understand project structure
|
||||
- Use write_file to create or update files with complete, working code
|
||||
- Always verify syntax correctness before submitting
|
||||
|
||||
Built for: ${input}
|
||||
Session ID: ${timestamp}`
|
||||
};
|
||||
};
|
||||
|
||||
// Generate a professional fallback name from user input
|
||||
const generateFallbackName = (input: string): string => {
|
||||
// Extract key words and create a professional sounding name
|
||||
const words = input.toLowerCase().split(/\s+/).filter(w => w.length > 2);
|
||||
|
||||
// Common tech keywords to look for
|
||||
const keywords: Record<string, string> = {
|
||||
'typescript': 'TypeScript Pro',
|
||||
'javascript': 'JS Expert',
|
||||
'react': 'React Master',
|
||||
'python': 'Python Guru',
|
||||
'api': 'API Architect',
|
||||
'code': 'Code Expert',
|
||||
'full': 'Full Stack Pro',
|
||||
'frontend': 'Frontend Master',
|
||||
'backend': 'Backend Pro',
|
||||
'mcp': 'MCP Specialist',
|
||||
'agent': 'Smart Agent',
|
||||
'thinking': 'Deep Thinker',
|
||||
'claude': 'AI Assistant',
|
||||
'smart': 'Smart Coder',
|
||||
'fix': 'Bug Hunter',
|
||||
'test': 'Test Master',
|
||||
'debug': 'Debug Pro',
|
||||
'architect': 'Code Architect',
|
||||
'review': 'Code Reviewer'
|
||||
};
|
||||
|
||||
// Try to find a matching keyword
|
||||
for (const word of words) {
|
||||
for (const [key, name] of Object.entries(keywords)) {
|
||||
if (word.includes(key)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: Create from first few words
|
||||
const titleWords = words.slice(0, 2).map(w =>
|
||||
w.charAt(0).toUpperCase() + w.slice(1)
|
||||
);
|
||||
return titleWords.length > 0 ? titleWords.join(' ') + ' Pro' : 'Custom Agent';
|
||||
}
|
||||
|
||||
// Generate a sophisticated fallback prompt when API fails
|
||||
const generateFallbackPrompt = (description: string): string => {
|
||||
return `# ${description}
|
||||
|
||||
## IDENTITY & CORE MISSION
|
||||
You are a world-class AI coding assistant specialized in: ${description}. You combine deep technical expertise with exceptional problem-solving abilities to deliver production-ready code that exceeds professional standards.
|
||||
|
||||
## CODEBASE AWARENESS PROTOCOL
|
||||
Before writing any code, you MUST:
|
||||
1. **Analyze Context**: Understand the existing project structure, patterns, and conventions
|
||||
2. **Identify Dependencies**: Check package.json, imports, and installed libraries
|
||||
3. **Match Style**: Adapt your output to the existing code style in the project
|
||||
4. **Verify Compatibility**: Ensure new code integrates seamlessly with existing modules
|
||||
|
||||
## TECHNICAL EXPERTISE
|
||||
- **Languages**: JavaScript, TypeScript, Python, and relevant frameworks
|
||||
- **Patterns**: SOLID principles, DRY, KISS, Clean Architecture
|
||||
- **Testing**: TDD approach, comprehensive test coverage
|
||||
- **Documentation**: Clear comments, JSDoc/TSDoc, README updates
|
||||
|
||||
## CODING STANDARDS
|
||||
1. **Naming**: Use descriptive, intention-revealing names
|
||||
2. **Functions**: Single responsibility, max 20-30 lines per function
|
||||
3. **Error Handling**: Always handle errors gracefully with informative messages
|
||||
4. **Types**: Prefer strict typing, avoid \`any\` type
|
||||
5. **Comments**: Explain WHY, not WHAT (the code explains what)
|
||||
|
||||
## ARCHITECTURAL PRINCIPLES
|
||||
- Favor composition over inheritance
|
||||
- Implement proper separation of concerns
|
||||
- Design for extensibility and maintainability
|
||||
- Consider performance implications of design choices
|
||||
- Apply appropriate design patterns (Factory, Strategy, Observer, etc.)
|
||||
|
||||
## COMMUNICATION STYLE
|
||||
- Be concise but thorough in explanations
|
||||
- Provide rationale for technical decisions
|
||||
- Offer alternatives when relevant
|
||||
- Acknowledge limitations and edge cases
|
||||
- Use code examples to illustrate concepts
|
||||
|
||||
## TOOL USAGE
|
||||
When modifying the codebase:
|
||||
1. Use \`read_file\` to understand existing code before making changes
|
||||
2. Use \`list_files\` to understand project structure
|
||||
3. Use \`write_file\` to create or update files with complete, working code
|
||||
4. Always verify your changes are syntactically correct
|
||||
5. Consider impact on other files that may need updates
|
||||
|
||||
## OUTPUT QUALITY STANDARDS
|
||||
Every piece of code you generate must be:
|
||||
- ✅ Syntactically correct and immediately runnable
|
||||
- ✅ Following existing project conventions
|
||||
- ✅ Properly typed (if TypeScript)
|
||||
- ✅ Including necessary imports
|
||||
- ✅ Handling edge cases and errors
|
||||
- ✅ Well-documented where appropriate
|
||||
|
||||
You are committed to excellence and take pride in delivering code that professionals would admire.`
|
||||
}
|
||||
|
||||
const handleSaveAgent = async () => {
|
||||
const agent = generatedAgent();
|
||||
if (!agent || isSaving()) return;
|
||||
|
||||
setIsSaving(true);
|
||||
const toastId = toast.loading("Saving agent...");
|
||||
|
||||
try {
|
||||
// Save to backend
|
||||
const response = await fetch(`/api/workspaces/${props.instanceId}/agents`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
systemPrompt: agent.systemPrompt,
|
||||
mode: "agent"
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// CRITICAL: Update local instance config to keep it in sync with backend
|
||||
// This is the source of truth that fetchAgents() reads from
|
||||
await updateInstanceConfig(props.instanceId, (draft) => {
|
||||
if (!draft.customAgents) {
|
||||
draft.customAgents = [];
|
||||
}
|
||||
const existingIndex = draft.customAgents.findIndex(a => a.name === agent.name);
|
||||
const agentData = {
|
||||
name: agent.name,
|
||||
description: agent.description || "",
|
||||
prompt: agent.systemPrompt || ""
|
||||
};
|
||||
if (existingIndex >= 0) {
|
||||
draft.customAgents[existingIndex] = agentData;
|
||||
} else {
|
||||
draft.customAgents.push(agentData);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch fresh agents from backend to update global signals
|
||||
await fetchAgents(props.instanceId);
|
||||
|
||||
// Refresh local agent list
|
||||
loadAgents();
|
||||
|
||||
// Manual update to ensure immediate feedback (fix for list lag)
|
||||
setAgentList(prev => {
|
||||
if (prev.some(a => a.name === agent.name)) return prev;
|
||||
return [...prev, { name: agent.name, description: agent.description, systemPrompt: agent.systemPrompt }];
|
||||
});
|
||||
|
||||
// Select the new agent
|
||||
props.onAgentChange(agent.name);
|
||||
|
||||
toast.success(`Agent "${agent.name}" saved and activated!`, { id: toastId });
|
||||
|
||||
// Close generator
|
||||
setShowGenerator(false);
|
||||
setGeneratedAgent(null);
|
||||
setGeneratorInput("");
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error("Failed to save agent:", response.status, errorData);
|
||||
toast.error(`Failed to save agent: ${errorData.error || response.statusText}`, { id: toastId });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save agent:", error);
|
||||
toast.error("Network error while saving agent", { id: toastId });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
class="flex items-center justify-between w-full px-3 py-2 bg-zinc-900/60 border border-white/10 rounded-lg text-left hover:border-indigo-500/30 transition-all"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Bot size={14} class="text-indigo-400" />
|
||||
<span class="text-[11px] font-bold text-zinc-200 truncate">
|
||||
{props.currentAgent || "Select Agent"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={12} class={`text-zinc-500 transition-transform ${isOpen() ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<div class="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-white/10 rounded-lg shadow-xl z-50 max-h-[80vh] overflow-y-auto">
|
||||
{/* Agent Generator Toggle */}
|
||||
<button
|
||||
onClick={() => setShowGenerator(!showGenerator())}
|
||||
class="w-full px-3 py-2 text-left hover:bg-indigo-500/10 transition-colors flex items-center gap-2 border-b border-white/5 text-indigo-400"
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
<span class="text-[11px] font-bold">AI Agent Generator</span>
|
||||
<Plus size={12} class="ml-auto" />
|
||||
</button>
|
||||
|
||||
{/* Generator Panel */}
|
||||
<Show when={showGenerator()}>
|
||||
<div class="p-3 border-b border-white/10 bg-zinc-950/50 space-y-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] text-zinc-400 font-medium">Generation Model:</div>
|
||||
<select
|
||||
value={selectedModel()}
|
||||
onChange={(e) => setSelectedModel(e.currentTarget.value)}
|
||||
class="w-full bg-zinc-800 border border-white/10 rounded px-2 py-1.5 text-[10px] text-zinc-200 outline-none focus:border-indigo-500/50"
|
||||
>
|
||||
<For each={availableModels()}>
|
||||
{(model) => (
|
||||
<option value={model.id}>{model.name}</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="text-[10px] text-zinc-400 font-medium">
|
||||
Describe the agent you want to create:
|
||||
</div>
|
||||
<textarea
|
||||
value={generatorInput()}
|
||||
onInput={(e) => setGeneratorInput(e.currentTarget.value)}
|
||||
placeholder="e.g., A TypeScript expert who focuses on clean code and best practices..."
|
||||
class="w-full bg-zinc-800 border border-white/10 rounded-lg px-3 py-2 text-[11px] text-zinc-200 placeholder-zinc-600 resize-none outline-none focus:border-indigo-500/50"
|
||||
rows={3}
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleGenerateAgent}
|
||||
disabled={!generatorInput().trim() || isGenerating()}
|
||||
class="flex-1 px-3 py-1.5 bg-indigo-500/20 border border-indigo-500/40 rounded-lg text-[10px] font-bold text-indigo-300 hover:bg-indigo-500/30 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Show when={isGenerating()} fallback={<Sparkles size={12} />}>
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
</Show>
|
||||
{isGenerating() ? "Generating..." : "Generate Agent"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Generated Agent Preview */}
|
||||
<Show when={generatedAgent()}>
|
||||
<div class="bg-zinc-800/50 rounded-lg p-3 border border-emerald-500/30 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-emerald-400">Generated Agent</span>
|
||||
<button
|
||||
onClick={() => setGeneratedAgent(null)}
|
||||
class="text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[12px] font-bold text-zinc-100">{generatedAgent()?.name}</div>
|
||||
<div class="text-[10px] text-zinc-400">{generatedAgent()?.description}</div>
|
||||
<div class="text-[9px] text-zinc-400 max-h-60 overflow-y-auto whitespace-pre-wrap font-mono bg-black/20 p-2 rounded border border-white/5">
|
||||
{generatedAgent()?.systemPrompt}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveAgent}
|
||||
disabled={isSaving()}
|
||||
class="w-full flex items-center justify-center gap-2 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-md text-[11px] font-bold transition-all shadow-lg active:scale-95"
|
||||
>
|
||||
<Show when={isSaving()} fallback={<Save size={14} />}>
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
</Show>
|
||||
{isSaving() ? "Saving..." : "Save & Use Agent"}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Agent List */}
|
||||
<div class="px-3 py-1.5 flex items-center justify-between border-t border-white/5 bg-zinc-950/30">
|
||||
<span class="text-[9px] font-bold text-zinc-500 uppercase tracking-widest">Saved Agents</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); loadAgents(); fetchAgents(); }}
|
||||
class="p-1 hover:bg-white/5 rounded text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
title="Refresh agents"
|
||||
>
|
||||
<RefreshCw size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-48 overflow-y-auto custom-scrollbar">
|
||||
<For each={agentList()}>
|
||||
{(agent) => (
|
||||
<button
|
||||
onClick={() => handleSelect(agent.name)}
|
||||
class={`w-full px-3 py-2 text-left hover:bg-white/5 transition-colors flex items-center gap-2 ${props.currentAgent === agent.name ? "bg-indigo-500/10 text-indigo-300" : "text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
<Bot size={12} class="text-zinc-500" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-[11px] font-bold truncate">{agent.name}</div>
|
||||
{agent.description && (
|
||||
<div class="text-[9px] text-zinc-500 truncate">{agent.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={agentList().length === 0}>
|
||||
<div class="px-3 py-2 text-[10px] text-zinc-600">No agents available</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* LiteModelSelector - Non-reactive model selector for MultiX v2
|
||||
*
|
||||
* Uses polling instead of reactive subscriptions to prevent cascading updates.
|
||||
*/
|
||||
|
||||
import { createSignal, For, onMount, onCleanup, Show } from "solid-js";
|
||||
import { providers } from "@/stores/session-state";
|
||||
import { ChevronDown, Cpu } from "lucide-solid";
|
||||
|
||||
interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
models: Model[];
|
||||
}
|
||||
|
||||
interface LiteModelSelectorProps {
|
||||
instanceId: string;
|
||||
sessionId: string;
|
||||
currentModel: { providerId: string; modelId: string };
|
||||
onModelChange: (model: { providerId: string; modelId: string }) => void;
|
||||
}
|
||||
|
||||
export function LiteModelSelector(props: LiteModelSelectorProps) {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [providerList, setProviderList] = createSignal<Provider[]>([]);
|
||||
|
||||
// Load providers once on mount, then poll
|
||||
function loadProviders() {
|
||||
try {
|
||||
const instanceProviders = providers().get(props.instanceId) || [];
|
||||
setProviderList(instanceProviders.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
models: (p.models || []).map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
providerId: p.id,
|
||||
})),
|
||||
})));
|
||||
} catch (e) {
|
||||
console.warn("Failed to load providers", e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadProviders();
|
||||
// Poll every 10 seconds (providers don't change often)
|
||||
const interval = setInterval(loadProviders, 10000);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
});
|
||||
|
||||
const handleSelect = (providerId: string, modelId: string) => {
|
||||
props.onModelChange({ providerId, modelId });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const getCurrentModelName = () => {
|
||||
if (!props.currentModel.modelId) return "Select Model";
|
||||
for (const provider of providerList()) {
|
||||
for (const model of provider.models) {
|
||||
if (model.id === props.currentModel.modelId) {
|
||||
return model.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return props.currentModel.modelId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
class="flex items-center justify-between w-full px-3 py-2 bg-zinc-900/60 border border-white/10 rounded-lg text-left hover:border-indigo-500/30 transition-all"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Cpu size={14} class="text-emerald-400" />
|
||||
<span class="text-[11px] font-bold text-zinc-200 truncate">
|
||||
{getCurrentModelName()}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={12} class={`text-zinc-500 transition-transform ${isOpen() ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<div class="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-white/10 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
|
||||
<For each={providerList()}>
|
||||
{(provider) => (
|
||||
<div>
|
||||
<div class="px-3 py-1.5 text-[9px] font-bold text-zinc-500 uppercase tracking-wide bg-zinc-950/50 sticky top-0">
|
||||
{provider.name}
|
||||
</div>
|
||||
<For each={provider.models}>
|
||||
{(model) => (
|
||||
<button
|
||||
onClick={() => handleSelect(provider.id, model.id)}
|
||||
class={`w-full px-3 py-2 text-left hover:bg-white/5 transition-colors flex items-center gap-2 ${props.currentModel.modelId === model.id ? "bg-emerald-500/10 text-emerald-300" : "text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
<Cpu size={12} class="text-zinc-500" />
|
||||
<span class="text-[11px] font-medium truncate">{model.name}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={providerList().length === 0}>
|
||||
<div class="px-3 py-2 text-[10px] text-zinc-600">No models available</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* LiteSkillsSelector - Non-reactive skills selector for MultiX v2
|
||||
*
|
||||
* Uses polling instead of reactive subscriptions to prevent cascading updates.
|
||||
* Displays selected skills as chips with ability to add/remove.
|
||||
*/
|
||||
|
||||
import { createSignal, For, onMount, onCleanup, Show } from "solid-js";
|
||||
import { catalog, catalogLoading, loadCatalog } from "@/stores/skills";
|
||||
import { getSessionSkills, setSessionSkills } from "@/stores/session-state";
|
||||
import { ChevronDown, Sparkles, X, Check, Loader2 } from "lucide-solid";
|
||||
import type { SkillSelection } from "@/types/session";
|
||||
|
||||
interface LiteSkillsSelectorProps {
|
||||
instanceId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface SkillInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function LiteSkillsSelector(props: LiteSkillsSelectorProps) {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [skillList, setSkillList] = createSignal<SkillInfo[]>([]);
|
||||
const [selectedSkills, setSelectedSkills] = createSignal<SkillSelection[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [filterText, setFilterText] = createSignal("");
|
||||
|
||||
// Load skills once on mount, then poll
|
||||
function loadSkills() {
|
||||
try {
|
||||
const skills = catalog();
|
||||
setSkillList(skills.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name || s.id,
|
||||
description: s.description
|
||||
})));
|
||||
} catch (e) {
|
||||
console.warn("Failed to load skills", e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadSelected() {
|
||||
try {
|
||||
const skills = getSessionSkills(props.instanceId, props.sessionId);
|
||||
setSelectedSkills(skills);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load selected skills", e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Load catalog if not already loaded
|
||||
if (catalog().length === 0) {
|
||||
setIsLoading(true);
|
||||
await loadCatalog();
|
||||
setIsLoading(false);
|
||||
}
|
||||
loadSkills();
|
||||
loadSelected();
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadSkills();
|
||||
loadSelected();
|
||||
}, 2000);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
});
|
||||
|
||||
const toggleSkill = (skill: SkillInfo) => {
|
||||
const current = selectedSkills();
|
||||
const isSelected = current.some(s => s.id === skill.id);
|
||||
|
||||
let next: SkillSelection[];
|
||||
if (isSelected) {
|
||||
next = current.filter(s => s.id !== skill.id);
|
||||
} else {
|
||||
next = [...current, { id: skill.id, name: skill.name, description: skill.description }];
|
||||
}
|
||||
|
||||
setSelectedSkills(next);
|
||||
setSessionSkills(props.instanceId, props.sessionId, next);
|
||||
};
|
||||
|
||||
const removeSkill = (id: string) => {
|
||||
const next = selectedSkills().filter(s => s.id !== id);
|
||||
setSelectedSkills(next);
|
||||
setSessionSkills(props.instanceId, props.sessionId, next);
|
||||
};
|
||||
|
||||
const filteredSkills = () => {
|
||||
const term = filterText().toLowerCase().trim();
|
||||
if (!term) return skillList();
|
||||
return skillList().filter(s =>
|
||||
s.name.toLowerCase().includes(term) ||
|
||||
s.id.toLowerCase().includes(term) ||
|
||||
(s.description?.toLowerCase().includes(term) ?? false)
|
||||
);
|
||||
};
|
||||
|
||||
const isSkillSelected = (id: string) => selectedSkills().some(s => s.id === id);
|
||||
|
||||
return (
|
||||
<div class="relative w-full">
|
||||
{/* Main Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
class="flex items-center justify-between w-full px-3 py-2 bg-zinc-900/60 border border-white/10 rounded-lg text-left hover:border-purple-500/30 transition-all"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Sparkles size={14} class="text-purple-400 shrink-0" />
|
||||
<Show
|
||||
when={selectedSkills().length > 0}
|
||||
fallback={<span class="text-[11px] text-zinc-500">No skills</span>}
|
||||
>
|
||||
<div class="flex items-center gap-1 overflow-hidden">
|
||||
<span class="text-[11px] font-bold text-purple-300">
|
||||
{selectedSkills().length} skill{selectedSkills().length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<For each={selectedSkills().slice(0, 2)}>
|
||||
{(skill) => (
|
||||
<span class="text-[10px] px-1.5 py-0.5 bg-purple-500/20 text-purple-300 rounded truncate max-w-[80px]">
|
||||
{skill.name}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={selectedSkills().length > 2}>
|
||||
<span class="text-[10px] text-zinc-500">+{selectedSkills().length - 2}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<ChevronDown size={12} class={`text-zinc-500 transition-transform shrink-0 ${isOpen() ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
<Show when={isOpen()}>
|
||||
<div class="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-white/10 rounded-lg shadow-xl z-50 max-h-80 overflow-hidden flex flex-col">
|
||||
{/* Selected Skills Chips */}
|
||||
<Show when={selectedSkills().length > 0}>
|
||||
<div class="px-3 py-2 border-b border-white/5 flex flex-wrap gap-1">
|
||||
<For each={selectedSkills()}>
|
||||
{(skill) => (
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-purple-500/20 text-purple-300 rounded-full text-[10px]">
|
||||
{skill.name}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSkill(skill.id);
|
||||
}}
|
||||
class="hover:text-red-400"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Filter Input */}
|
||||
<div class="px-3 py-2 border-b border-white/5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter skills..."
|
||||
value={filterText()}
|
||||
onInput={(e) => setFilterText(e.currentTarget.value)}
|
||||
class="w-full bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-zinc-200 outline-none focus:border-purple-500/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Skills List */}
|
||||
<div class="overflow-y-auto flex-1 max-h-48">
|
||||
<Show
|
||||
when={!isLoading() && !catalogLoading()}
|
||||
fallback={
|
||||
<div class="px-3 py-4 text-center text-[11px] text-zinc-500 flex items-center justify-center gap-2">
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
Loading skills...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={filteredSkills().length > 0}
|
||||
fallback={
|
||||
<div class="px-3 py-4 text-center text-[11px] text-zinc-500">
|
||||
No skills found
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filteredSkills()}>
|
||||
{(skill) => (
|
||||
<button
|
||||
onClick={() => toggleSkill(skill)}
|
||||
class={`w-full px-3 py-2 text-left hover:bg-white/5 transition-colors flex items-center gap-2 ${isSkillSelected(skill.id) ? "bg-purple-500/10" : ""
|
||||
}`}
|
||||
>
|
||||
<div class={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${isSkillSelected(skill.id)
|
||||
? "bg-purple-500 border-purple-500"
|
||||
: "border-white/20"
|
||||
}`}>
|
||||
<Show when={isSkillSelected(skill.id)}>
|
||||
<Check size={10} class="text-white" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class={`text-[11px] font-medium truncate ${isSkillSelected(skill.id) ? "text-purple-300" : "text-zinc-300"
|
||||
}`}>
|
||||
{skill.name}
|
||||
</div>
|
||||
<Show when={skill.description}>
|
||||
<div class="text-[10px] text-zinc-500 truncate">
|
||||
{skill.description}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* MessageNavSidebar - Quick navigation for messages
|
||||
*
|
||||
* Shows YOU/ASST labels with hover preview.
|
||||
*/
|
||||
|
||||
import { For, Show, createSignal, type Accessor } from "solid-js";
|
||||
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
|
||||
|
||||
interface MessageNavSidebarProps {
|
||||
messageIds: Accessor<string[]>;
|
||||
store: () => InstanceMessageStore;
|
||||
scrollContainer: HTMLDivElement | undefined;
|
||||
onTabClick: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function MessageNavSidebar(props: MessageNavSidebarProps) {
|
||||
return (
|
||||
<div class="w-14 shrink-0 bg-zinc-900/40 border-l border-white/5 overflow-hidden py-2 px-1.5 flex flex-col items-center gap-1">
|
||||
<For each={props.messageIds()}>
|
||||
{(messageId, index) => {
|
||||
const [showPreview, setShowPreview] = createSignal(false);
|
||||
|
||||
const msg = () => props.store().getMessage(messageId);
|
||||
const isUser = () => msg()?.role === "user";
|
||||
|
||||
// Get message preview text (first 150 chars)
|
||||
const previewText = () => {
|
||||
const message = msg();
|
||||
if (!message) return "";
|
||||
|
||||
// Try to get text from parts
|
||||
const parts = message.parts || {};
|
||||
let text = "";
|
||||
for (const partId of Object.keys(parts)) {
|
||||
const partRecord = parts[partId];
|
||||
if (partRecord?.data?.type === "text") {
|
||||
text = (partRecord.data as any).text || "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct content
|
||||
if (!text && (message as any).content) {
|
||||
text = (message as any).content;
|
||||
}
|
||||
|
||||
return text.length > 150 ? text.substring(0, 150) + "..." : text;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative group">
|
||||
<button
|
||||
onClick={() => props.onTabClick(messageId)}
|
||||
onMouseEnter={() => setShowPreview(true)}
|
||||
onMouseLeave={() => setShowPreview(false)}
|
||||
class={`w-10 py-1.5 rounded text-[8px] font-black uppercase transition-all cursor-pointer ${isUser()
|
||||
? "bg-indigo-500/20 border border-indigo-500/40 text-indigo-400 hover:bg-indigo-500/40 hover:scale-105"
|
||||
: "bg-emerald-500/20 border border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/40 hover:scale-105"
|
||||
}`}
|
||||
>
|
||||
{isUser() ? "YOU" : "ASST"}
|
||||
</button>
|
||||
|
||||
{/* Hover Preview Tooltip */}
|
||||
<Show when={showPreview()}>
|
||||
<div class="absolute right-full mr-2 top-0 w-72 max-h-40 overflow-y-auto bg-zinc-900 border border-white/10 rounded-lg shadow-xl p-3 z-50 animate-in fade-in slide-in-from-right-2 duration-150 custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class={`text-[9px] font-bold uppercase ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
|
||||
{isUser() ? "You" : "Assistant"} • Msg {index() + 1}
|
||||
</div>
|
||||
<div class="text-[8px] text-zinc-600">
|
||||
{msg()?.status === "streaming" ? "• Streaming" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-zinc-300 leading-relaxed whitespace-pre-wrap">
|
||||
{previewText()}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* PipelineView - Task Dashboard
|
||||
*
|
||||
* Shows all active tasks as cards when no task is selected.
|
||||
*/
|
||||
|
||||
import { For, Show, type Accessor } from "solid-js";
|
||||
import { Plus, ChevronRight, X } from "lucide-solid";
|
||||
import type { Task } from "@/types/session";
|
||||
|
||||
interface PipelineViewProps {
|
||||
visibleTasks: Accessor<Task[]>;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onArchiveTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function PipelineView(props: PipelineViewProps) {
|
||||
return (
|
||||
<div class="p-4 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-2xl font-black text-white tracking-tight leading-none">Pipeline</h2>
|
||||
<p class="text-xs font-medium text-zinc-500 uppercase tracking-[0.2em]">Agentic Orchestration</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest">Active Threads</span>
|
||||
<div class="h-px flex-1 bg-white/5 mx-4" />
|
||||
<span class="text-[10px] font-black text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded border border-indigo-500/20">
|
||||
{props.visibleTasks().length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<Show when={props.visibleTasks().length === 0}>
|
||||
<div class="group relative p-8 rounded-3xl border border-dashed border-white/5 bg-zinc-900/20 flex flex-col items-center justify-center text-center space-y-4 transition-all hover:bg-zinc-900/40 hover:border-white/10">
|
||||
<div class="w-12 h-12 rounded-2xl bg-white/5 flex items-center justify-center text-zinc-600 group-hover:text-indigo-400 group-hover:scale-110 transition-all duration-500">
|
||||
<Plus size={24} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-bold text-zinc-400">No active tasks</p>
|
||||
<p class="text-[11px] text-zinc-600">Send a message below to start a new thread</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={props.visibleTasks()}>
|
||||
{(task) => (
|
||||
<button
|
||||
onClick={() => props.onTaskClick(task.id)}
|
||||
class={`group relative p-4 rounded-2xl border border-white/5 bg-zinc-900/40 hover:bg-zinc-800/60 hover:border-indigo-500/30 transition-all duration-300 text-left flex items-start space-x-4 active:scale-[0.98] ${task.title.toLowerCase().includes("smart fix") ? "smart-fix-highlight" : ""}`}
|
||||
>
|
||||
<div class={`mt-1 w-2 h-2 rounded-full shadow-[0_0_10px_rgba(var(--color),0.5)] ${task.status === "completed" ? "bg-emerald-500 shadow-emerald-500/40" :
|
||||
task.status === "in-progress" ? "bg-indigo-500 shadow-indigo-500/40 animate-pulse" :
|
||||
"bg-zinc-600 shadow-zinc-600/20"
|
||||
}`} />
|
||||
<div class="flex-1 min-w-0 space-y-1">
|
||||
<p class="text-sm font-bold text-zinc-100 truncate group-hover:text-white transition-colors">
|
||||
{task.title}
|
||||
</p>
|
||||
<div class="flex items-center space-x-3 text-[10px] font-bold text-zinc-500 uppercase tracking-tight">
|
||||
<span>{new Date(task.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span class="w-1 h-1 rounded-full bg-zinc-800" />
|
||||
<span>{task.messageIds?.length || 0} messages</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onArchiveTask(task.id);
|
||||
}}
|
||||
class="text-zinc-600 hover:text-zinc-200 transition-colors"
|
||||
title="Archive task"
|
||||
>
|
||||
<X size={14} />
|
||||
</span>
|
||||
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* PromptEnhancer - Clavix-inspired prompt optimization
|
||||
*
|
||||
* Source: https://github.com/ClavixDev/Clavix.git
|
||||
*
|
||||
* Takes a user's raw input and refines it into a precise,
|
||||
* context-aware, actionable prompt using the session's configured model.
|
||||
*/
|
||||
|
||||
import { getLogger } from "@/lib/logger";
|
||||
import { sessions } from "@/stores/session-state";
|
||||
|
||||
const log = getLogger("prompt-enhancer");
|
||||
|
||||
// The meta-prompt based on Clavix CLEAR framework
|
||||
const ENHANCEMENT_PROMPT = `You are an ELITE Software Architect and Prompt Engineer, powered by the "ThoughtBox" reasoning engine.
|
||||
|
||||
YOUR MISSION:
|
||||
Transform the user's raw input into a "God-Tier" System Prompt—a comprehensive, execution-ready technical specification that a senior engineer could implement without further questions.
|
||||
|
||||
TARGET OUTPUT:
|
||||
- Detailed, file-level architectural blueprint
|
||||
- Explicit coding standards (TypeScript/solid-js/tailwindcss context implied)
|
||||
- Comprehensive error handling and edge case strategy
|
||||
- Step-by-step implementation plan
|
||||
|
||||
METHODOLOGY (ThoughtBox):
|
||||
1. **Decode Intent**: What is the root problem? What is the *value*?
|
||||
2. **Context Inference**: Assume a high-performance TypeScript/React/Electron environment. Infer necessary imports, stores, and services.
|
||||
3. **Architectural Strategy**: Define the component hierarchy, state management (signals/stores), and side effects.
|
||||
4. **Specification Generation**: Write the actual prompt.
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Return ONLY the enhanced prompt string, formatted as follows:
|
||||
|
||||
# 🎯 OBJECTIVE
|
||||
[Concise, high-level goal]
|
||||
|
||||
# 🏗️ ARCHITECTURE & DESIGN
|
||||
- **Files**: List exact file paths to touch/create.
|
||||
- **Components**: Define props, state, and interfaces.
|
||||
- **Data Flow**: Explain signal/store interactions.
|
||||
|
||||
# 🛡️ RESTRICTIONS & STANDARDS
|
||||
- **Tech Stack**: TypeScript, SolidJS, TailwindCSS, Lucide Icons.
|
||||
- **Rules**: NO placeholders, NO "todo", Strict Types, Accessibility-first.
|
||||
- **Context**: [Infer from input, e.g., "Use ContextEngine for retrieval"]
|
||||
|
||||
# 📝 IMPLEMENTATION PLAN
|
||||
1. [Step 1: Description]
|
||||
2. [Step 2: Description]
|
||||
...
|
||||
|
||||
# 💡 ORIGINAL REQUEST
|
||||
"""
|
||||
{INPUT}
|
||||
"""
|
||||
`;
|
||||
|
||||
/**
|
||||
* Get the model configured for a session
|
||||
*/
|
||||
function getSessionModel(instanceId: string, sessionId: string): string {
|
||||
try {
|
||||
const instanceSessions = sessions().get(instanceId);
|
||||
const session = instanceSessions?.get(sessionId);
|
||||
if (session?.model?.modelId) {
|
||||
return session.model.modelId;
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("Could not get session model", e);
|
||||
}
|
||||
return "minimax-m1"; // Fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance a user's prompt using the session's AI model
|
||||
*/
|
||||
export async function enhancePrompt(
|
||||
userInput: string,
|
||||
instanceId: string,
|
||||
sessionId?: string
|
||||
): Promise<string> {
|
||||
if (!userInput.trim()) {
|
||||
return userInput;
|
||||
}
|
||||
|
||||
// Get the model from the session
|
||||
const model = sessionId ? getSessionModel(instanceId, sessionId) : "minimax-m1";
|
||||
|
||||
log.info("Enhancing prompt...", { length: userInput.length, model });
|
||||
|
||||
try {
|
||||
// Call the Ollama API for enhancement using the session's model
|
||||
const response = await fetch("/api/ollama/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: ENHANCEMENT_PROMPT.replace("{INPUT}", userInput)
|
||||
}
|
||||
],
|
||||
stream: false
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
log.warn("Enhancement API failed, returning original", { status: response.status });
|
||||
return userInput;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const enhanced = data?.message?.content || data?.choices?.[0]?.message?.content;
|
||||
|
||||
if (!enhanced || enhanced.trim().length === 0) {
|
||||
log.warn("Enhancement returned empty, using original");
|
||||
return userInput;
|
||||
}
|
||||
|
||||
log.info("Prompt enhanced successfully", {
|
||||
originalLength: userInput.length,
|
||||
enhancedLength: enhanced.length,
|
||||
model
|
||||
});
|
||||
|
||||
return enhanced.trim();
|
||||
} catch (error) {
|
||||
log.error("Prompt enhancement failed", error);
|
||||
return userInput;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a quick suggestion for improving a prompt (synchronous hint)
|
||||
*/
|
||||
export function getQuickTips(userInput: string): string[] {
|
||||
const tips: string[] = [];
|
||||
|
||||
if (userInput.length < 20) {
|
||||
tips.push("Add more context for better results");
|
||||
}
|
||||
|
||||
if (!userInput.includes("file") && !userInput.includes("function") && !userInput.includes("component")) {
|
||||
tips.push("Mention specific files or functions if applicable");
|
||||
}
|
||||
|
||||
if (!userInput.match(/\b(create|fix|update|add|remove|refactor)\b/i)) {
|
||||
tips.push("Start with an action verb: create, fix, update, etc.");
|
||||
}
|
||||
|
||||
return tips;
|
||||
}
|
||||
849
packages/ui/src/components/chat/multix-v2/index.tsx
Normal file
849
packages/ui/src/components/chat/multix-v2/index.tsx
Normal file
@@ -0,0 +1,849 @@
|
||||
/**
|
||||
* MultiX v2 - Main Entry Point
|
||||
*
|
||||
* A complete rebuild of the MultiTaskChat component with:
|
||||
* 1. Local signals + polling (no reactive cascade = no freeze)
|
||||
* 2. 100% feature parity with original
|
||||
* 3. New features: Context-Engine, Compaction, Prompt Enhancement
|
||||
*/
|
||||
|
||||
import { createSignal, Show, onMount, For, onCleanup, batch } from "solid-js";
|
||||
import toast from "solid-toast";
|
||||
import { sessions, activeSessionId, setActiveSession } from "@/stores/session-state";
|
||||
import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession, forceReset, abortSession } from "@/stores/session-actions";
|
||||
import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions";
|
||||
import { messageStoreBus } from "@/stores/message-v2/bus";
|
||||
import { formatTokenTotal } from "@/lib/formatters";
|
||||
import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggleAutoApproval, toggleApex } from "@/stores/solo-store";
|
||||
import { getLogger } from "@/lib/logger";
|
||||
import { clearCompactionSuggestion, getCompactionSuggestion } from "@/stores/session-compaction";
|
||||
import { emitSessionSidebarRequest } from "@/lib/session-sidebar-events";
|
||||
import {
|
||||
Command, Plus, PanelRight, ListTodo, AtSign, Hash, Mic, ArrowUp,
|
||||
ChevronRight, Loader2, X, Zap, Layers, Sparkles, StopCircle, Key,
|
||||
FileArchive, Paperclip, Wand2, Shield,
|
||||
} from "lucide-solid";
|
||||
// Using Lite versions to avoid reactive cascade
|
||||
// import ModelSelector from "@/components/model-selector";
|
||||
// import AgentSelector from "@/components/agent-selector";
|
||||
import { DebugOverlay, setForceResetFn } from "@/components/debug-overlay";
|
||||
import AttachmentChip from "@/components/attachment-chip";
|
||||
import { createFileAttachment } from "@/types/attachment";
|
||||
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
|
||||
import type { Task, Session } from "@/types/session";
|
||||
|
||||
// Sub-components
|
||||
import { SimpleMessageBlock } from "./core/SimpleMessageBlock";
|
||||
import { PipelineView } from "./features/PipelineView";
|
||||
import { MessageNavSidebar } from "./features/MessageNavSidebar";
|
||||
import { enhancePrompt } from "./features/PromptEnhancer";
|
||||
import { LiteAgentSelector } from "./features/LiteAgentSelector";
|
||||
import { LiteModelSelector } from "./features/LiteModelSelector";
|
||||
import { LiteSkillsSelector } from "./features/LiteSkillsSelector";
|
||||
import MessageBlockList from "@/components/message-block-list";
|
||||
|
||||
const OPEN_ADVANCED_SETTINGS_EVENT = "open-advanced-settings";
|
||||
const log = getLogger("multix-v2");
|
||||
|
||||
interface MultiXV2Props {
|
||||
instanceId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export default function MultiXV2(props: MultiXV2Props) {
|
||||
// ============================================================================
|
||||
// LOCAL STATE (No reactive memos on stores - polling instead)
|
||||
// ============================================================================
|
||||
// Per-task sending state (Map of taskId -> boolean)
|
||||
const [sendingTasks, setSendingTasks] = createSignal<Set<string>>(new Set());
|
||||
const [chatInput, setChatInput] = createSignal("");
|
||||
const [isCompacting, setIsCompacting] = createSignal(false);
|
||||
const [attachments, setAttachments] = createSignal<ReturnType<typeof createFileAttachment>[]>([]);
|
||||
const [userScrolling, setUserScrolling] = createSignal(false);
|
||||
const [isEnhancing, setIsEnhancing] = createSignal(false);
|
||||
|
||||
// Cached store values - updated via polling
|
||||
const [tasks, setTasks] = createSignal<Task[]>([]);
|
||||
const [visibleTasks, setVisibleTasks] = createSignal<Task[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskIdLocal] = createSignal<string | null>(null);
|
||||
const [messageIds, setMessageIds] = createSignal<string[]>([]);
|
||||
const [cachedModelId, setCachedModelId] = createSignal("unknown");
|
||||
const [cachedAgent, setCachedAgent] = createSignal("");
|
||||
const [cachedTokensUsed, setCachedTokensUsed] = createSignal(0);
|
||||
const [cachedCost, setCachedCost] = createSignal(0);
|
||||
const [isAgentThinking, setIsAgentThinking] = createSignal(false);
|
||||
const [compactionSuggestion, setCompactionSuggestion] = createSignal<{ reason: string } | null>(null);
|
||||
const [soloState, setSoloState] = createSignal({ isApex: false, isAutonomous: false, autoApproval: false, activeTaskId: null as string | null });
|
||||
const [lastAssistantIndex, setLastAssistantIndex] = createSignal(-1);
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
|
||||
|
||||
// Helper to check if CURRENT task is sending
|
||||
const isSending = () => {
|
||||
const taskId = selectedTaskId();
|
||||
if (!taskId) return sendingTasks().size > 0; // If no task selected, check if any is sending
|
||||
return sendingTasks().has(taskId);
|
||||
};
|
||||
|
||||
// Helper to set sending state for a task
|
||||
const setTaskSending = (taskId: string, sending: boolean) => {
|
||||
setSendingTasks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (sending) {
|
||||
next.add(taskId);
|
||||
} else {
|
||||
next.delete(taskId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
let scrollContainer: HTMLDivElement | undefined;
|
||||
let fileInputRef: HTMLInputElement | undefined;
|
||||
|
||||
// ============================================================================
|
||||
// STORE ACCESS HELPERS (Non-reactive reads)
|
||||
// ============================================================================
|
||||
function getSession(): Session | undefined {
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
return instanceSessions?.get(props.sessionId);
|
||||
}
|
||||
|
||||
function getMessageStore(): InstanceMessageStore {
|
||||
return messageStoreBus.getOrCreate(props.instanceId);
|
||||
}
|
||||
|
||||
function getSelectedTask(): Task | undefined {
|
||||
return visibleTasks().find(t => t.id === selectedTaskId());
|
||||
}
|
||||
|
||||
function getActiveTaskSessionId(): string {
|
||||
const task = getSelectedTask();
|
||||
return task?.taskSessionId || props.sessionId;
|
||||
}
|
||||
|
||||
function getActiveTaskSession(): Session | undefined {
|
||||
const sessionId = getActiveTaskSessionId();
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
return instanceSessions?.get(sessionId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POLLING-BASED SYNC (Updates local state from stores every 150ms)
|
||||
// ============================================================================
|
||||
function syncFromStore() {
|
||||
try {
|
||||
const session = getSession();
|
||||
if (session) {
|
||||
const allTasks = session.tasks || [];
|
||||
setTasks(allTasks);
|
||||
setVisibleTasks(allTasks.filter(t => !t.archived));
|
||||
// NOTE: Don't overwrite selectedTaskId from store - local state is authoritative
|
||||
// This prevents the reactive cascade when the store updates
|
||||
}
|
||||
|
||||
// Get message IDs for currently selected task
|
||||
const currentTaskId = selectedTaskId();
|
||||
if (currentTaskId) {
|
||||
const task = visibleTasks().find(t => t.id === currentTaskId);
|
||||
if (task) {
|
||||
const store = getMessageStore();
|
||||
if (task.taskSessionId) {
|
||||
setMessageIds(store.getSessionMessageIds(task.taskSessionId));
|
||||
} else {
|
||||
setMessageIds(task.messageIds || []);
|
||||
}
|
||||
} else {
|
||||
setMessageIds([]);
|
||||
}
|
||||
} else {
|
||||
setMessageIds([]);
|
||||
}
|
||||
|
||||
const taskSession = getActiveTaskSession();
|
||||
if (taskSession?.model?.modelId) {
|
||||
setCachedModelId(taskSession.model.modelId);
|
||||
}
|
||||
if (taskSession?.agent) {
|
||||
setCachedAgent(taskSession.agent);
|
||||
}
|
||||
|
||||
const store = getMessageStore();
|
||||
const usage = store.getSessionUsage(props.sessionId);
|
||||
if (usage) {
|
||||
setCachedTokensUsed(usage.actualUsageTokens ?? 0);
|
||||
setCachedCost(usage.totalCost ?? 0);
|
||||
}
|
||||
|
||||
const ids = messageIds();
|
||||
if (ids.length > 0) {
|
||||
const lastMsg = store.getMessage(ids[ids.length - 1]);
|
||||
setIsAgentThinking(
|
||||
lastMsg?.role === "assistant" &&
|
||||
(lastMsg.status === "streaming" || lastMsg.status === "sending")
|
||||
);
|
||||
|
||||
// Calculate lastAssistantIndex
|
||||
let lastIdx = -1;
|
||||
for (let i = ids.length - 1; i >= 0; i--) {
|
||||
const msg = store.getMessage(ids[i]);
|
||||
if (msg?.role === "assistant") {
|
||||
lastIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setLastAssistantIndex(lastIdx);
|
||||
} else {
|
||||
setIsAgentThinking(false);
|
||||
setLastAssistantIndex(-1);
|
||||
}
|
||||
|
||||
const suggestion = getCompactionSuggestion(props.instanceId, getActiveTaskSessionId());
|
||||
setCompactionSuggestion(suggestion);
|
||||
|
||||
setSoloState(getSoloState(props.instanceId));
|
||||
} catch (e) {
|
||||
log.error("syncFromStore error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LIFECYCLE
|
||||
// ============================================================================
|
||||
onMount(() => {
|
||||
setForceResetFn(() => {
|
||||
forceReset();
|
||||
// Clear all sending states on force reset
|
||||
setSendingTasks(new Set<string>());
|
||||
});
|
||||
|
||||
syncFromStore();
|
||||
|
||||
const interval = setInterval(syncFromStore, 150);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollContainer) return;
|
||||
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 50;
|
||||
setUserScrolling(!isAtBottom);
|
||||
};
|
||||
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
scrollContainer?.removeEventListener('scroll', handleScroll);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ACTIONS
|
||||
// ============================================================================
|
||||
const scrollToBottom = () => {
|
||||
if (scrollContainer && !userScrolling()) {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const setSelectedTaskId = (id: string | null) => {
|
||||
// Update local state immediately (fast)
|
||||
setSelectedTaskIdLocal(id);
|
||||
|
||||
// Immediately sync to load the new task's agent/model
|
||||
syncFromStore();
|
||||
|
||||
// Defer the global store update using idle callback (non-blocking)
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(() => {
|
||||
setActiveTask(props.instanceId, props.sessionId, id || undefined);
|
||||
}, { timeout: 500 });
|
||||
} else {
|
||||
// Fallback: use setTimeout with longer delay
|
||||
setTimeout(() => {
|
||||
setActiveTask(props.instanceId, props.sessionId, id || undefined);
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const message = chatInput().trim();
|
||||
if (!message) return;
|
||||
|
||||
// Check if THIS specific task is already sending
|
||||
const currentTaskId = selectedTaskId();
|
||||
if (currentTaskId && sendingTasks().has(currentTaskId)) return;
|
||||
|
||||
const currentMessage = message;
|
||||
const currentAttachments = attachments();
|
||||
|
||||
batch(() => {
|
||||
setChatInput("");
|
||||
setAttachments([]);
|
||||
});
|
||||
|
||||
// Track which task we're sending for (might be created below)
|
||||
let taskIdForSending: string | null = null;
|
||||
|
||||
try {
|
||||
let taskId = currentTaskId;
|
||||
let targetSessionId = props.sessionId;
|
||||
|
||||
if (!taskId) {
|
||||
// Create new task
|
||||
const title = currentMessage.length > 30 ? currentMessage.substring(0, 27) + "..." : currentMessage;
|
||||
log.info("[MultiX] Creating task...", { title });
|
||||
const result = await addTask(props.instanceId, props.sessionId, title);
|
||||
taskId = result.id;
|
||||
targetSessionId = result.taskSessionId || props.sessionId;
|
||||
log.info("[MultiX] Task created", { taskId, targetSessionId, hasTaskSession: !!result.taskSessionId });
|
||||
|
||||
// Immediately sync to get the new task in our local state
|
||||
syncFromStore();
|
||||
|
||||
// Set the selected task
|
||||
setSelectedTaskIdLocal(taskId);
|
||||
|
||||
const s = soloState();
|
||||
if (s.isAutonomous) {
|
||||
if (!s.activeTaskId) {
|
||||
setActiveTaskId(props.instanceId, taskId);
|
||||
} else {
|
||||
addToTaskQueue(props.instanceId, taskId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Existing task - get up-to-date task info
|
||||
syncFromStore();
|
||||
const task = visibleTasks().find(t => t.id === taskId);
|
||||
targetSessionId = task?.taskSessionId || props.sessionId;
|
||||
log.info("[MultiX] Existing task", { taskId, targetSessionId });
|
||||
}
|
||||
|
||||
// Mark THIS task as sending
|
||||
taskIdForSending = taskId;
|
||||
setTaskSending(taskId, true);
|
||||
|
||||
log.info("[MultiX] Sending message", { instanceId: props.instanceId, targetSessionId, messageLength: currentMessage.length, taskId });
|
||||
|
||||
// Send the message (this is async and will stream)
|
||||
await sendMessage(props.instanceId, targetSessionId, currentMessage, currentAttachments, taskId || undefined);
|
||||
log.info("[MultiX] Message sent successfully");
|
||||
|
||||
// Force sync after message is sent to pick up the new messages
|
||||
setTimeout(() => syncFromStore(), 100);
|
||||
setTimeout(() => syncFromStore(), 500);
|
||||
setTimeout(() => syncFromStore(), 1000);
|
||||
|
||||
setTimeout(scrollToBottom, 150);
|
||||
} catch (error) {
|
||||
log.error("Send failed:", error);
|
||||
console.error("[MultiX] Send failed:", error);
|
||||
} finally {
|
||||
// Clear sending state for this specific task
|
||||
if (taskIdForSending) {
|
||||
setTaskSending(taskIdForSending, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTask = () => {
|
||||
// Allow creating new tasks even when other tasks are processing
|
||||
const nextIndex = tasks().length + 1;
|
||||
const title = `Task ${nextIndex} `;
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const result = await addTask(props.instanceId, props.sessionId, title);
|
||||
setSelectedTaskIdLocal(result.id);
|
||||
setTimeout(() => syncFromStore(), 50);
|
||||
} catch (error) {
|
||||
log.error("handleCreateTask failed", error);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopAgent = async (e?: MouseEvent) => {
|
||||
if (e?.shiftKey) {
|
||||
forceReset();
|
||||
// Clear all sending states on force reset
|
||||
setSendingTasks(new Set<string>());
|
||||
return;
|
||||
}
|
||||
|
||||
const task = getSelectedTask();
|
||||
// If no task selected, we might be in global pipeline, use sessionId
|
||||
const targetSessionId = task?.taskSessionId || props.sessionId;
|
||||
const taskId = task?.id || selectedTaskId();
|
||||
|
||||
try {
|
||||
await abortSession(props.instanceId, targetSessionId);
|
||||
|
||||
// Manually force UI update
|
||||
if (taskId) {
|
||||
setTaskSending(taskId, false);
|
||||
}
|
||||
setIsAgentThinking(false);
|
||||
|
||||
setTimeout(() => syncFromStore(), 50);
|
||||
} catch (error) {
|
||||
log.error("Failed to stop agent", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompact = async () => {
|
||||
const targetSessionId = getActiveTaskSessionId();
|
||||
if (isCompacting()) return;
|
||||
|
||||
// Get message count to verify we have messages to compact
|
||||
const store = getMessageStore();
|
||||
const msgIds = store.getSessionMessageIds(targetSessionId);
|
||||
|
||||
log.info("[MultiX] Starting compaction", {
|
||||
instanceId: props.instanceId,
|
||||
sessionId: targetSessionId,
|
||||
messageCount: msgIds.length
|
||||
});
|
||||
|
||||
if (msgIds.length < 3) {
|
||||
log.info("[MultiX] Session too small to compact", { count: msgIds.length });
|
||||
toast.success("Session is already concise. No compaction needed.", {
|
||||
icon: <Zap size={14} class="text-amber-400" />
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCompacting(true);
|
||||
const toastId = toast.loading("Compacting session history...");
|
||||
try {
|
||||
clearCompactionSuggestion(props.instanceId, targetSessionId);
|
||||
const result = await compactSession(props.instanceId, targetSessionId);
|
||||
|
||||
// CRITICAL: Restore the parent session as active to prevent navigation away from MultiX
|
||||
const currentActive = activeSessionId().get(props.instanceId);
|
||||
if (currentActive !== props.sessionId) {
|
||||
setActiveSession(props.instanceId, props.sessionId);
|
||||
}
|
||||
log.info("[MultiX] Compaction complete", {
|
||||
success: result.success,
|
||||
tokenBefore: result.token_before,
|
||||
tokenAfter: result.token_after,
|
||||
reduction: result.token_reduction_pct
|
||||
});
|
||||
|
||||
toast.success(`Compacted! Reduced by ${result.token_reduction_pct}% (${result.token_after} tokens)`, {
|
||||
id: toastId,
|
||||
duration: 4000
|
||||
});
|
||||
|
||||
// Sync to update UI after compaction
|
||||
syncFromStore();
|
||||
} catch (error) {
|
||||
log.error("Failed to compact session", error);
|
||||
toast.error("Compaction failed. Please try again.", { id: toastId });
|
||||
} finally {
|
||||
setIsCompacting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdvancedSettings = () => {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_ADVANCED_SETTINGS_EVENT, {
|
||||
detail: { instanceId: props.instanceId, sessionId: props.sessionId }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEnhancePrompt = async () => {
|
||||
const input = chatInput().trim();
|
||||
if (!input || isEnhancing()) return;
|
||||
|
||||
setIsEnhancing(true);
|
||||
try {
|
||||
// Pass sessionId so it uses the task's configured model
|
||||
const taskSessionId = getActiveTaskSessionId();
|
||||
const enhanced = await enhancePrompt(input, props.instanceId, taskSessionId);
|
||||
setChatInput(enhanced);
|
||||
} catch (error) {
|
||||
log.error("Prompt enhancement failed", error);
|
||||
} finally {
|
||||
setIsEnhancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleApexPro = () => {
|
||||
const s = soloState();
|
||||
const currentState = s.isAutonomous && s.autoApproval;
|
||||
if (currentState) {
|
||||
if (s.isAutonomous) toggleAutonomous(props.instanceId);
|
||||
if (s.autoApproval) toggleAutoApproval(props.instanceId);
|
||||
} else {
|
||||
if (!s.isAutonomous) toggleAutonomous(props.instanceId);
|
||||
if (!s.autoApproval) toggleAutoApproval(props.instanceId);
|
||||
}
|
||||
};
|
||||
|
||||
const isApexPro = () => {
|
||||
const s = soloState();
|
||||
return s.isAutonomous && s.autoApproval;
|
||||
};
|
||||
|
||||
const handleArchiveTask = (taskId: string) => {
|
||||
archiveTask(props.instanceId, props.sessionId, taskId);
|
||||
};
|
||||
|
||||
const addAttachment = (attachment: ReturnType<typeof createFileAttachment>) => {
|
||||
setAttachments((prev) => [...prev, attachment]);
|
||||
};
|
||||
|
||||
const removeAttachment = (attachmentId: string) => {
|
||||
setAttachments((prev) => prev.filter((item) => item.id !== attachmentId));
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
Array.from(input.files).forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const buffer = reader.result instanceof ArrayBuffer ? reader.result : null;
|
||||
const data = buffer ? new Uint8Array(buffer) : undefined;
|
||||
const attachment = createFileAttachment(file.name, file.name, file.type || "application/octet-stream", data);
|
||||
if (file.type.startsWith("image/") && typeof reader.result === "string") {
|
||||
attachment.url = reader.result;
|
||||
}
|
||||
addAttachment(attachment);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
const handleTabClick = (messageId: string) => {
|
||||
const anchorId = `message-anchor-${messageId}`;
|
||||
const element = scrollContainer?.querySelector(`#${anchorId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
element.classList.add("message-highlight");
|
||||
setTimeout(() => element.classList.remove("message-highlight"), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RENDER (Gemini 3 Pro)
|
||||
// ============================================================================
|
||||
return (
|
||||
<div class="absolute inset-0 flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
|
||||
<DebugOverlay />
|
||||
|
||||
{/* ===== GEMINI 3 PRO HEADER ===== */}
|
||||
<header class="h-12 px-2 flex items-center justify-between bg-[#0a0a0b]/90 backdrop-blur-xl border-b border-white/5 relative z-30 shrink-0 select-none">
|
||||
<div class="flex items-center gap-2 overflow-hidden flex-1">
|
||||
{/* Brand / Mode Indicator */}
|
||||
<div class="flex items-center gap-2 px-2 py-1 rounded-md text-zinc-400">
|
||||
<Layers size={14} class="text-indigo-500" />
|
||||
<span class="text-[11px] font-bold tracking-wider text-zinc-300">MULTIX</span>
|
||||
</div>
|
||||
|
||||
<div class="h-4 w-px bg-white/5 shrink-0" />
|
||||
|
||||
{/* Pipeline / Task Switcher */}
|
||||
<div class="flex items-center gap-1 overflow-x-auto no-scrollbar mask-linear-fade">
|
||||
{/* Pipeline Tab */}
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(null)}
|
||||
class={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all border ${!selectedTaskId()
|
||||
? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20 shadow-[0_0_10px_rgba(99,102,241,0.1)]"
|
||||
: "text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span class="font-mono">PIPELINE</span>
|
||||
</button>
|
||||
|
||||
{/* Active Tasks */}
|
||||
<For each={visibleTasks()}>
|
||||
{(task) => (
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
class={`group flex items-center gap-2 px-3 py-1.5 rounded-lg text-[10px] font-bold transition-all border max-w-[140px] ${selectedTaskId() === task.id
|
||||
? "bg-zinc-800 text-zinc-100 border-zinc-700 shadow-lg"
|
||||
: "text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-white/5"
|
||||
} ${task.title.toLowerCase().includes("smart fix") ? "smart-fix-highlight" : ""}`}
|
||||
>
|
||||
<div class={`w-1.5 h-1.5 rounded-full ${task.status === "completed" ? "bg-emerald-500" :
|
||||
task.status === "interrupted" ? "bg-rose-500" :
|
||||
"bg-indigo-500 animate-pulse"
|
||||
}`} />
|
||||
<span class="truncate">{task.title}</span>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); handleArchiveTask(task.id); }}
|
||||
class="opacity-0 group-hover:opacity-100 hover:text-red-400 transition-opacity"
|
||||
>
|
||||
<X size={10} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{/* New Task */}
|
||||
<button
|
||||
onClick={handleCreateTask}
|
||||
class="w-6 h-6 flex items-center justify-center rounded-md text-zinc-600 hover:text-zinc-200 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Actions */}
|
||||
<div class="flex items-center gap-2 shrink-0 pl-4">
|
||||
{/* Stream Status */}
|
||||
<Show when={isAgentThinking()}>
|
||||
<div class="flex items-center gap-2 px-2 py-1 rounded-full bg-violet-500/10 border border-violet-500/20">
|
||||
<Loader2 size={10} class="animate-spin text-violet-400" />
|
||||
<span class="text-[9px] font-mono text-violet-300">{formatTokenTotal(cachedTokensUsed())}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="h-4 w-px bg-white/5" />
|
||||
|
||||
{/* Tools */}
|
||||
<button
|
||||
onClick={handleCompact}
|
||||
disabled={!selectedTaskId()}
|
||||
class="p-1.5 text-zinc-500 hover:text-zinc-200 hover:bg-white/5 rounded-md transition-colors disabled:opacity-30"
|
||||
title="Compact Context"
|
||||
>
|
||||
<FileArchive size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => emitSessionSidebarRequest({ instanceId: props.instanceId, action: "show-skills" })}
|
||||
class="p-1.5 text-zinc-500 hover:text-indigo-300 hover:bg-indigo-500/10 rounded-md transition-colors"
|
||||
title="Skills"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ===== AGENT/MODEL SELECTORS (LITE VERSIONS - PER TASK) ===== */}
|
||||
<Show when={getSelectedTask()}>
|
||||
<div class="px-4 py-3 border-b border-white/5 bg-[#0a0a0b]">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<LiteAgentSelector
|
||||
instanceId={props.instanceId}
|
||||
sessionId={getActiveTaskSessionId()}
|
||||
currentAgent={cachedAgent()}
|
||||
onAgentChange={(agent) => {
|
||||
// Update the TASK's session, not a global cache
|
||||
const taskSessionId = getActiveTaskSessionId();
|
||||
log.info("[MultiX] Changing agent for task session", { taskSessionId, agent });
|
||||
updateSessionAgent(props.instanceId, taskSessionId, agent);
|
||||
// Force immediate sync to reflect the change
|
||||
setTimeout(() => syncFromStore(), 50);
|
||||
}}
|
||||
/>
|
||||
<LiteModelSelector
|
||||
instanceId={props.instanceId}
|
||||
sessionId={getActiveTaskSessionId()}
|
||||
currentModel={{ providerId: "", modelId: cachedModelId() }}
|
||||
onModelChange={(model) => {
|
||||
// Update the TASK's session, not a global cache
|
||||
const taskSessionId = getActiveTaskSessionId();
|
||||
log.info("[MultiX] Changing model for task session", { taskSessionId, model });
|
||||
updateSessionModelForSession(props.instanceId, taskSessionId, model);
|
||||
// Force immediate sync to reflect the change
|
||||
setTimeout(() => syncFromStore(), 50);
|
||||
}}
|
||||
/>
|
||||
<LiteSkillsSelector
|
||||
instanceId={props.instanceId}
|
||||
sessionId={getActiveTaskSessionId()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ===== MAIN CONTENT AREA (Row Layout) ===== */}
|
||||
<div class="flex-1 flex flex-row min-h-0 relative bg-[#050505] overflow-hidden w-full h-full">
|
||||
|
||||
{/* Chat Column */}
|
||||
<div class="flex-1 min-h-0 flex flex-col overflow-hidden relative">
|
||||
<div ref={scrollContainer} class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar">
|
||||
{/* Compaction Suggestion Banner */}
|
||||
<Show when={compactionSuggestion()}>
|
||||
<div class="mx-3 mt-3 mb-1 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-[11px] text-emerald-200 flex items-center justify-between gap-3">
|
||||
<span class="font-semibold">Compact suggested: {compactionSuggestion()?.reason}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 rounded-lg text-[10px] font-bold uppercase tracking-wide bg-emerald-500/20 border border-emerald-500/40 text-emerald-200 hover:bg-emerald-500/30 transition-colors"
|
||||
onClick={handleCompact}
|
||||
>
|
||||
Compact now
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!selectedTaskId()} fallback={
|
||||
/* Message List - Using full MessageBlockList for proper streaming */
|
||||
<div class="min-h-full pb-4">
|
||||
<MessageBlockList
|
||||
instanceId={props.instanceId}
|
||||
sessionId={getActiveTaskSessionId()}
|
||||
store={getMessageStore}
|
||||
messageIds={() => messageIds()}
|
||||
lastAssistantIndex={() => lastAssistantIndex()}
|
||||
showThinking={() => true}
|
||||
thinkingDefaultExpanded={() => true}
|
||||
showUsageMetrics={() => true}
|
||||
scrollContainer={() => scrollContainer}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
/>
|
||||
{/* Bottom anchor */}
|
||||
<div id="bottom-anchor" class="h-10 w-full" />
|
||||
</div>
|
||||
}>
|
||||
{/* Pipeline View */}
|
||||
<PipelineView
|
||||
visibleTasks={visibleTasks}
|
||||
onTaskClick={setSelectedTaskId}
|
||||
onArchiveTask={handleArchiveTask}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* ===== INPUT AREA ===== */}
|
||||
<div class="p-4 bg-[#0a0a0b] border-t border-white/5 shrink-0 z-20">
|
||||
{/* Input Container */}
|
||||
<div class="w-full bg-zinc-900/50 border border-white/10 rounded-2xl shadow-sm overflow-hidden focus-within:border-indigo-500/30 transition-all">
|
||||
{/* Input Header Row */}
|
||||
<div class="flex items-center justify-between px-3 pt-2 pb-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[10px] font-bold text-zinc-400 uppercase tracking-wide">
|
||||
{selectedTaskId() ? "Task Context" : "Global Pipeline"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
{/* APEX / Shield Toggles */}
|
||||
<button
|
||||
onClick={() => toggleApex(props.instanceId)}
|
||||
title="Apex"
|
||||
class={`p-1 rounded transition-colors ${soloState().isApex ? "text-rose-400 bg-rose-500/10" : "text-zinc-600 hover:text-zinc-400"}`}
|
||||
>
|
||||
<Zap size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleAutoApproval(props.instanceId)}
|
||||
title="Shield"
|
||||
class={`p-1 rounded transition-colors ${soloState().autoApproval ? "text-emerald-400 bg-emerald-500/10" : "text-zinc-600 hover:text-zinc-400"}`}
|
||||
>
|
||||
<Shield size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<Show when={attachments().length > 0}>
|
||||
<div class="flex flex-wrap gap-1.5 px-3 py-1">
|
||||
<For each={attachments()}>
|
||||
{(attachment) => (
|
||||
<AttachmentChip
|
||||
attachment={attachment}
|
||||
onRemove={() => removeAttachment(attachment.id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Text Input */}
|
||||
<textarea
|
||||
value={chatInput()}
|
||||
onInput={(e) => {
|
||||
setChatInput(e.currentTarget.value);
|
||||
e.currentTarget.style.height = "auto";
|
||||
e.currentTarget.style.height = e.currentTarget.scrollHeight + "px";
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={selectedTaskId() ? "Message agent..." : "Start a new task..."}
|
||||
class="w-full bg-transparent text-zinc-200 placeholder-zinc-500 text-sm p-3 outline-none resize-none max-h-[300px] min-h-[44px]"
|
||||
rows={1}
|
||||
disabled={isSending()}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div class="flex items-center justify-between px-2 pb-2 mt-1 border-t border-white/5 pt-2 bg-zinc-900/30">
|
||||
<div class="flex items-center space-x-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef?.click()}
|
||||
class="p-1.5 text-zinc-500 hover:text-zinc-300 rounded hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEnhancePrompt}
|
||||
disabled={!chatInput().trim() || isEnhancing()}
|
||||
class={`p-1.5 rounded hover:bg-white/5 transition-colors ${isEnhancing() ? "text-amber-400 animate-pulse" : "text-zinc-500 hover:text-amber-300"}`}
|
||||
>
|
||||
<Wand2 size={14} class={isEnhancing() ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-[9px] text-zinc-600 font-mono hidden md:block">
|
||||
{cachedModelId()}
|
||||
</div>
|
||||
|
||||
{/* Stop Button (visible when agent is thinking) */}
|
||||
<Show when={isAgentThinking() || isSending()}>
|
||||
<button
|
||||
onClick={handleStopAgent}
|
||||
class="p-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30 rounded-lg transition-all shadow-sm"
|
||||
title="Stop Agent (Shift+Click = Force Reset)"
|
||||
>
|
||||
<StopCircle size={14} strokeWidth={2.5} />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* Send Button */}
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={(!chatInput().trim() && attachments().length === 0) || isSending()}
|
||||
class="p-1.5 bg-zinc-100 hover:bg-white text-black rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm"
|
||||
>
|
||||
<Show when={isSending()} fallback={<ArrowUp size={14} strokeWidth={3} />}>
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar (Right) */}
|
||||
<Show when={selectedTaskId() && messageIds().length > 0}>
|
||||
<MessageNavSidebar
|
||||
messageIds={messageIds}
|
||||
store={getMessageStore}
|
||||
scrollContainer={scrollContainer}
|
||||
onTabClick={handleTabClick}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
packages/ui/src/components/code-block-inline.tsx
Normal file
107
packages/ui/src/components/code-block-inline.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||
|
||||
const inlineLoadedLanguages = new Set<string>()
|
||||
|
||||
type LoadLanguageArg = Parameters<Highlighter["loadLanguage"]>[0]
|
||||
type CodeToHtmlOptions = Parameters<Highlighter["codeToHtml"]>[1]
|
||||
|
||||
interface CodeBlockInlineProps {
|
||||
code: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [html, setHtml] = createSignal("")
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [ready, setReady] = createSignal(false)
|
||||
let highlighter: Highlighter | null = null
|
||||
|
||||
onMount(async () => {
|
||||
highlighter = await getSharedHighlighter()
|
||||
setReady(true)
|
||||
await updateHighlight()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (ready()) {
|
||||
isDark()
|
||||
props.code
|
||||
props.language
|
||||
void updateHighlight()
|
||||
}
|
||||
})
|
||||
|
||||
const updateHighlight = async () => {
|
||||
if (!highlighter) return
|
||||
|
||||
if (!props.language) {
|
||||
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const language = props.language as LoadLanguageArg
|
||||
if (!inlineLoadedLanguages.has(props.language)) {
|
||||
await highlighter.loadLanguage(language)
|
||||
inlineLoadedLanguages.add(props.language)
|
||||
}
|
||||
|
||||
const highlighted = highlighter.codeToHtml(props.code, {
|
||||
lang: props.language as CodeToHtmlOptions["lang"],
|
||||
theme: isDark() ? "github-dark" : "github-light",
|
||||
})
|
||||
setHtml(highlighted)
|
||||
} catch {
|
||||
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
|
||||
}
|
||||
}
|
||||
|
||||
const copyCode = async () => {
|
||||
await navigator.clipboard.writeText(props.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={ready()}
|
||||
fallback={
|
||||
<pre class="tool-call-content">
|
||||
<code>{props.code}</code>
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
<div class="code-block-inline">
|
||||
<div class="code-block-header">
|
||||
<Show when={props.language}>
|
||||
<span class="code-block-language">{props.language}</span>
|
||||
</Show>
|
||||
<button onClick={copyCode} class="code-block-copy">
|
||||
<svg
|
||||
class="copy-icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div innerHTML={html()} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
287
packages/ui/src/components/command-palette.tsx
Normal file
287
packages/ui/src/components/command-palette.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import type { Command } from "../lib/commands"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
commands: Command[]
|
||||
onExecute: (command: Command) => void
|
||||
}
|
||||
|
||||
function buildShortcutString(shortcut: Command["shortcut"]): string {
|
||||
if (!shortcut) return ""
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (shortcut.meta || shortcut.ctrl) parts.push("cmd")
|
||||
if (shortcut.shift) parts.push("shift")
|
||||
if (shortcut.alt) parts.push("alt")
|
||||
parts.push(shortcut.key)
|
||||
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
|
||||
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
let listRef: HTMLDivElement | undefined
|
||||
|
||||
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
|
||||
|
||||
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
|
||||
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
|
||||
|
||||
const processedCommands = createMemo<ProcessedCommands>(() => {
|
||||
const source = props.commands ?? []
|
||||
const q = query().trim().toLowerCase()
|
||||
|
||||
const filtered = q
|
||||
? source.filter((cmd) => {
|
||||
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
|
||||
const labelMatch = label.toLowerCase().includes(q)
|
||||
const descMatch = cmd.description.toLowerCase().includes(q)
|
||||
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q))
|
||||
const categoryMatch = cmd.category?.toLowerCase().includes(q)
|
||||
return labelMatch || descMatch || keywordMatch || categoryMatch
|
||||
})
|
||||
: source
|
||||
|
||||
const groupsMap = new Map<string, Command[]>()
|
||||
for (const cmd of filtered) {
|
||||
const category = cmd.category || "Other"
|
||||
const list = groupsMap.get(category)
|
||||
if (list) {
|
||||
list.push(cmd)
|
||||
} else {
|
||||
groupsMap.set(category, [cmd])
|
||||
}
|
||||
}
|
||||
|
||||
const groups: CommandGroup[] = []
|
||||
const ordered: Command[] = []
|
||||
const processedCategories = new Set<string>()
|
||||
|
||||
const addGroup = (category: string) => {
|
||||
const cmds = groupsMap.get(category)
|
||||
if (!cmds || cmds.length === 0 || processedCategories.has(category)) return
|
||||
groups.push({ category, commands: cmds, startIndex: ordered.length })
|
||||
ordered.push(...cmds)
|
||||
processedCategories.add(category)
|
||||
}
|
||||
|
||||
for (const category of categoryOrder) {
|
||||
addGroup(category)
|
||||
}
|
||||
|
||||
for (const [category] of groupsMap) {
|
||||
addGroup(category)
|
||||
}
|
||||
|
||||
return { groups, ordered }
|
||||
})
|
||||
|
||||
const groupedCommandList = () => processedCommands().groups
|
||||
const orderedCommands = () => processedCommands().ordered
|
||||
const selectedIndex = createMemo(() => {
|
||||
const ordered = orderedCommands()
|
||||
if (ordered.length === 0) return -1
|
||||
const id = selectedCommandId()
|
||||
if (!id) return 0
|
||||
const index = ordered.findIndex((cmd) => cmd.id === id)
|
||||
return index >= 0 ? index : 0
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
setQuery("")
|
||||
setSelectedCommandId(null)
|
||||
setIsPointerSelecting(false)
|
||||
setTimeout(() => inputRef?.focus(), 100)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const ordered = orderedCommands()
|
||||
if (ordered.length === 0) {
|
||||
if (selectedCommandId() !== null) {
|
||||
setSelectedCommandId(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const currentId = selectedCommandId()
|
||||
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
||||
setSelectedCommandId(ordered[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
const index = selectedIndex()
|
||||
if (!listRef || index < 0) return
|
||||
|
||||
const selectedButton = listRef.querySelector(`[data-command-index="${index}"]`) as HTMLElement
|
||||
if (selectedButton) {
|
||||
selectedButton.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}
|
||||
})
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const ordered = orderedCommands()
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (ordered.length === 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsPointerSelecting(false)
|
||||
const current = selectedIndex()
|
||||
const nextIndex = Math.min((current < 0 ? 0 : current) + 1, ordered.length - 1)
|
||||
setSelectedCommandId(ordered[nextIndex]?.id ?? null)
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsPointerSelecting(false)
|
||||
const current = selectedIndex()
|
||||
const nextIndex = current <= 0 ? ordered.length - 1 : current - 1
|
||||
setSelectedCommandId(ordered[nextIndex]?.id ?? null)
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const index = selectedIndex()
|
||||
if (index < 0 || index >= ordered.length) return
|
||||
const command = ordered[index]
|
||||
if (!command) return
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommandClick(command: Command) {
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
setIsPointerSelecting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
||||
<Dialog.Content
|
||||
class="modal-surface w-full max-w-2xl max-h-[60vh]"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
|
||||
|
||||
<div class="modal-search-container">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 modal-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query()}
|
||||
onInput={(e) => {
|
||||
setQuery(e.currentTarget.value)
|
||||
setSelectedCommandId(null)
|
||||
}}
|
||||
placeholder="Type a command or search..."
|
||||
class="modal-search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
class="modal-list-container"
|
||||
data-pointer-mode={isPointerSelecting() ? "pointer" : "keyboard"}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Show
|
||||
when={orderedCommands().length > 0}
|
||||
fallback={<div class="modal-empty-state">No commands found for "{query()}"</div>}
|
||||
>
|
||||
<For each={groupedCommandList()}>
|
||||
{(group) => (
|
||||
<div class="py-2">
|
||||
<div class="modal-section-header">
|
||||
{group.category}
|
||||
</div>
|
||||
<For each={group.commands}>
|
||||
{(command, localIndex) => {
|
||||
const commandIndex = group.startIndex + localIndex()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-command-index={commandIndex}
|
||||
onClick={() => handleCommandClick(command)}
|
||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||
onPointerMove={(event) => {
|
||||
if (event.movementX === 0 && event.movementY === 0) return
|
||||
if (event.pointerType === "mouse" || event.pointerType === "pen" || event.pointerType === "touch") {
|
||||
if (!isPointerSelecting()) {
|
||||
setIsPointerSelecting(true)
|
||||
}
|
||||
setSelectedCommandId(command.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="modal-item-label">
|
||||
{typeof command.label === "function" ? command.label() : command.label}
|
||||
</div>
|
||||
<div class="modal-item-description">
|
||||
{command.description}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={command.shortcut}>
|
||||
<div class="mt-1">
|
||||
<Kbd shortcut={buildShortcutString(command.shortcut)} />
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandPalette
|
||||
100
packages/ui/src/components/debug-overlay.tsx
Normal file
100
packages/ui/src/components/debug-overlay.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createSignal, onMount, onCleanup, Show } from "solid-js"
|
||||
|
||||
// Simple debug log storage (no reactive overhead)
|
||||
export function addDebugLog(message: string, level: "info" | "warn" | "error" = "info") {
|
||||
// Disabled - no-op for performance
|
||||
}
|
||||
|
||||
// HARD STOP function - forces page reload
|
||||
function hardStop() {
|
||||
console.warn("HARD STOP triggered - reloading page")
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// Force reset function import placeholder
|
||||
let forceResetFn: (() => void) | null = null
|
||||
|
||||
export function setForceResetFn(fn: () => void) {
|
||||
forceResetFn = fn
|
||||
}
|
||||
|
||||
export function DebugOverlay() {
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
// Toggle with Ctrl+Shift+D
|
||||
onMount(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === "D") {
|
||||
setVisible((v) => !v)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => window.removeEventListener("keydown", handleKeyDown))
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={visible()}>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "10px",
|
||||
right: "10px",
|
||||
"z-index": "99999",
|
||||
background: "rgba(0,0,0,0.9)",
|
||||
color: "#fff",
|
||||
padding: "12px",
|
||||
"border-radius": "8px",
|
||||
"font-family": "monospace",
|
||||
"font-size": "11px",
|
||||
"min-width": "200px",
|
||||
border: "1px solid #333",
|
||||
"pointer-events": "auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ "margin-bottom": "8px", "font-weight": "bold" }}>
|
||||
DEBUG PANEL (Ctrl+Shift+D to toggle)
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (forceResetFn) forceResetFn()
|
||||
}}
|
||||
style={{
|
||||
background: "#f59e0b",
|
||||
color: "#000",
|
||||
border: "none",
|
||||
padding: "6px 12px",
|
||||
"border-radius": "4px",
|
||||
cursor: "pointer",
|
||||
"font-weight": "bold",
|
||||
"font-size": "10px",
|
||||
}}
|
||||
>
|
||||
RESET UI
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={hardStop}
|
||||
style={{
|
||||
background: "#ef4444",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
padding: "6px 12px",
|
||||
"border-radius": "4px",
|
||||
cursor: "pointer",
|
||||
"font-weight": "bold",
|
||||
"font-size": "10px",
|
||||
}}
|
||||
>
|
||||
HARD RELOAD
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ "margin-top": "8px", "font-size": "9px", color: "#888" }}>
|
||||
If stuck: Click HARD RELOAD or press F5
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
137
packages/ui/src/components/diff-viewer.tsx
Normal file
137
packages/ui/src/components/diff-viewer.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
import { disableCache } from "@git-diff-view/core"
|
||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||
import { ErrorBoundary } from "solid-js"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setCacheEntry } from "../lib/global-cache"
|
||||
import type { CacheEntryParams } from "../lib/global-cache"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
disableCache()
|
||||
|
||||
interface ToolCallDiffViewerProps {
|
||||
diffText: string
|
||||
filePath?: string
|
||||
theme: "light" | "dark"
|
||||
mode: DiffViewMode
|
||||
onRendered?: () => void
|
||||
cachedHtml?: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
}
|
||||
|
||||
type DiffData = {
|
||||
oldFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null }
|
||||
newFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null }
|
||||
hunks: string[]
|
||||
}
|
||||
|
||||
type CaptureContext = {
|
||||
theme: ToolCallDiffViewerProps["theme"]
|
||||
mode: DiffViewMode
|
||||
diffText: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
}
|
||||
|
||||
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
const diffData = createMemo<DiffData | null>(() => {
|
||||
const normalized = normalizeDiffText(props.diffText)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
const language = getLanguageFromPath(props.filePath) || "text"
|
||||
const fileName = props.filePath || "diff"
|
||||
|
||||
return {
|
||||
oldFile: {
|
||||
fileName,
|
||||
fileLang: (language || "text") as DiffHighlighterLang | null,
|
||||
},
|
||||
newFile: {
|
||||
fileName,
|
||||
fileLang: (language || "text") as DiffHighlighterLang | null,
|
||||
},
|
||||
hunks: [normalized],
|
||||
}
|
||||
})
|
||||
|
||||
let diffContainerRef: HTMLDivElement | undefined
|
||||
let lastCapturedKey: string | undefined
|
||||
|
||||
const contextKey = createMemo(() => {
|
||||
const data = diffData()
|
||||
if (!data) return ""
|
||||
return `${props.theme}|${props.mode}|${props.diffText}`
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const cachedHtml = props.cachedHtml
|
||||
if (cachedHtml) {
|
||||
// When we are given cached HTML, we rely on the caller's cache
|
||||
// and simply notify once rendered.
|
||||
props.onRendered?.()
|
||||
return
|
||||
}
|
||||
|
||||
const key = contextKey()
|
||||
if (!key) return
|
||||
if (!diffContainerRef) return
|
||||
if (lastCapturedKey === key) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!diffContainerRef) return
|
||||
const markup = diffContainerRef.innerHTML
|
||||
if (!markup) return
|
||||
lastCapturedKey = key
|
||||
if (props.cacheEntryParams) {
|
||||
setCacheEntry(props.cacheEntryParams, {
|
||||
text: props.diffText,
|
||||
html: markup,
|
||||
theme: props.theme,
|
||||
mode: props.mode,
|
||||
})
|
||||
}
|
||||
props.onRendered?.()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<div class="tool-call-diff-viewer">
|
||||
<Show
|
||||
when={props.cachedHtml}
|
||||
fallback={
|
||||
<div ref={diffContainerRef}>
|
||||
<Show
|
||||
when={diffData()}
|
||||
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
|
||||
>
|
||||
{(data) => (
|
||||
<ErrorBoundary fallback={(error) => {
|
||||
log.warn("Failed to render diff view", error)
|
||||
return <pre class="tool-call-diff-fallback">{props.diffText}</pre>
|
||||
}}>
|
||||
<DiffView
|
||||
data={data()}
|
||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||
diffViewTheme={props.theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={false}
|
||||
diffViewFontSize={13}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div innerHTML={props.cachedHtml} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
375
packages/ui/src/components/directory-browser-dialog.tsx
Normal file
375
packages/ui/src/components/directory-browser-dialog.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
|
||||
function normalizePathKey(input?: string | null) {
|
||||
if (!input || input === "." || input === "./") {
|
||||
return "."
|
||||
}
|
||||
if (input === WINDOWS_DRIVES_ROOT) {
|
||||
return WINDOWS_DRIVES_ROOT
|
||||
}
|
||||
let normalized = input.replace(/\\/g, "/")
|
||||
if (/^[a-zA-Z]:/.test(normalized)) {
|
||||
const [drive, rest = ""] = normalized.split(":")
|
||||
const suffix = rest.startsWith("/") ? rest : rest ? `/${rest}` : "/"
|
||||
return `${drive.toUpperCase()}:${suffix.replace(/\/+/g, "/")}`
|
||||
}
|
||||
if (normalized.startsWith("//")) {
|
||||
return `//${normalized.slice(2).replace(/\/+/g, "/")}`
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
return `/${normalized.slice(1).replace(/\/+/g, "/")}`
|
||||
}
|
||||
normalized = normalized.replace(/^\.\/+/, "").replace(/\/+/g, "/")
|
||||
return normalized === "" ? "." : normalized
|
||||
}
|
||||
|
||||
|
||||
function isAbsolutePathLike(input: string) {
|
||||
return input.startsWith("/") || /^[a-zA-Z]:/.test(input) || input.startsWith("\\\\")
|
||||
}
|
||||
|
||||
interface DirectoryBrowserDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description?: string
|
||||
onSelect: (absolutePath: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function resolveAbsolutePath(root: string, relativePath: string) {
|
||||
if (!root) {
|
||||
return relativePath
|
||||
}
|
||||
if (!relativePath || relativePath === "." || relativePath === "./") {
|
||||
return root
|
||||
}
|
||||
if (isAbsolutePathLike(relativePath)) {
|
||||
return relativePath
|
||||
}
|
||||
const separator = root.includes("\\") ? "\\" : "/"
|
||||
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
|
||||
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
|
||||
return `${trimmedRoot}${normalized}`
|
||||
}
|
||||
|
||||
type FolderRow =
|
||||
| { type: "up"; path: string }
|
||||
| { type: "folder"; entry: FileSystemEntry }
|
||||
|
||||
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
|
||||
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
|
||||
|
||||
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
||||
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
|
||||
|
||||
function resetState() {
|
||||
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
|
||||
setLoadingPaths(new Set<string>())
|
||||
setCurrentPathKey(null)
|
||||
setCurrentMetadata(null)
|
||||
metadataCache.clear()
|
||||
inFlightRequests.clear()
|
||||
setError(null)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) {
|
||||
return
|
||||
}
|
||||
resetState()
|
||||
void initialize()
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
})
|
||||
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const metadata = await loadDirectory()
|
||||
applyMetadata(metadata)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function applyMetadata(metadata: FileSystemListingMetadata) {
|
||||
const key = normalizePathKey(metadata.currentPath)
|
||||
setCurrentPathKey(key)
|
||||
setCurrentMetadata(metadata)
|
||||
setRootPath(metadata.rootPath)
|
||||
}
|
||||
|
||||
async function loadDirectory(targetPath?: string): Promise<FileSystemListingMetadata> {
|
||||
const key = targetPath ? normalizePathKey(targetPath) : undefined
|
||||
if (key) {
|
||||
const cached = metadataCache.get(key)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const pending = inFlightRequests.get(key)
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
if (key) {
|
||||
setLoadingPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const response = await serverApi.listFileSystem(targetPath, { includeFiles: false })
|
||||
const canonicalKey = normalizePathKey(response.metadata.currentPath)
|
||||
const directories = response.entries
|
||||
.filter((entry) => entry.type === "directory")
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
setDirectoryChildren((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(canonicalKey, directories)
|
||||
return next
|
||||
})
|
||||
|
||||
metadataCache.set(canonicalKey, response.metadata)
|
||||
|
||||
setLoadingPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (key) {
|
||||
next.delete(key)
|
||||
}
|
||||
next.delete(canonicalKey)
|
||||
return next
|
||||
})
|
||||
|
||||
return response.metadata
|
||||
})()
|
||||
.catch((err) => {
|
||||
if (key) {
|
||||
setLoadingPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
throw err
|
||||
})
|
||||
.finally(() => {
|
||||
if (key) {
|
||||
inFlightRequests.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
if (key) {
|
||||
inFlightRequests.set(key, request)
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
async function navigateTo(path?: string) {
|
||||
setError(null)
|
||||
try {
|
||||
const metadata = await loadDirectory(path)
|
||||
applyMetadata(metadata)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||
setError(message)
|
||||
}
|
||||
}
|
||||
|
||||
const folderRows = createMemo<FolderRow[]>(() => {
|
||||
const rows: FolderRow[] = []
|
||||
const metadata = currentMetadata()
|
||||
if (metadata?.parentPath) {
|
||||
rows.push({ type: "up", path: metadata.parentPath })
|
||||
}
|
||||
const key = currentPathKey()
|
||||
if (!key) {
|
||||
return rows
|
||||
}
|
||||
const children = directoryChildren().get(key) ?? []
|
||||
for (const entry of children) {
|
||||
rows.push({ type: "folder", entry })
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
function handleNavigateTo(path: string) {
|
||||
void navigateTo(path)
|
||||
}
|
||||
|
||||
function handleNavigateUp() {
|
||||
const parent = currentMetadata()?.parentPath
|
||||
if (parent) {
|
||||
void navigateTo(parent)
|
||||
}
|
||||
}
|
||||
|
||||
const currentAbsolutePath = createMemo(() => {
|
||||
const metadata = currentMetadata()
|
||||
if (!metadata) {
|
||||
return ""
|
||||
}
|
||||
if (metadata.pathKind === "drives") {
|
||||
return ""
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
||||
}
|
||||
return metadata.displayPath
|
||||
})
|
||||
|
||||
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
|
||||
|
||||
function handleEntrySelect(entry: FileSystemEntry) {
|
||||
const absolutePath = entry.absolutePath
|
||||
? entry.absolutePath
|
||||
: isAbsolutePathLike(entry.path)
|
||||
? entry.path
|
||||
: resolveAbsolutePath(rootPath(), entry.path)
|
||||
props.onSelect(absolutePath)
|
||||
}
|
||||
|
||||
function isPathLoading(path: string) {
|
||||
return loadingPaths().has(normalizePathKey(path))
|
||||
}
|
||||
|
||||
function handleOverlayClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
|
||||
<div class="modal-surface directory-browser-modal" role="dialog" aria-modal="true">
|
||||
<div class="panel directory-browser-panel">
|
||||
<div class="directory-browser-header">
|
||||
<div class="directory-browser-heading">
|
||||
<h3 class="directory-browser-title">{props.title}</h3>
|
||||
<p class="directory-browser-description">
|
||||
{props.description || "Browse folders under the configured workspace root."}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="directory-browser-close" aria-label="Close" onClick={props.onClose}>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body directory-browser-body">
|
||||
<Show when={rootPath()}>
|
||||
<div class="directory-browser-current">
|
||||
<div class="directory-browser-current-meta">
|
||||
<span class="directory-browser-current-label">Current folder</span>
|
||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||
disabled={!canSelectCurrent()}
|
||||
onClick={() => {
|
||||
const absolute = currentAbsolutePath()
|
||||
if (absolute) {
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select Current
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!loading() && !error()}
|
||||
fallback={
|
||||
<div class="panel-empty-state flex-1">
|
||||
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
|
||||
<div class="directory-browser-loading">
|
||||
<Loader2 class="w-5 h-5 animate-spin" />
|
||||
<span>Loading folders…</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={folderRows().length > 0}
|
||||
fallback={<div class="panel-empty-state flex-1">No folders available.</div>}
|
||||
>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
|
||||
<For each={folderRows()}>
|
||||
{(item) => {
|
||||
const isFolder = item.type === "folder"
|
||||
const label = isFolder ? item.entry.name || item.entry.path : "Up one level"
|
||||
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
|
||||
return (
|
||||
<div class="panel-list-item" role="option">
|
||||
<div class="panel-list-item-content directory-browser-row">
|
||||
<button type="button" class="directory-browser-row-main" onClick={navigate}>
|
||||
<div class="directory-browser-row-icon">
|
||||
<Show when={!isFolder} fallback={<FolderIcon class="w-4 h-4" />}>
|
||||
<ArrowUpLeft class="w-4 h-4" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="directory-browser-row-text">
|
||||
<span class="directory-browser-row-name">{label}</span>
|
||||
</div>
|
||||
<Show when={isFolder && isPathLoading(item.entry.path)}>
|
||||
<Loader2 class="directory-browser-row-spinner animate-spin" />
|
||||
</Show>
|
||||
</button>
|
||||
{isFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleEntrySelect(item.entry)
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default DirectoryBrowserDialog
|
||||
51
packages/ui/src/components/empty-state.tsx
Normal file
51
packages/ui/src/components/empty-state.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Loader2 } from "lucide-solid"
|
||||
|
||||
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
interface EmptyStateProps {
|
||||
onSelectFolder: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const EmptyState: Component<EmptyStateProps> = (props) => {
|
||||
return (
|
||||
<div class="flex h-full w-full items-center justify-center bg-surface-secondary">
|
||||
<div class="max-w-[500px] px-8 py-12 text-center">
|
||||
<div class="mb-8 flex justify-center">
|
||||
<img src={codeNomadIcon} alt="CodeNomad logo" class="h-24 w-auto" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<h1 class="mb-3 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<p class="mb-8 text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
|
||||
|
||||
<button
|
||||
onClick={props.onSelectFolder}
|
||||
disabled={props.isLoading}
|
||||
class="mb-4 button-primary"
|
||||
>
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Selecting...
|
||||
</>
|
||||
) : (
|
||||
"Select Folder"
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p class="text-sm text-muted">
|
||||
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-1 text-sm text-muted">
|
||||
<p>Examples: ~/projects/my-app</p>
|
||||
<p>You can have multiple instances of the same folder</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
148
packages/ui/src/components/environment-variables-editor.tsx
Normal file
148
packages/ui/src/components/environment-variables-editor.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Component, createSignal, For, Show } from "solid-js"
|
||||
import { Plus, Trash2, Key, Globe } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
interface EnvironmentVariablesEditorProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||
const {
|
||||
preferences,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
updateEnvironmentVariables,
|
||||
} = useConfig()
|
||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
|
||||
const [newKey, setNewKey] = createSignal("")
|
||||
const [newValue, setNewValue] = createSignal("")
|
||||
|
||||
const entries = () => Object.entries(envVars())
|
||||
|
||||
function handleAddVariable() {
|
||||
const key = newKey().trim()
|
||||
const value = newValue().trim()
|
||||
|
||||
if (!key) return
|
||||
|
||||
addEnvironmentVariable(key, value)
|
||||
setEnvVars({ ...envVars(), [key]: value })
|
||||
setNewKey("")
|
||||
setNewValue("")
|
||||
}
|
||||
|
||||
function handleRemoveVariable(key: string) {
|
||||
removeEnvironmentVariable(key)
|
||||
const { [key]: removed, ...rest } = envVars()
|
||||
setEnvVars(rest)
|
||||
}
|
||||
|
||||
function handleUpdateVariable(key: string, value: string) {
|
||||
const updated = { ...envVars(), [key]: value }
|
||||
setEnvVars(updated)
|
||||
updateEnvironmentVariables(updated)
|
||||
}
|
||||
|
||||
function handleKeyPress(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleAddVariable()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Globe class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Environment Variables</span>
|
||||
<span class="text-xs text-muted">
|
||||
({entries().length} variable{entries().length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Existing variables */}
|
||||
<Show when={entries().length > 0}>
|
||||
<div class="space-y-2">
|
||||
<For each={entries()}>
|
||||
{([key, value]) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<Key class="w-3.5 h-3.5 icon-muted flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-secondary border border-base rounded text-muted cursor-not-allowed"
|
||||
placeholder="Variable name"
|
||||
title="Variable name (read-only)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
disabled={props.disabled}
|
||||
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Variable value"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveVariable(key)}
|
||||
disabled={props.disabled}
|
||||
class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Remove variable"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Add new variable */}
|
||||
<div class="flex items-center gap-2 pt-2 border-t border-base">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<Key class="w-3.5 h-3.5 icon-muted flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={newKey()}
|
||||
onInput={(e) => setNewKey(e.currentTarget.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Variable name"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newValue()}
|
||||
onInput={(e) => setNewValue(e.currentTarget.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Variable value"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddVariable}
|
||||
disabled={props.disabled || !newKey().trim()}
|
||||
class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Add variable"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={entries().length === 0}>
|
||||
<div class="text-xs text-muted text-center py-2">
|
||||
No environment variables configured. Add variables above to customize the OpenCode environment.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="text-xs text-muted mt-2">
|
||||
These variables will be available in the OpenCode environment when starting instances.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnvironmentVariablesEditor
|
||||
451
packages/ui/src/components/filesystem-browser-dialog.tsx
Normal file
451
packages/ui/src/components/filesystem-browser-dialog.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid"
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
const MAX_RESULTS = 200
|
||||
|
||||
function normalizeEntryPath(path: string | undefined): string {
|
||||
if (!path || path === "." || path === "./") {
|
||||
return "."
|
||||
}
|
||||
let cleaned = path.replace(/\\/g, "/")
|
||||
if (cleaned.startsWith("./")) {
|
||||
cleaned = cleaned.replace(/^\.\/+/, "")
|
||||
}
|
||||
if (cleaned.startsWith("/")) {
|
||||
cleaned = cleaned.replace(/^\/+/, "")
|
||||
}
|
||||
cleaned = cleaned.replace(/\/+/g, "/")
|
||||
return cleaned === "" ? "." : cleaned
|
||||
}
|
||||
|
||||
function resolveAbsolutePath(root: string, relativePath: string): string {
|
||||
if (!root) {
|
||||
return relativePath
|
||||
}
|
||||
if (!relativePath || relativePath === "." || relativePath === "./") {
|
||||
return root
|
||||
}
|
||||
const separator = root.includes("\\") ? "\\" : "/"
|
||||
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
|
||||
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
|
||||
return `${trimmedRoot}${normalized}`
|
||||
}
|
||||
|
||||
|
||||
interface FileSystemBrowserDialogProps {
|
||||
open: boolean
|
||||
mode: "directories" | "files"
|
||||
title: string
|
||||
description?: string
|
||||
onSelect: (absolutePath: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
|
||||
|
||||
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
||||
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
|
||||
const [loadingPath, setLoadingPath] = createSignal<string | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
|
||||
let searchInputRef: HTMLInputElement | undefined
|
||||
|
||||
const directoryCache = new Map<string, FileSystemEntry[]>()
|
||||
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
||||
const inFlightLoads = new Map<string, Promise<FileSystemListingMetadata>>()
|
||||
|
||||
function resetDialogState() {
|
||||
directoryCache.clear()
|
||||
metadataCache.clear()
|
||||
inFlightLoads.clear()
|
||||
setEntries([])
|
||||
setCurrentMetadata(null)
|
||||
setLoadingPath(null)
|
||||
}
|
||||
|
||||
async function fetchDirectory(path: string, makeCurrent = false): Promise<FileSystemListingMetadata> {
|
||||
const normalized = normalizeEntryPath(path)
|
||||
|
||||
if (directoryCache.has(normalized) && metadataCache.has(normalized)) {
|
||||
if (makeCurrent) {
|
||||
setCurrentMetadata(metadataCache.get(normalized) ?? null)
|
||||
setEntries(directoryCache.get(normalized) ?? [])
|
||||
}
|
||||
return metadataCache.get(normalized) as FileSystemListingMetadata
|
||||
}
|
||||
|
||||
if (inFlightLoads.has(normalized)) {
|
||||
const metadata = await inFlightLoads.get(normalized)!
|
||||
if (makeCurrent) {
|
||||
setCurrentMetadata(metadata)
|
||||
setEntries(directoryCache.get(normalized) ?? [])
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
const loadPromise = (async () => {
|
||||
setLoadingPath(normalized)
|
||||
const response = await serverApi.listFileSystem(normalized === "." ? "." : normalized, {
|
||||
includeFiles: props.mode === "files",
|
||||
})
|
||||
directoryCache.set(normalized, response.entries)
|
||||
metadataCache.set(normalized, response.metadata)
|
||||
if (!rootPath()) {
|
||||
setRootPath(response.metadata.rootPath)
|
||||
}
|
||||
if (loadingPath() === normalized) {
|
||||
setLoadingPath(null)
|
||||
}
|
||||
return response.metadata
|
||||
})().catch((err) => {
|
||||
if (loadingPath() === normalized) {
|
||||
setLoadingPath(null)
|
||||
}
|
||||
throw err
|
||||
})
|
||||
|
||||
inFlightLoads.set(normalized, loadPromise)
|
||||
try {
|
||||
const metadata = await loadPromise
|
||||
if (makeCurrent) {
|
||||
const key = normalizeEntryPath(metadata.currentPath)
|
||||
setCurrentMetadata(metadata)
|
||||
setEntries(directoryCache.get(key) ?? directoryCache.get(normalized) ?? [])
|
||||
}
|
||||
return metadata
|
||||
} finally {
|
||||
inFlightLoads.delete(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshEntries() {
|
||||
setError(null)
|
||||
resetDialogState()
|
||||
try {
|
||||
const metadata = await fetchDirectory(".", true)
|
||||
setRootPath(metadata.rootPath)
|
||||
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||
setError(message)
|
||||
}
|
||||
}
|
||||
|
||||
function describeLoadingPath() {
|
||||
const path = loadingPath()
|
||||
if (!path) {
|
||||
return "filesystem"
|
||||
}
|
||||
if (path === ".") {
|
||||
return rootPath() || "workspace root"
|
||||
}
|
||||
return resolveAbsolutePath(rootPath(), path)
|
||||
}
|
||||
|
||||
function currentAbsolutePath(): string {
|
||||
const metadata = currentMetadata()
|
||||
if (!metadata) {
|
||||
return rootPath()
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(rootPath(), metadata.currentPath)
|
||||
}
|
||||
return metadata.displayPath
|
||||
}
|
||||
|
||||
function handleOverlayClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleEntrySelect(entry: FileSystemEntry) {
|
||||
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
|
||||
function handleNavigateTo(path: string) {
|
||||
void fetchDirectory(path, true).catch((err) => {
|
||||
log.error("Failed to open directory", err)
|
||||
setError(err instanceof Error ? err.message : "Unable to open directory")
|
||||
})
|
||||
}
|
||||
|
||||
function handleNavigateUp() {
|
||||
const parent = currentMetadata()?.parentPath
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
handleNavigateTo(parent)
|
||||
}
|
||||
|
||||
const filteredEntries = createMemo(() => {
|
||||
const query = searchQuery().trim().toLowerCase()
|
||||
const subset = entries().filter((entry) => (props.mode === "directories" ? entry.type === "directory" : true))
|
||||
if (!query) {
|
||||
return subset
|
||||
}
|
||||
return subset.filter((entry) => {
|
||||
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
|
||||
|
||||
const folderRows = createMemo<FolderRow[]>(() => {
|
||||
const rows: FolderRow[] = []
|
||||
const metadata = currentMetadata()
|
||||
if (metadata?.parentPath) {
|
||||
rows.push({ type: "up", path: metadata.parentPath })
|
||||
}
|
||||
for (const entry of visibleEntries()) {
|
||||
rows.push({ type: "entry", entry })
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const list = visibleEntries()
|
||||
if (list.length === 0) {
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
if (selectedIndex() >= list.length) {
|
||||
setSelectedIndex(list.length - 1)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) {
|
||||
return
|
||||
}
|
||||
setSearchQuery("")
|
||||
setSelectedIndex(0)
|
||||
void refreshEntries()
|
||||
setTimeout(() => searchInputRef?.focus(), 50)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!props.open) return
|
||||
const results = visibleEntries()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
const entry = results[selectedIndex()]
|
||||
if (entry) {
|
||||
handleEntrySelect(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
resetDialogState()
|
||||
setRootPath("")
|
||||
setError(null)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
|
||||
<div class="modal-surface max-h-full w-full max-w-3xl overflow-hidden rounded-xl bg-surface p-0" role="dialog" aria-modal="true">
|
||||
<div class="panel flex flex-col">
|
||||
<div class="panel-header flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="panel-title">{props.title}</h3>
|
||||
<p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p>
|
||||
<Show when={rootPath()}>
|
||||
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||
<X class="w-4 h-4" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<label class="w-full text-sm text-secondary mb-2 block">Filter</label>
|
||||
<div class="selector-input-group">
|
||||
<div class="flex items-center gap-2 px-3 text-muted">
|
||||
<Search class="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
ref={(el) => {
|
||||
searchInputRef = el
|
||||
}}
|
||||
type="text"
|
||||
value={searchQuery()}
|
||||
onInput={(event) => setSearchQuery(event.currentTarget.value)}
|
||||
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"}
|
||||
class="selector-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.mode === "directories"}>
|
||||
<div class="px-4 pb-2">
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
|
||||
<div>
|
||||
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary whitespace-nowrap"
|
||||
onClick={() => props.onSelect(currentAbsolutePath())}
|
||||
>
|
||||
Select Current
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
|
||||
<Show
|
||||
when={entries().length > 0}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center py-6 text-sm text-secondary">
|
||||
<Show
|
||||
when={loadingPath() !== null}
|
||||
fallback={<span class="text-red-500">{error()}</span>}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
<span>Loading {describeLoadingPath()}…</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={loadingPath()}>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Loading {describeLoadingPath()}…</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={folderRows().length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
|
||||
<p>No entries found.</p>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={folderRows()}>
|
||||
{(row) => {
|
||||
if (row.type === "up") {
|
||||
return (
|
||||
<div class="panel-list-item" role="button">
|
||||
<div class="panel-list-item-content directory-browser-row">
|
||||
<button type="button" class="directory-browser-row-main" onClick={handleNavigateUp}>
|
||||
<div class="directory-browser-row-icon">
|
||||
<ArrowUpLeft class="w-4 h-4" />
|
||||
</div>
|
||||
<div class="directory-browser-row-text">
|
||||
<span class="directory-browser-row-name">Up one level</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const entry = row.entry
|
||||
const selectEntry = () => handleEntrySelect(entry)
|
||||
const activateEntry = () => {
|
||||
if (entry.type === "directory") {
|
||||
handleNavigateTo(entry.path)
|
||||
} else {
|
||||
selectEntry()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="panel-list-item" role="listitem">
|
||||
<div class="panel-list-item-content directory-browser-row">
|
||||
<button type="button" class="directory-browser-row-main" onClick={activateEntry}>
|
||||
<div class="directory-browser-row-icon">
|
||||
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
|
||||
<FolderIcon class="w-4 h-4" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="directory-browser-row-text">
|
||||
<span class="directory-browser-row-name">{entry.name || entry.path}</span>
|
||||
<span class="directory-browser-row-sub">
|
||||
{resolveAbsolutePath(rootPath(), entry.path)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
selectEntry()
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Esc</kbd>
|
||||
<span>Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileSystemBrowserDialog
|
||||
|
||||
580
packages/ui/src/components/folder-selection-view.tsx
Normal file
580
packages/ui/src/components/folder-selection-view.tsx
Normal file
@@ -0,0 +1,580 @@
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { users, activeUser, refreshUsers, createUser, updateUser, deleteUser, loginUser, createGuest } from "../stores/users"
|
||||
|
||||
const nomadArchLogo = new URL("../images/NomadArch-Icon.png", import.meta.url).href
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
isLoading?: boolean
|
||||
advancedSettingsOpen?: boolean
|
||||
onAdvancedSettingsOpen?: () => void
|
||||
onAdvancedSettingsClose?: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences } = useConfig()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const [showUserModal, setShowUserModal] = createSignal(false)
|
||||
const [newUserName, setNewUserName] = createSignal("")
|
||||
const [newUserPassword, setNewUserPassword] = createSignal("")
|
||||
const [loginPassword, setLoginPassword] = createSignal("")
|
||||
const [loginTargetId, setLoginTargetId] = createSignal<string | null>(null)
|
||||
const [userError, setUserError] = createSignal<string | null>(null)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
const folders = () => recentFolders()
|
||||
const isLoading = () => Boolean(props.isLoading)
|
||||
|
||||
// Update selected binary when preferences change
|
||||
createEffect(() => {
|
||||
const lastUsed = preferences().lastUsedBinary
|
||||
if (!lastUsed) return
|
||||
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||
})
|
||||
|
||||
|
||||
function scrollToIndex(index: number) {
|
||||
const container = recentListRef
|
||||
if (!container) return
|
||||
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
|
||||
if (!element) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const elementRect = element.getBoundingClientRect()
|
||||
|
||||
if (elementRect.top < containerRect.top) {
|
||||
container.scrollTop -= containerRect.top - elementRect.top
|
||||
} else if (elementRect.bottom > containerRect.bottom) {
|
||||
container.scrollTop += elementRect.bottom - containerRect.bottom
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const normalizedKey = e.key.toLowerCase()
|
||||
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
||||
const blockedKeys = [
|
||||
"ArrowDown",
|
||||
"ArrowUp",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
"Home",
|
||||
"End",
|
||||
"Enter",
|
||||
"Backspace",
|
||||
"Delete",
|
||||
]
|
||||
|
||||
if (isLoading()) {
|
||||
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const folderList = folders()
|
||||
|
||||
if (isBrowseShortcut) {
|
||||
e.preventDefault()
|
||||
void handleBrowse()
|
||||
return
|
||||
}
|
||||
|
||||
if (folderList.length === 0) return
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
const newIndex = Math.max(selectedIndex() - 1, 0)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "PageDown") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "PageUp") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.max(selectedIndex() - pageSize, 0)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(0)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(0)
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault()
|
||||
const newIndex = folderList.length - 1
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleEnterKey()
|
||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||
e.preventDefault()
|
||||
if (folderList.length > 0 && focusMode() === "recent") {
|
||||
const folder = folderList[selectedIndex()]
|
||||
if (folder) {
|
||||
handleRemove(folder.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleEnterKey() {
|
||||
if (isLoading()) return
|
||||
const folderList = folders()
|
||||
const index = selectedIndex()
|
||||
|
||||
const folder = folderList[index]
|
||||
if (folder) {
|
||||
handleFolderSelect(folder.path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
refreshUsers()
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
})
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return "just now"
|
||||
}
|
||||
|
||||
function handleFolderSelect(path: string) {
|
||||
if (isLoading()) return
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
if (nativeDialogsAvailable) {
|
||||
const fallbackPath = folders()[0]?.path
|
||||
const selected = await openNativeFolderDialog({
|
||||
title: "Select Workspace",
|
||||
defaultPath: fallbackPath,
|
||||
})
|
||||
if (selected) {
|
||||
handleFolderSelect(selected)
|
||||
}
|
||||
return
|
||||
}
|
||||
setIsFolderBrowserOpen(true)
|
||||
}
|
||||
|
||||
function handleBrowserSelect(path: string) {
|
||||
setIsFolderBrowserOpen(false)
|
||||
handleFolderSelect(path)
|
||||
}
|
||||
|
||||
function handleBinaryChange(binary: string) {
|
||||
|
||||
setSelectedBinary(binary)
|
||||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
const name = newUserName().trim()
|
||||
const password = newUserPassword()
|
||||
if (!name || password.length < 4) {
|
||||
setUserError("Provide a name and a 4+ character password.")
|
||||
return
|
||||
}
|
||||
setUserError(null)
|
||||
await createUser(name, password)
|
||||
setNewUserName("")
|
||||
setNewUserPassword("")
|
||||
}
|
||||
|
||||
async function handleLogin(userId: string) {
|
||||
const password = loginTargetId() === userId ? loginPassword() : ""
|
||||
const ok = await loginUser(userId, password)
|
||||
if (!ok) {
|
||||
setUserError("Invalid password.")
|
||||
return
|
||||
}
|
||||
setUserError(null)
|
||||
setLoginPassword("")
|
||||
setLoginTargetId(null)
|
||||
setShowUserModal(false)
|
||||
}
|
||||
|
||||
async function handleGuest() {
|
||||
await createGuest()
|
||||
setShowUserModal(false)
|
||||
}
|
||||
|
||||
function handleRemove(path: string, e?: Event) {
|
||||
if (isLoading()) return
|
||||
e?.stopPropagation()
|
||||
removeRecentFolder(path)
|
||||
|
||||
const folderList = folders()
|
||||
if (selectedIndex() >= folderList.length && folderList.length > 0) {
|
||||
setSelectedIndex(folderList.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getDisplayPath(path: string): string {
|
||||
if (path.startsWith("/Users/")) {
|
||||
return path.replace(/^\/Users\/[^/]+/, "~")
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||
style="background-color: var(--surface-secondary)"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<div class="absolute top-4 left-6">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => setShowUserModal(true)}
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 right-6">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={nomadArchLogo} alt="NomadArch logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">NomadArch</h1>
|
||||
<p class="text-xs text-muted mb-1">An enhanced fork of CodeNomad</p>
|
||||
<Show when={activeUser()}>
|
||||
{(user) => (
|
||||
<p class="text-xs text-muted mb-1">
|
||||
Active user: <span class="text-secondary font-medium">{user().name}</span>
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
|
||||
<Show
|
||||
|
||||
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Recent Folders</h2>
|
||||
<p class="panel-subtitle">
|
||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{folder.path.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||
{getDisplayPath(folder.path)}
|
||||
</div>
|
||||
<div class="text-xs mt-1 pl-6 text-muted">
|
||||
{formatRelativeTime(folder.lastAccessed)}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button
|
||||
onClick={() => props.onAdvancedSettingsOpen?.()}
|
||||
class="panel-section-header w-full justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Remove</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<span>Browse</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={isLoading()}>
|
||||
<div class="folder-loading-overlay">
|
||||
<div class="folder-loading-indicator">
|
||||
<div class="spinner" />
|
||||
<p class="folder-loading-text">Starting instance…</p>
|
||||
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<AdvancedSettingsModal
|
||||
open={Boolean(props.advancedSettingsOpen)}
|
||||
onClose={() => props.onAdvancedSettingsClose?.()}
|
||||
selectedBinary={selectedBinary()}
|
||||
onBinaryChange={handleBinaryChange}
|
||||
isLoading={props.isLoading}
|
||||
/>
|
||||
|
||||
<DirectoryBrowserDialog
|
||||
open={isFolderBrowserOpen()}
|
||||
title="Select Workspace"
|
||||
description="Select workspace to start coding."
|
||||
onClose={() => setIsFolderBrowserOpen(false)}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
|
||||
<Show when={showUserModal()}>
|
||||
<div class="modal-overlay">
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="modal-surface w-full max-w-lg p-5 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-primary">Users</h2>
|
||||
<button class="selector-button selector-button-secondary" onClick={() => setShowUserModal(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={userError()}>
|
||||
{(msg) => <div class="text-sm text-red-400">{msg()}</div>}
|
||||
</Show>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs uppercase tracking-wide text-muted">Available</div>
|
||||
<For each={users()}>
|
||||
{(user) => (
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-2 rounded border border-base bg-surface-secondary">
|
||||
<div class="text-sm text-primary">
|
||||
{user.name}
|
||||
<Show when={user.isGuest}>
|
||||
<span class="ml-2 text-[10px] uppercase text-amber-400">Guest</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={!user.isGuest && loginTargetId() === user.id}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={loginPassword()}
|
||||
onInput={(event) => setLoginPassword(event.currentTarget.value)}
|
||||
class="rounded-md bg-white/5 border border-white/10 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
class="selector-button selector-button-primary"
|
||||
onClick={() => {
|
||||
if (user.isGuest) {
|
||||
void handleLogin(user.id)
|
||||
return
|
||||
}
|
||||
if (loginTargetId() !== user.id) {
|
||||
setLoginTargetId(user.id)
|
||||
setLoginPassword("")
|
||||
return
|
||||
}
|
||||
void handleLogin(user.id)
|
||||
}}
|
||||
>
|
||||
{activeUser()?.id === user.id ? "Active" : loginTargetId() === user.id ? "Unlock" : "Login"}
|
||||
</button>
|
||||
<button
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => void deleteUser(user.id)}
|
||||
disabled={user.isGuest}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs uppercase tracking-wide text-muted">Create User</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={newUserName()}
|
||||
onInput={(event) => setNewUserName(event.currentTarget.value)}
|
||||
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={newUserPassword()}
|
||||
onInput={(event) => setNewUserPassword(event.currentTarget.value)}
|
||||
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button class="selector-button selector-button-primary" onClick={() => void handleCreateUser()}>
|
||||
Create
|
||||
</button>
|
||||
<button class="selector-button selector-button-secondary" onClick={() => void handleGuest()}>
|
||||
Guest Mode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FolderSelectionView
|
||||
12
packages/ui/src/components/hint-row.tsx
Normal file
12
packages/ui/src/components/hint-row.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Component, JSX } from "solid-js"
|
||||
|
||||
interface HintRowProps {
|
||||
children: JSX.Element
|
||||
class?: string
|
||||
}
|
||||
|
||||
const HintRow: Component<HintRowProps> = (props) => {
|
||||
return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span>
|
||||
}
|
||||
|
||||
export default HintRow
|
||||
161
packages/ui/src/components/info-view.tsx
Normal file
161
packages/ui/src/components/info-view.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import InstanceInfo from "./instance-info"
|
||||
|
||||
interface InfoViewProps {
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
const InfoView: Component<InfoViewProps> = (props) => {
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const savedState = logsScrollState.get(props.instanceId)
|
||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||
|
||||
const instance = () => instances().get(props.instanceId)
|
||||
const logs = createMemo(() => getInstanceLogs(props.instanceId))
|
||||
const streamingEnabled = createMemo(() => isInstanceLogStreaming(props.instanceId))
|
||||
|
||||
const handleEnableLogs = () => setInstanceLogStreaming(props.instanceId, true)
|
||||
const handleDisableLogs = () => setInstanceLogStreaming(props.instanceId, false)
|
||||
|
||||
onMount(() => {
|
||||
|
||||
if (scrollRef && savedState) {
|
||||
scrollRef.scrollTop = savedState.scrollTop
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (scrollRef) {
|
||||
logsScrollState.set(props.instanceId, {
|
||||
scrollTop: scrollRef.scrollTop,
|
||||
autoScroll: autoScroll(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (autoScroll() && scrollRef && logs().length > 0) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
})
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef) return
|
||||
|
||||
const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50
|
||||
|
||||
setAutoScroll(isAtBottom)
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollRef) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return "log-level-error"
|
||||
case "warn":
|
||||
return "log-level-warn"
|
||||
case "debug":
|
||||
return "log-level-debug"
|
||||
default:
|
||||
return "log-level-default"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="log-container">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
||||
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div class="log-header">
|
||||
<h2 class="panel-title">Server Logs</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
||||
Hide server logs
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
class="log-content"
|
||||
>
|
||||
<Show
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<div class="log-paused-state">
|
||||
<p class="log-paused-title">Server logs are paused</p>
|
||||
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
|
||||
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={logs().length > 0}
|
||||
fallback={<div class="log-empty-state">Waiting for server output...</div>}
|
||||
>
|
||||
<For each={logs()}>
|
||||
{(entry) => (
|
||||
<div class="log-entry">
|
||||
<span class="log-timestamp">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<span class={`log-message ${getLevelColor(entry.level)}`}>{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={!autoScroll() && streamingEnabled()}>
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
class="scroll-to-bottom"
|
||||
>
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
Scroll to bottom
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default InfoView
|
||||
47
packages/ui/src/components/instance-disconnected-modal.tsx
Normal file
47
packages/ui/src/components/instance-disconnected-modal.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
|
||||
interface InstanceDisconnectedModalProps {
|
||||
open: boolean
|
||||
folder?: string
|
||||
reason?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) {
|
||||
const folderLabel = props.folder || "this workspace"
|
||||
const reasonLabel = props.reason || "The server stopped responding"
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
{folderLabel} can no longer be reached. Close the tab to continue working.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 text-sm text-secondary">
|
||||
<p class="font-medium text-primary">Details</p>
|
||||
<p class="mt-2 text-secondary">{reasonLabel}</p>
|
||||
{props.folder && (
|
||||
<p class="mt-2 text-secondary">
|
||||
Folder: <span class="font-mono text-primary break-all">{props.folder}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="selector-button selector-button-primary" onClick={props.onClose}>
|
||||
Close Instance
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
291
packages/ui/src/components/instance-info.tsx
Normal file
291
packages/ui/src/components/instance-info.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import InstanceServiceStatus from "./instance-service-status"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
|
||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
||||
|
||||
const currentInstance = () => instanceAccessor()
|
||||
const metadata = () => metadataAccessor()
|
||||
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
||||
const environmentVariables = () => currentInstance().environmentVariables
|
||||
const environmentEntries = createMemo(() => {
|
||||
const env = environmentVariables()
|
||||
return env ? Object.entries(env) : []
|
||||
})
|
||||
const [showExportDialog, setShowExportDialog] = createSignal(false)
|
||||
const [showImportSourceDialog, setShowImportSourceDialog] = createSignal(false)
|
||||
const [showImportDestinationDialog, setShowImportDestinationDialog] = createSignal(false)
|
||||
const [importSourcePath, setImportSourcePath] = createSignal<string | null>(null)
|
||||
const [includeConfig, setIncludeConfig] = createSignal(false)
|
||||
const [isExporting, setIsExporting] = createSignal(false)
|
||||
const [isImporting, setIsImporting] = createSignal(false)
|
||||
|
||||
const handleExport = async (destination: string) => {
|
||||
if (isExporting()) return
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const response = await serverApi.exportWorkspace(currentInstance().id, {
|
||||
destination,
|
||||
includeConfig: includeConfig(),
|
||||
})
|
||||
showToastNotification({
|
||||
title: "Workspace exported",
|
||||
message: `Export saved to ${response.destination}`,
|
||||
variant: "success",
|
||||
duration: 7000,
|
||||
})
|
||||
} catch (error) {
|
||||
showToastNotification({
|
||||
title: "Export failed",
|
||||
message: error instanceof Error ? error.message : "Unable to export workspace",
|
||||
variant: "error",
|
||||
duration: 8000,
|
||||
})
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportDestination = async (destination: string) => {
|
||||
const source = importSourcePath()
|
||||
if (!source || isImporting()) return
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const response = await serverApi.importWorkspace({
|
||||
source,
|
||||
destination,
|
||||
includeConfig: includeConfig(),
|
||||
})
|
||||
showToastNotification({
|
||||
title: "Workspace imported",
|
||||
message: `Imported workspace into ${response.path}`,
|
||||
variant: "success",
|
||||
duration: 7000,
|
||||
})
|
||||
} catch (error) {
|
||||
showToastNotification({
|
||||
title: "Import failed",
|
||||
message: error instanceof Error ? error.message : "Unable to import workspace",
|
||||
variant: "error",
|
||||
duration: 8000,
|
||||
})
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
setImportSourcePath(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Instance Information</h2>
|
||||
</div>
|
||||
<div class="panel-body space-y-3">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
|
||||
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
{currentInstance().folder}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!isLoadingMetadata() && metadata()?.project}>
|
||||
{(project) => (
|
||||
<>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
Project
|
||||
</div>
|
||||
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||
{project().id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={project().vcs}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
Version Control
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-primary">
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
style="color: var(--status-warning);"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
<span class="capitalize">{project().vcs}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={binaryVersion()}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
OpenCode Version
|
||||
</div>
|
||||
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
v{binaryVersion()}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={currentInstance().binaryPath}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
Binary Path
|
||||
</div>
|
||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
{currentInstance().binaryPath}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={environmentEntries().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
Environment Variables ({environmentEntries().length})
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<For each={environmentEntries()}>
|
||||
{([key, value]) => (
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
||||
{key}
|
||||
</span>
|
||||
<span class="text-xs font-mono flex-1 text-secondary" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">Workspace Export / Import</div>
|
||||
<label class="flex items-center gap-2 text-xs text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeConfig()}
|
||||
onChange={(event) => setIncludeConfig(event.currentTarget.checked)}
|
||||
/>
|
||||
Include user config (settings, keys)
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="button-secondary"
|
||||
disabled={isExporting()}
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
>
|
||||
{isExporting() ? "Exporting..." : "Export Workspace"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-secondary"
|
||||
disabled={isImporting()}
|
||||
onClick={() => setShowImportSourceDialog(true)}
|
||||
>
|
||||
{isImporting() ? "Importing..." : "Import Workspace"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[11px] text-muted">
|
||||
Export creates a portable folder. Import restores the workspace into a chosen destination.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isLoadingMetadata()}>
|
||||
<div class="text-xs text-muted py-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="animate-spin h-3 w-3 icon-muted" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">Server</div>
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">Port:</span>
|
||||
<span class="text-primary font-mono">{currentInstance().port}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">PID:</span>
|
||||
<span class="text-primary font-mono">{currentInstance().pid}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">Status:</span>
|
||||
<span class={`status-badge ${currentInstance().status}`}>
|
||||
<div
|
||||
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{currentInstance().status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DirectoryBrowserDialog
|
||||
open={showExportDialog()}
|
||||
title="Export workspace to folder"
|
||||
description="Choose a destination folder for the export package."
|
||||
onClose={() => setShowExportDialog(false)}
|
||||
onSelect={(destination) => {
|
||||
setShowExportDialog(false)
|
||||
void handleExport(destination)
|
||||
}}
|
||||
/>
|
||||
<DirectoryBrowserDialog
|
||||
open={showImportSourceDialog()}
|
||||
title="Select export folder"
|
||||
description="Pick the export folder that contains the workspace package."
|
||||
onClose={() => setShowImportSourceDialog(false)}
|
||||
onSelect={(source) => {
|
||||
setShowImportSourceDialog(false)
|
||||
setImportSourcePath(source)
|
||||
setShowImportDestinationDialog(true)
|
||||
}}
|
||||
/>
|
||||
<DirectoryBrowserDialog
|
||||
open={showImportDestinationDialog()}
|
||||
title="Select destination folder"
|
||||
description="Choose the folder where the workspace should be imported."
|
||||
onClose={() => setShowImportDestinationDialog(false)}
|
||||
onSelect={(destination) => {
|
||||
setShowImportDestinationDialog(false)
|
||||
void handleImportDestination(destination)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceInfo
|
||||
224
packages/ui/src/components/instance-service-status.tsx
Normal file
224
packages/ui/src/components/instance-service-status.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
|
||||
import Switch from "@suid/material/Switch"
|
||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type ServiceSection = "lsp" | "mcp"
|
||||
|
||||
interface InstanceServiceStatusProps {
|
||||
sections?: ServiceSection[]
|
||||
showSectionHeadings?: boolean
|
||||
class?: string
|
||||
initialInstance?: Instance
|
||||
}
|
||||
|
||||
type ParsedMcpStatus = {
|
||||
name: string
|
||||
status: "running" | "stopped" | "error"
|
||||
error?: string
|
||||
}
|
||||
|
||||
function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
|
||||
if (!status || typeof status !== "object") return []
|
||||
const result: ParsedMcpStatus[] = []
|
||||
for (const [name, value] of Object.entries(status)) {
|
||||
if (!value || typeof value !== "object") continue
|
||||
const rawStatus = (value as { status?: string }).status
|
||||
if (!rawStatus) continue
|
||||
let mapped: ParsedMcpStatus["status"]
|
||||
if (rawStatus === "connected") mapped = "running"
|
||||
else if (rawStatus === "failed") mapped = "error"
|
||||
else mapped = "stopped"
|
||||
result.push({
|
||||
name,
|
||||
status: mapped,
|
||||
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
const instance = metadataContext?.instance ?? (() => {
|
||||
if (props.initialInstance) {
|
||||
return props.initialInstance
|
||||
}
|
||||
throw new Error("InstanceServiceStatus requires InstanceMetadataProvider or initialInstance prop")
|
||||
})
|
||||
const isLoading = metadataContext?.isLoading ?? (() => false)
|
||||
const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve())
|
||||
const sections = createMemo<ServiceSection[]>(() => props.sections ?? ["lsp", "mcp"])
|
||||
const includeLsp = createMemo(() => sections().includes("lsp"))
|
||||
const includeMcp = createMemo(() => sections().includes("mcp"))
|
||||
const showHeadings = () => props.showSectionHeadings !== false
|
||||
|
||||
const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata)
|
||||
const metadata = createMemo(() => metadataAccessor())
|
||||
const hasLspMetadata = () => metadata()?.lspStatus !== undefined
|
||||
const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined
|
||||
const lspServers = createMemo(() => metadata()?.lspStatus ?? [])
|
||||
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined))
|
||||
|
||||
const isLspLoading = () => isLoading() || !hasLspMetadata()
|
||||
const isMcpLoading = () => isLoading() || !hasMcpMetadata()
|
||||
|
||||
|
||||
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
|
||||
|
||||
const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => {
|
||||
setPendingMcpActions((prev) => {
|
||||
const next = { ...prev }
|
||||
if (action) next[name] = action
|
||||
else delete next[name]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleMcpServer = async (serverName: string, shouldEnable: boolean) => {
|
||||
const client = instance().client
|
||||
if (!client?.mcp) return
|
||||
const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect"
|
||||
setPendingMcpAction(serverName, action)
|
||||
try {
|
||||
if (shouldEnable) {
|
||||
await client.mcp.connect({ path: { name: serverName } })
|
||||
} else {
|
||||
await client.mcp.disconnect({ path: { name: serverName } })
|
||||
}
|
||||
await refreshMetadata()
|
||||
} catch (error) {
|
||||
log.error("Failed to toggle MCP server", { serverName, action, error })
|
||||
} finally {
|
||||
setPendingMcpAction(serverName)
|
||||
}
|
||||
}
|
||||
|
||||
const renderEmptyState = (message: string) => (
|
||||
<p class="text-[11px] text-secondary italic" role="status">
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
|
||||
const renderLspSection = () => (
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
LSP Servers
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isLspLoading() && lspServers().length > 0}
|
||||
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={lspServers()}>
|
||||
{(server) => (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
|
||||
<span class="text-[11px] text-secondary truncate" title={server.root}>
|
||||
{server.root}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
||||
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
|
||||
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
|
||||
const renderMcpSection = () => (
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
MCP Servers
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isMcpLoading() && mcpServers().length > 0}
|
||||
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={mcpServers()}>
|
||||
{(server) => {
|
||||
const pendingAction = () => pendingMcpActions()[server.name]
|
||||
const isPending = () => Boolean(pendingAction())
|
||||
const isRunning = () => server.status === "running"
|
||||
const switchDisabled = () => isPending() || !instance().client
|
||||
const statusDotClass = () => {
|
||||
if (isPending()) return "status-dot animate-pulse"
|
||||
if (server.status === "running") return "status-dot ready animate-pulse"
|
||||
if (server.status === "error") return "status-dot error"
|
||||
return "status-dot stopped"
|
||||
}
|
||||
const statusDotStyle = () => (isPending() ? { background: "var(--status-warning)" } : undefined)
|
||||
return (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<div class="flex items-center gap-1.5 text-xs text-secondary">
|
||||
<Show when={isPending()}>
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
<div class={statusDotClass()} style={statusDotStyle()} />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={isRunning()}
|
||||
disabled={switchDisabled()}
|
||||
color="success"
|
||||
size="small"
|
||||
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
|
||||
onChange={(_, checked) => {
|
||||
if (switchDisabled()) return
|
||||
void toggleMcpServer(server.name, Boolean(checked))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<Show when={server.error}>
|
||||
{(error) => (
|
||||
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class={props.class}>
|
||||
<Show when={includeLsp()}>{renderLspSection()}</Show>
|
||||
<Show when={includeMcp()}>{renderMcpSection()}</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceServiceStatus
|
||||
59
packages/ui/src/components/instance-tab.tsx
Normal file
59
packages/ui/src/components/instance-tab.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Component } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { FolderOpen, X } from "lucide-solid"
|
||||
|
||||
interface InstanceTabProps {
|
||||
instance: Instance
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string {
|
||||
const name = path.split("/").pop() || path
|
||||
|
||||
const duplicates = instances.filter((i) => {
|
||||
const iName = i.folder.split("/").pop() || i.folder
|
||||
return iName === name
|
||||
})
|
||||
|
||||
if (duplicates.length > 1) {
|
||||
const index = duplicates.findIndex((i) => i.id === currentInstance.id)
|
||||
return `~/${name} (${index + 1})`
|
||||
}
|
||||
|
||||
return `~/${name}`
|
||||
}
|
||||
|
||||
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
return (
|
||||
<div class="group">
|
||||
<button
|
||||
class={`tab-base ${props.active ? "tab-active" : "tab-inactive"}`}
|
||||
onClick={props.onSelect}
|
||||
title={props.instance.folder}
|
||||
role="tab"
|
||||
aria-selected={props.active}
|
||||
>
|
||||
<FolderOpen class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="tab-label">
|
||||
{props.instance.folder.split("/").pop() || props.instance.folder}
|
||||
</span>
|
||||
<span
|
||||
class="tab-close ml-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onClose()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close instance"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceTab
|
||||
71
packages/ui/src/components/instance-tabs.tsx
Normal file
71
packages/ui/src/components/instance-tabs.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
onNew: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
return (
|
||||
<div class="tab-bar tab-bar-instance">
|
||||
<div class="tab-container" role="tablist">
|
||||
<div class="tab-scroll">
|
||||
<div class="tab-strip">
|
||||
<div class="tab-strip-tabs">
|
||||
<For each={Array.from(props.instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === props.activeInstanceId}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={() => props.onClose(id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
class="new-tab-button"
|
||||
onClick={props.onNew}
|
||||
title="New instance (Cmd/Ctrl+N)"
|
||||
aria-label="New instance"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-strip-spacer" />
|
||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
||||
<div class="tab-shortcuts">
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||
Boolean,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
title="Remote connect"
|
||||
aria-label="Remote connect"
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceTabs
|
||||
579
packages/ui/src/components/instance-welcome-view.tsx
Normal file
579
packages/ui/src/components/instance-welcome-view.tsx
Normal file
@@ -0,0 +1,579 @@
|
||||
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { Loader2, Pencil, Trash2 } from "lucide-solid"
|
||||
|
||||
import type { Instance } from "../types/instance"
|
||||
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading, renameSession } from "../stores/sessions"
|
||||
import InstanceInfo from "./instance-info"
|
||||
import Kbd from "./kbd"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
|
||||
interface InstanceWelcomeViewProps {
|
||||
instance: Instance
|
||||
}
|
||||
|
||||
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
const [isCreating, setIsCreating] = createSignal(false)
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
||||
const [showInstanceInfoOverlay, setShowInstanceInfoOverlay] = createSignal(false)
|
||||
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
|
||||
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
|
||||
)
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||
|
||||
const parentSessions = () => getParentSessions(props.instance.id)
|
||||
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
|
||||
const isSessionDeleting = (sessionId: string) => {
|
||||
const deleting = loading().deletingSession.get(props.instance.id)
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
}
|
||||
const newSessionShortcut = createMemo<KeyboardShortcut>(() => {
|
||||
const registered = keyboardRegistry.get("session-new")
|
||||
if (registered) return registered
|
||||
return {
|
||||
id: "session-new-display",
|
||||
key: "n",
|
||||
modifiers: {
|
||||
shift: true,
|
||||
meta: isMac(),
|
||||
ctrl: !isMac(),
|
||||
},
|
||||
handler: () => {},
|
||||
description: "New Session",
|
||||
context: "global",
|
||||
}
|
||||
})
|
||||
const newSessionShortcutString = createMemo(() => (isMac() ? "cmd+shift+n" : "ctrl+shift+n"))
|
||||
|
||||
createEffect(() => {
|
||||
const sessions = parentSessions()
|
||||
if (sessions.length === 0) {
|
||||
setFocusMode("new-session")
|
||||
setSelectedIndex(0)
|
||||
} else {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
})
|
||||
|
||||
const openInstanceInfoOverlay = () => {
|
||||
if (isDesktopLayout()) return
|
||||
setShowInstanceInfoOverlay(true)
|
||||
}
|
||||
const closeInstanceInfoOverlay = () => setShowInstanceInfoOverlay(false)
|
||||
|
||||
function scrollToIndex(index: number) {
|
||||
const element = document.querySelector(`[data-session-index="${index}"]`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "auto" })
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
let activeElement: HTMLElement | null = null
|
||||
if (typeof document !== "undefined") {
|
||||
activeElement = document.activeElement as HTMLElement | null
|
||||
}
|
||||
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
|
||||
const isEditingField =
|
||||
activeElement &&
|
||||
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) ||
|
||||
activeElement.isContentEditable ||
|
||||
Boolean(insideModal))
|
||||
|
||||
if (isEditingField) {
|
||||
if (insideModal && e.key === "Escape" && renameTarget()) {
|
||||
e.preventDefault()
|
||||
closeRenameDialog()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (showInstanceInfoOverlay()) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
closeInstanceInfoOverlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const sessions = parentSessions()
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
||||
e.preventDefault()
|
||||
handleNewSession()
|
||||
return
|
||||
}
|
||||
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const listFocused = focusMode() === "sessions"
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
if (!listFocused) {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
e.preventDefault()
|
||||
const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
scrollToIndex(newIndex)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
if (!listFocused) {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(Math.max(parentSessions().length - 1, 0))
|
||||
}
|
||||
e.preventDefault()
|
||||
const newIndex = Math.max(selectedIndex() - 1, 0)
|
||||
setSelectedIndex(newIndex)
|
||||
scrollToIndex(newIndex)
|
||||
return
|
||||
}
|
||||
|
||||
if (!listFocused) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "PageDown") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "PageUp") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.max(selectedIndex() - pageSize, 0)
|
||||
setSelectedIndex(newIndex)
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(0)
|
||||
scrollToIndex(0)
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault()
|
||||
const newIndex = sessions.length - 1
|
||||
setSelectedIndex(newIndex)
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
void handleEnterKey()
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault()
|
||||
void handleDeleteKey()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleEnterKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
|
||||
if (index < sessions.length) {
|
||||
await handleSessionSelect(sessions[index].id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
|
||||
if (index >= sessions.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleSessionDelete(sessions[index].id)
|
||||
|
||||
const updatedSessions = parentSessions()
|
||||
if (updatedSessions.length === 0) {
|
||||
setFocusMode("new-session")
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = Math.min(index, updatedSessions.length - 1)
|
||||
setSelectedIndex(nextIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(nextIndex)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const mediaQuery = window.matchMedia("(min-width: 1024px)")
|
||||
const handleMediaChange = (matches: boolean) => {
|
||||
setIsDesktopLayout(matches)
|
||||
if (matches) {
|
||||
closeInstanceInfoOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
const listener = (event: MediaQueryListEvent) => handleMediaChange(event.matches)
|
||||
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", listener)
|
||||
onCleanup(() => {
|
||||
mediaQuery.removeEventListener("change", listener)
|
||||
})
|
||||
} else {
|
||||
mediaQuery.addListener(listener)
|
||||
onCleanup(() => {
|
||||
mediaQuery.removeListener(listener)
|
||||
})
|
||||
}
|
||||
|
||||
handleMediaChange(mediaQuery.matches)
|
||||
})
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return "just now"
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
async function handleSessionSelect(sessionId: string) {
|
||||
setActiveParentSession(props.instance.id, sessionId)
|
||||
}
|
||||
|
||||
async function handleSessionDelete(sessionId: string) {
|
||||
if (isSessionDeleting(sessionId)) return
|
||||
|
||||
try {
|
||||
await deleteSession(props.instance.id, sessionId)
|
||||
} catch (error) {
|
||||
log.error("Failed to delete session:", error)
|
||||
}
|
||||
}
|
||||
|
||||
function openRenameDialogForSession(sessionId: string, title: string) {
|
||||
const label = title && title.trim() ? title : sessionId
|
||||
setRenameTarget({ id: sessionId, title: title ?? "", label })
|
||||
}
|
||||
|
||||
function closeRenameDialog() {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
|
||||
async function handleRenameSubmit(nextTitle: string) {
|
||||
const target = renameTarget()
|
||||
if (!target) return
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
await renameSession(props.instance.id, target.id, nextTitle)
|
||||
setRenameTarget(null)
|
||||
} catch (error) {
|
||||
log.error("Failed to rename session:", error)
|
||||
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewSession() {
|
||||
if (isCreating()) return
|
||||
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const session = await createSession(props.instance.id)
|
||||
setActiveParentSession(props.instance.id, session.id)
|
||||
} catch (error) {
|
||||
log.error("Failed to create session:", error)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex-1 flex flex-col overflow-hidden bg-surface-secondary">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto min-w-0">
|
||||
<div class="flex-1 flex flex-col gap-4 min-h-0 min-w-0">
|
||||
<Show
|
||||
when={parentSessions().length > 0}
|
||||
fallback={
|
||||
<Show
|
||||
when={isFetchingSessions()}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
||||
<div class="panel-empty-state-icon">
|
||||
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Previous Sessions</p>
|
||||
<p class="panel-empty-state-description">Create a new session below to get started</p>
|
||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
|
||||
View Instance Info
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">Loading Sessions</p>
|
||||
<p class="panel-empty-state-description">Fetching your previous sessions...</p>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
|
||||
<div>
|
||||
<h2 class="panel-title">Resume Session</h2>
|
||||
<p class="panel-subtitle">
|
||||
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
|
||||
</p>
|
||||
</div>
|
||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary lg:hidden flex-shrink-0"
|
||||
onClick={openInstanceInfoOverlay}
|
||||
>
|
||||
View Instance Info
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto">
|
||||
<For each={parentSessions()}>
|
||||
{(session, index) => {
|
||||
const isFocused = () => focusMode() === "sessions" && selectedIndex() === index()
|
||||
return (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": isFocused(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
type="button"
|
||||
data-session-index={index()}
|
||||
class="panel-list-item-content group flex-1"
|
||||
onClick={() => handleSessionSelect(session.id)}
|
||||
onMouseEnter={() => {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
||||
classList={{
|
||||
"text-accent": isFocused(),
|
||||
}}
|
||||
>
|
||||
{session.title || "Untitled Session"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
||||
<span>{session.agent}</span>
|
||||
<span>•</span>
|
||||
<span>{formatRelativeTime(session.time.updated)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<Show when={isFocused()}>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<kbd class="kbd flex-shrink-0">↵</kbd>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
title="Rename session"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openRenameDialogForSession(session.id, session.title || "")
|
||||
}}
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
title="Delete session"
|
||||
disabled={isSessionDeleting(session.id)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void handleSessionDelete(session.id)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={!isSessionDeleting(session.id)}
|
||||
fallback={
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel flex-shrink-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Start New Session</h2>
|
||||
<p class="panel-subtitle">We’ll reuse your last agent/model automatically</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating()}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{isCreating() ? (
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Create Session</span>
|
||||
</div>
|
||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block lg:w-80 flex-shrink-0">
|
||||
<div class="sticky top-0 max-h-full overflow-y-auto pr-1">
|
||||
<InstanceInfo instance={props.instance} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!isDesktopLayout() && showInstanceInfoOverlay()}>
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={closeInstanceInfoOverlay}
|
||||
>
|
||||
<div class="flex min-h-full items-start justify-center p-4 overflow-y-auto">
|
||||
<div
|
||||
class="w-full max-w-md space-y-3"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-[85vh] overflow-y-auto pr-1">
|
||||
<InstanceInfo instance={props.instance} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel-footer hidden sm:block">
|
||||
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">PgUp</kbd>
|
||||
<kbd class="kbd">PgDn</kbd>
|
||||
<span>Jump</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Home</kbd>
|
||||
<kbd class="kbd">End</kbd>
|
||||
<span>First/Last</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Resume</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SessionRenameDialog
|
||||
open={Boolean(renameTarget())}
|
||||
currentTitle={renameTarget()?.title ?? ""}
|
||||
sessionLabel={renameTarget()?.label}
|
||||
isSubmitting={isRenaming()}
|
||||
onRename={handleRenameSubmit}
|
||||
onClose={closeRenameDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceWelcomeView
|
||||
52
packages/ui/src/components/instance/editor.tsx
Normal file
52
packages/ui/src/components/instance/editor.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { FileNode } from "./sidebar"
|
||||
|
||||
interface EditorProps {
|
||||
file: FileNode | null
|
||||
}
|
||||
|
||||
export const Editor: Component<EditorProps> = (props) => {
|
||||
return (
|
||||
<Show
|
||||
when={props.file}
|
||||
fallback={
|
||||
<div class="flex-1 flex items-center justify-center text-zinc-500 bg-[#0d0d0d]">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 opacity-20 flex justify-center">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Select a file to start editing</p>
|
||||
<p class="text-sm mt-2 opacity-60">Press Ctrl+P to search</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex-1 overflow-hidden flex flex-col bg-[#0d0d0d]">
|
||||
<div class="h-10 glass border-b border-white/5 flex items-center px-4 space-x-2 shrink-0">
|
||||
<span class="text-xs text-zinc-400 font-medium">{props.file?.name}</span>
|
||||
<span class="text-[10px] text-zinc-600 uppercase">{props.file?.language || "text"}</span>
|
||||
</div>
|
||||
<div class="flex-1 p-6 overflow-auto mono text-sm leading-relaxed">
|
||||
<pre class="text-zinc-300">
|
||||
<Show
|
||||
when={props.file?.content}
|
||||
fallback={<span class="italic text-zinc-600">// Empty file</span>}
|
||||
>
|
||||
<For each={props.file?.content?.split("\n")}>
|
||||
{(line, i) => (
|
||||
<div class="flex group">
|
||||
<span class="w-12 text-zinc-600 select-none text-right pr-4">{i() + 1}</span>
|
||||
<span class="whitespace-pre">{line}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
1589
packages/ui/src/components/instance/instance-shell2.tsx
Normal file
1589
packages/ui/src/components/instance/instance-shell2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
518
packages/ui/src/components/instance/sidebar.tsx
Normal file
518
packages/ui/src/components/instance/sidebar.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import { Component, createSignal, For, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import {
|
||||
Files,
|
||||
Search,
|
||||
GitBranch,
|
||||
Play,
|
||||
Settings,
|
||||
Plug,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
User,
|
||||
FileCode,
|
||||
FileJson,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-solid"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import InstanceServiceStatus from "../instance-service-status"
|
||||
import McpManager from "../mcp-manager"
|
||||
import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills"
|
||||
import { getSessionSkills, setSessionSkills } from "../../stores/session-state"
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
type: "file" | "directory"
|
||||
path: string
|
||||
language?: string
|
||||
content?: string
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
instanceId: string
|
||||
onFileSelect: (file: FileNode) => void
|
||||
isOpen: boolean
|
||||
sessions: any[] // Existing sessions to display in one of the tabs
|
||||
activeSessionId?: string
|
||||
onSessionSelect: (id: string) => void
|
||||
onOpenCommandPalette?: () => void
|
||||
onToggleTerminal?: () => void
|
||||
isTerminalOpen?: boolean
|
||||
onOpenAdvancedSettings?: () => void
|
||||
requestedTab?: string | null
|
||||
}
|
||||
|
||||
const getFileIcon = (fileName: string) => {
|
||||
if (fileName.endsWith(".tsx") || fileName.endsWith(".ts"))
|
||||
return <FileCode size={16} class="text-blue-400" />
|
||||
if (fileName.endsWith(".json")) return <FileJson size={16} class="text-yellow-400" />
|
||||
if (fileName.endsWith(".md")) return <FileText size={16} class="text-gray-400" />
|
||||
if (fileName.endsWith(".png") || fileName.endsWith(".jpg"))
|
||||
return <ImageIcon size={16} class="text-purple-400" />
|
||||
return <FileCode size={16} class="text-blue-300" />
|
||||
}
|
||||
|
||||
const FileTree: Component<{
|
||||
node: FileNode;
|
||||
depth: number;
|
||||
onSelect: (f: FileNode) => void;
|
||||
instanceId: string;
|
||||
}> = (props) => {
|
||||
const [isOpen, setIsOpen] = createSignal(props.depth === 0)
|
||||
const [children, setChildren] = createSignal<FileNode[]>([])
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
if (props.node.type === "directory") {
|
||||
const nextOpen = !isOpen()
|
||||
setIsOpen(nextOpen)
|
||||
|
||||
if (nextOpen && children().length === 0) {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const entries = await serverApi.listWorkspaceFiles(props.instanceId, props.node.path)
|
||||
setChildren(entries.map(e => ({
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
path: e.path
|
||||
})))
|
||||
} catch (e) {
|
||||
console.error("Failed to list files", e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
props.onSelect(props.node)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-white/5 text-zinc-400 text-sm transition-colors rounded ${props.depth > 0 ? "ml-2" : ""}`}
|
||||
>
|
||||
<span class="mr-1 w-4 flex justify-center">
|
||||
<Show when={props.node.type === "directory"}>
|
||||
<Show when={isOpen()} fallback={<ChevronRight size={14} />}>
|
||||
<ChevronDown size={14} />
|
||||
</Show>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="mr-2">
|
||||
<Show
|
||||
when={props.node.type === "directory"}
|
||||
fallback={getFileIcon(props.node.name)}
|
||||
>
|
||||
<Folder size={14} class="text-blue-500/80" />
|
||||
</Show>
|
||||
</span>
|
||||
<span class={props.node.type === "directory" ? "font-medium" : ""}>{props.node.name}</span>
|
||||
<Show when={isLoading()}>
|
||||
<span class="ml-2 w-3 h-3 border border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.node.type === "directory" && isOpen()}>
|
||||
<div class="border-l border-white/5 ml-3">
|
||||
<For each={children()}>
|
||||
{(child) => <FileTree node={child} depth={props.depth + 1} onSelect={props.onSelect} instanceId={props.instanceId} />}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Sidebar: Component<SidebarProps> = (props) => {
|
||||
const [activeTab, setActiveTab] = createSignal("files")
|
||||
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
|
||||
const [lastRequestedTab, setLastRequestedTab] = createSignal<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [searchResults, setSearchResults] = createSignal<FileNode[]>([])
|
||||
const [searchLoading, setSearchLoading] = createSignal(false)
|
||||
const [gitStatus, setGitStatus] = createSignal<{
|
||||
isRepo: boolean
|
||||
branch: string | null
|
||||
ahead: number
|
||||
behind: number
|
||||
changes: Array<{ path: string; status: string }>
|
||||
error?: string
|
||||
} | null>(null)
|
||||
const [gitLoading, setGitLoading] = createSignal(false)
|
||||
const [skillsFilter, setSkillsFilter] = createSignal("")
|
||||
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
const refreshRootFiles = async () => {
|
||||
if (!props.instanceId) return
|
||||
try {
|
||||
const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".")
|
||||
setRootFiles(entries.map(e => ({
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
path: e.path
|
||||
})))
|
||||
} catch (e) {
|
||||
console.error("Failed to load root files", e)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
void refreshRootFiles()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ instanceId?: string }>).detail
|
||||
console.log(`[Sidebar] Received FILE_CHANGE_EVENT`, {
|
||||
detail,
|
||||
currentInstanceId: props.instanceId,
|
||||
match: detail?.instanceId === props.instanceId
|
||||
});
|
||||
if (!detail || detail.instanceId !== props.instanceId) return
|
||||
void refreshRootFiles()
|
||||
}
|
||||
window.addEventListener(FILE_CHANGE_EVENT, handler)
|
||||
onCleanup(() => window.removeEventListener(FILE_CHANGE_EVENT, handler))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (activeTab() === "skills") {
|
||||
loadCatalog()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const nextTab = props.requestedTab ?? null
|
||||
if (!nextTab || nextTab === lastRequestedTab()) return
|
||||
setActiveTab(nextTab)
|
||||
setLastRequestedTab(nextTab)
|
||||
})
|
||||
|
||||
const filteredSkills = createMemo(() => {
|
||||
const term = skillsFilter().trim().toLowerCase()
|
||||
if (!term) return catalog()
|
||||
return catalog().filter((skill) => {
|
||||
const name = skill.name?.toLowerCase() ?? ""
|
||||
const description = skill.description?.toLowerCase() ?? ""
|
||||
return name.includes(term) || description.includes(term) || skill.id.toLowerCase().includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
const selectedSkills = createMemo(() => {
|
||||
if (!props.activeSessionId) return []
|
||||
return getSessionSkills(props.instanceId, props.activeSessionId)
|
||||
})
|
||||
|
||||
const toggleSkillSelection = (skillId: string) => {
|
||||
if (!props.activeSessionId) return
|
||||
const current = selectedSkills()
|
||||
const exists = current.some((skill) => skill.id === skillId)
|
||||
const next = exists
|
||||
? current.filter((skill) => skill.id !== skillId)
|
||||
: (() => {
|
||||
const found = catalog().find((skill) => skill.id === skillId)
|
||||
if (!found) return current
|
||||
return [...current, { id: found.id, name: found.name, description: found.description }]
|
||||
})()
|
||||
setSessionSkills(props.instanceId, props.activeSessionId, next)
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
const query = searchQuery().trim()
|
||||
if (!query) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
setSearchLoading(true)
|
||||
try {
|
||||
const results = await serverApi.searchWorkspaceFiles(props.instanceId, query, { limit: 50, type: "all" })
|
||||
setSearchResults(
|
||||
results.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
path: entry.path,
|
||||
})),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to search files", error)
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshGitStatus = async () => {
|
||||
setGitLoading(true)
|
||||
try {
|
||||
const status = await serverApi.fetchWorkspaceGitStatus(props.instanceId)
|
||||
setGitStatus(status)
|
||||
} catch (error) {
|
||||
setGitStatus({
|
||||
isRepo: false,
|
||||
branch: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
changes: [],
|
||||
error: error instanceof Error ? error.message : "Unable to load git status",
|
||||
})
|
||||
} finally {
|
||||
setGitLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (activeTab() === "git") {
|
||||
refreshGitStatus()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`flex bg-[#111111] border-r border-white/5 transition-all duration-300 ease-in-out h-full ${props.isOpen ? "w-72" : "w-0 overflow-hidden"}`}
|
||||
>
|
||||
{/* Activity Bar */}
|
||||
<div class="w-14 border-r border-white/5 flex flex-col items-center py-4 space-y-6 shrink-0">
|
||||
<For
|
||||
each={[
|
||||
{ id: "files", icon: Files },
|
||||
{ id: "sessions", icon: User },
|
||||
{ id: "search", icon: Search },
|
||||
{ id: "git", icon: GitBranch },
|
||||
{ id: "debug", icon: Play },
|
||||
{ id: "mcp", icon: Plug },
|
||||
{ id: "skills", icon: Sparkles },
|
||||
{ id: "settings", icon: Settings },
|
||||
]}
|
||||
>
|
||||
{(item) => (
|
||||
<button
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
class={`p-2 transition-all duration-200 relative ${activeTab() === item.id ? "text-white" : "text-zinc-500 hover:text-zinc-300"}`}
|
||||
>
|
||||
<item.icon size={22} strokeWidth={1.5} />
|
||||
<Show when={activeTab() === item.id}>
|
||||
<div class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-6 bg-blue-500 rounded-r-full shadow-[0_0_10px_rgba(59,130,246,0.5)]" />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Side Pane */}
|
||||
<div class="flex-1 flex flex-col py-3 min-w-0">
|
||||
<div class="px-4 mb-4 flex items-center justify-between">
|
||||
<h2 class="text-[10px] uppercase font-bold text-zinc-500 tracking-wider">
|
||||
{activeTab() === "files" ? "Explorer" : activeTab() === "sessions" ? "Sessions" : activeTab()}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto px-2">
|
||||
<Show when={activeTab() === "files"}>
|
||||
<For each={rootFiles()}>
|
||||
{(node) => <FileTree node={node} depth={0} onSelect={props.onFileSelect} instanceId={props.instanceId} />}
|
||||
</For>
|
||||
</Show>
|
||||
<Show when={activeTab() === "sessions"}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<For each={props.sessions}>
|
||||
{(session) => (
|
||||
<div
|
||||
onClick={() => props.onSessionSelect(session.id)}
|
||||
class={`px-3 py-1.5 rounded cursor-pointer text-sm transition-colors ${props.activeSessionId === session.id ? 'bg-blue-600/20 text-blue-400 border border-blue-500/20' : 'text-zinc-400 hover:bg-white/5'}`}
|
||||
>
|
||||
{session.title || session.id.slice(0, 8)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={activeTab() === "search"}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
value={searchQuery()}
|
||||
onInput={(event) => setSearchQuery(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
handleSearch()
|
||||
}
|
||||
}}
|
||||
placeholder="Search files..."
|
||||
class="flex-1 rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
class="px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/30"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
<Show when={searchLoading()}>
|
||||
<div class="text-xs text-zinc-500">Searching...</div>
|
||||
</Show>
|
||||
<Show when={!searchLoading() && searchResults().length === 0 && searchQuery().trim().length > 0}>
|
||||
<div class="text-xs text-zinc-500">No results found.</div>
|
||||
</Show>
|
||||
<div class="flex flex-col gap-1">
|
||||
<For each={searchResults()}>
|
||||
{(result) => (
|
||||
<div
|
||||
onClick={() => props.onFileSelect(result)}
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs text-zinc-300 rounded-md hover:bg-white/5 cursor-pointer"
|
||||
>
|
||||
<span class="text-zinc-500">{result.type === "directory" ? "DIR" : "FILE"}</span>
|
||||
<span class="truncate">{result.path}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={activeTab() === "git"}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs uppercase tracking-wide text-zinc-500">Repository Status</span>
|
||||
<button
|
||||
onClick={refreshGitStatus}
|
||||
class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md border border-white/10 text-zinc-300 hover:text-white"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<Show when={gitLoading()}>
|
||||
<div class="text-xs text-zinc-500">Loading git status...</div>
|
||||
</Show>
|
||||
<Show when={!gitLoading() && gitStatus()}>
|
||||
{(status) => (
|
||||
<div class="flex flex-col gap-3">
|
||||
<Show when={!status().isRepo}>
|
||||
<div class="text-xs text-zinc-500">
|
||||
{status().error ? `Git unavailable: ${status().error}` : "No git repository detected."}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={status().isRepo}>
|
||||
<div class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold">{status().branch || "Detached"}</span>
|
||||
<span class="text-zinc-500">
|
||||
{status().ahead ? `↑${status().ahead}` : ""}
|
||||
{status().behind ? ` ↓${status().behind}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-zinc-500 mt-1">
|
||||
{status().changes.length} change{status().changes.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<For each={status().changes}>
|
||||
{(change) => (
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-300 px-3 py-1 rounded-md hover:bg-white/5">
|
||||
<span class="text-zinc-500 w-6">{change.status}</span>
|
||||
<span class="truncate">{change.path}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={activeTab() === "debug"}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-xs uppercase tracking-wide text-zinc-500">Tools</div>
|
||||
<button
|
||||
onClick={() => props.onOpenCommandPalette?.()}
|
||||
class="px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white"
|
||||
>
|
||||
Open Command Palette
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.onToggleTerminal?.()}
|
||||
class="px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white"
|
||||
>
|
||||
{props.isTerminalOpen ? "Close Terminal" : "Open Terminal"}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={activeTab() === "mcp"}>
|
||||
<McpManager instanceId={props.instanceId} />
|
||||
</Show>
|
||||
<Show when={activeTab() === "skills"}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs uppercase tracking-wide text-zinc-500">Skills</span>
|
||||
<span class="text-[10px] text-zinc-500">
|
||||
{selectedSkills().length} selected
|
||||
</span>
|
||||
</div>
|
||||
<Show when={!props.activeSessionId}>
|
||||
<div class="text-xs text-zinc-500">Select a session to assign skills.</div>
|
||||
</Show>
|
||||
<input
|
||||
value={skillsFilter()}
|
||||
onInput={(event) => setSkillsFilter(event.currentTarget.value)}
|
||||
placeholder="Filter skills..."
|
||||
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
|
||||
/>
|
||||
<Show when={catalogLoading()}>
|
||||
<div class="text-xs text-zinc-500">Loading skills...</div>
|
||||
</Show>
|
||||
<Show when={catalogError()}>
|
||||
{(error) => <div class="text-xs text-amber-400">{error()}</div>}
|
||||
</Show>
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={filteredSkills()}>
|
||||
{(skill) => {
|
||||
const isSelected = () => selectedSkills().some((item) => item.id === skill.id)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSkillSelection(skill.id)}
|
||||
class={`w-full text-left px-3 py-2 rounded-md border transition-colors ${isSelected()
|
||||
? "border-blue-500/60 bg-blue-500/10 text-blue-200"
|
||||
: "border-white/10 bg-white/5 text-zinc-300 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<div class="text-xs font-semibold">{skill.name}</div>
|
||||
<Show when={skill.description}>
|
||||
<div class="text-[11px] text-zinc-500 mt-1">{skill.description}</div>
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={activeTab() === "settings"}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-xs uppercase tracking-wide text-zinc-500">Settings</div>
|
||||
<button
|
||||
onClick={() => props.onOpenAdvancedSettings?.()}
|
||||
class="px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white"
|
||||
>
|
||||
Open Advanced Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.onOpenCommandPalette?.()}
|
||||
class="px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white"
|
||||
>
|
||||
Open Command Palette
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
packages/ui/src/components/kbd.tsx
Normal file
79
packages/ui/src/components/kbd.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Component, JSX, For } from "solid-js"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
|
||||
interface KbdProps {
|
||||
children?: JSX.Element
|
||||
shortcut?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
enter: "Enter",
|
||||
return: "Enter",
|
||||
esc: "Esc",
|
||||
escape: "Esc",
|
||||
tab: "Tab",
|
||||
space: "Space",
|
||||
backspace: "Backspace",
|
||||
delete: "Delete",
|
||||
pageup: "Page Up",
|
||||
pagedown: "Page Down",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
}
|
||||
|
||||
const Kbd: Component<KbdProps> = (props) => {
|
||||
const parts = () => {
|
||||
if (props.children) return [{ text: props.children, isModifier: false }]
|
||||
if (!props.shortcut) return []
|
||||
|
||||
const result: { text: string | JSX.Element; isModifier: boolean }[] = []
|
||||
const shortcut = props.shortcut.toLowerCase()
|
||||
const tokens = shortcut.split("+")
|
||||
|
||||
tokens.forEach((token) => {
|
||||
const trimmed = token.trim()
|
||||
const lower = trimmed.toLowerCase()
|
||||
|
||||
if (lower === "cmd" || lower === "command") {
|
||||
result.push({ text: isMac() ? "Cmd" : "Ctrl", isModifier: false })
|
||||
} else if (lower === "shift") {
|
||||
result.push({ text: "Shift", isModifier: false })
|
||||
} else if (lower === "alt" || lower === "option") {
|
||||
result.push({ text: isMac() ? "Option" : "Alt", isModifier: false })
|
||||
} else if (lower === "ctrl" || lower === "control") {
|
||||
result.push({ text: "Ctrl", isModifier: false })
|
||||
} else {
|
||||
const label = SPECIAL_KEY_LABELS[lower]
|
||||
if (label) {
|
||||
result.push({ text: label, isModifier: false })
|
||||
} else if (trimmed.length === 1) {
|
||||
result.push({ text: trimmed.toUpperCase(), isModifier: false })
|
||||
} else {
|
||||
result.push({ text: trimmed.charAt(0).toUpperCase() + trimmed.slice(1), isModifier: false })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return (
|
||||
<kbd class={`kbd ${props.class || ""}`}>
|
||||
<For each={parts()}>
|
||||
{(part, index) => (
|
||||
<>
|
||||
{index() > 0 && <span class="kbd-separator">+</span>}
|
||||
<span>{part.text}</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
export default Kbd
|
||||
44
packages/ui/src/components/keyboard-hint.tsx
Normal file
44
packages/ui/src/components/keyboard-hint.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Component, For } from "solid-js"
|
||||
import { formatShortcut, isMac } from "../lib/keyboard-utils"
|
||||
import type { KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import Kbd from "./kbd"
|
||||
import HintRow from "./hint-row"
|
||||
|
||||
const KeyboardHint: Component<{
|
||||
shortcuts: KeyboardShortcut[]
|
||||
separator?: string
|
||||
showDescription?: boolean
|
||||
}> = (props) => {
|
||||
function buildShortcutString(shortcut: KeyboardShortcut): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (shortcut.modifiers.ctrl || shortcut.modifiers.meta) {
|
||||
parts.push("cmd")
|
||||
}
|
||||
if (shortcut.modifiers.shift) {
|
||||
parts.push("shift")
|
||||
}
|
||||
if (shortcut.modifiers.alt) {
|
||||
parts.push("alt")
|
||||
}
|
||||
parts.push(shortcut.key)
|
||||
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
return (
|
||||
<HintRow>
|
||||
<For each={props.shortcuts}>
|
||||
{(shortcut, i) => (
|
||||
<>
|
||||
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
|
||||
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
|
||||
<Kbd shortcut={buildShortcutString(shortcut)} />
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</HintRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default KeyboardHint
|
||||
171
packages/ui/src/components/logs-view.tsx
Normal file
171
packages/ui/src/components/logs-view.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
|
||||
interface LogsViewProps {
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
const LogsView: Component<LogsViewProps> = (props) => {
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const savedState = logsScrollState.get(props.instanceId)
|
||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||
|
||||
const instance = () => instances().get(props.instanceId)
|
||||
const logs = createMemo(() => getInstanceLogs(props.instanceId))
|
||||
const streamingEnabled = createMemo(() => isInstanceLogStreaming(props.instanceId))
|
||||
|
||||
const handleEnableLogs = () => setInstanceLogStreaming(props.instanceId, true)
|
||||
const handleDisableLogs = () => setInstanceLogStreaming(props.instanceId, false)
|
||||
|
||||
onMount(() => {
|
||||
|
||||
if (scrollRef && savedState) {
|
||||
scrollRef.scrollTop = savedState.scrollTop
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (scrollRef) {
|
||||
logsScrollState.set(props.instanceId, {
|
||||
scrollTop: scrollRef.scrollTop,
|
||||
autoScroll: autoScroll(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (autoScroll() && scrollRef && logs().length > 0) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
})
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef) return
|
||||
|
||||
const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50
|
||||
|
||||
setAutoScroll(isAtBottom)
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollRef) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return "log-level-error"
|
||||
case "warn":
|
||||
return "log-level-warn"
|
||||
case "debug":
|
||||
return "log-level-debug"
|
||||
default:
|
||||
return "log-level-default"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="log-container">
|
||||
<div class="log-header">
|
||||
<h3 class="text-sm font-medium" style="color: var(--text-secondary)">Server Logs</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
||||
Hide server logs
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}>
|
||||
<div class="env-vars-container">
|
||||
<div class="env-vars-title">
|
||||
Environment Variables ({Object.keys(instance()?.environmentVariables!).length})
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<For each={Object.entries(instance()?.environmentVariables!)}>
|
||||
{([key, value]) => (
|
||||
<div class="env-var-item">
|
||||
<span class="env-var-key">{key}</span>
|
||||
<span class="env-var-separator">=</span>
|
||||
<span class="env-var-value" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
class="log-content"
|
||||
>
|
||||
<Show
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<div class="log-paused-state">
|
||||
<p class="log-paused-title">Server logs are paused</p>
|
||||
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
|
||||
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={logs().length > 0}
|
||||
fallback={<div class="log-empty-state">Waiting for server output...</div>}
|
||||
>
|
||||
<For each={logs()}>
|
||||
{(entry) => (
|
||||
<div class="log-entry">
|
||||
<span class="log-timestamp">{formatTime(entry.timestamp)}</span>
|
||||
<span class={`log-message ${getLevelColor(entry.level)}`}>{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={!autoScroll() && streamingEnabled()}>
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
class="scroll-to-bottom"
|
||||
>
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
Scroll to bottom
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default LogsView
|
||||
178
packages/ui/src/components/markdown.tsx
Normal file
178
packages/ui/src/components/markdown.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { addDebugLog } from "./debug-overlay"
|
||||
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
const markdownRenderCache = new Map<string, RenderCache>()
|
||||
|
||||
function makeMarkdownCacheKey(partId: string, themeKey: string, highlightEnabled: boolean) {
|
||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
part: TextPart
|
||||
isDark?: boolean
|
||||
size?: "base" | "sm" | "tight"
|
||||
disableHighlight?: boolean
|
||||
onRendered?: () => void
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
export function Markdown(props: MarkdownProps) {
|
||||
const [html, setHtml] = createSignal("")
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let latestRequestedText = ""
|
||||
|
||||
const notifyRendered = () => {
|
||||
Promise.resolve().then(() => props.onRendered?.())
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const dark = Boolean(props.isDark)
|
||||
const themeKey = dark ? "dark" : "light"
|
||||
const highlightEnabled = !props.disableHighlight
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "__anonymous__"
|
||||
const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled)
|
||||
|
||||
latestRequestedText = text
|
||||
|
||||
// 1. Check Synchronous Local Cache
|
||||
const localCache = part.renderCache
|
||||
if (localCache && localCache.text === text && localCache.theme === themeKey) {
|
||||
setHtml(localCache.html)
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Check Global Cache
|
||||
const globalCache = markdownRenderCache.get(cacheKey)
|
||||
if (globalCache && globalCache.text === text) {
|
||||
setHtml(globalCache.html)
|
||||
part.renderCache = globalCache
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Throttle/Debounce Rendering for new content
|
||||
// We delay the expensive async render to avoid choking the main thread during rapid streaming
|
||||
const performRender = async () => {
|
||||
if (latestRequestedText !== text) return // Stale
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text, { suppressHighlight: !highlightEnabled })
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
|
||||
setHtml(rendered)
|
||||
part.renderCache = cacheEntry
|
||||
markdownRenderCache.set(cacheKey, cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(text) // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic: If text length matches cache length + small amount, it's streaming.
|
||||
// We can debounce. If it's a huge jump (initial load), render immediately.
|
||||
// For now, always debounce slightly to unblock main thread.
|
||||
// Using 200ms (was 50ms) for less frequent but smoother updates
|
||||
const timerId = setTimeout(performRender, 200)
|
||||
|
||||
onCleanup(() => clearTimeout(timerId))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const handleClick = async (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||
|
||||
if (copyButton) {
|
||||
e.preventDefault()
|
||||
const code = copyButton.getAttribute("data-code")
|
||||
if (code) {
|
||||
const decodedCode = decodeURIComponent(code)
|
||||
await navigator.clipboard.writeText(decodedCode)
|
||||
const copyText = copyButton.querySelector(".copy-text")
|
||||
if (copyText) {
|
||||
copyText.textContent = "Copied!"
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const previewButton = target.closest(".code-block-preview") as HTMLButtonElement
|
||||
if (previewButton) {
|
||||
e.preventDefault()
|
||||
const code = previewButton.getAttribute("data-code")
|
||||
const lang = previewButton.getAttribute("data-lang")
|
||||
|
||||
if (code && lang === "html") {
|
||||
const decodedCode = decodeURIComponent(code)
|
||||
|
||||
// Try to find a filename in the text part
|
||||
const contentText = props.part.text || ""
|
||||
const fileMatch = contentText.match(/(\w+\.html)/)
|
||||
const fileName = fileMatch ? fileMatch[1] : null
|
||||
|
||||
window.dispatchEvent(new CustomEvent("MANUAL_PREVIEW_EVENT", {
|
||||
detail: {
|
||||
code: decodedCode,
|
||||
fileName: fileName,
|
||||
instanceId: props.instanceId
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
containerRef?.addEventListener("click", handleClick)
|
||||
|
||||
// Register listener for language loading completion
|
||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||
if (props.disableHighlight) {
|
||||
return
|
||||
}
|
||||
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
|
||||
if (latestRequestedText !== text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(rendered)
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
part.renderCache = { text, html: rendered, theme: themeKey }
|
||||
notifyRendered()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to re-render markdown after language load:", error)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
containerRef?.removeEventListener("click", handleClick)
|
||||
cleanupLanguageListener()
|
||||
})
|
||||
})
|
||||
|
||||
const proseClass = () => "markdown-body"
|
||||
|
||||
return <div ref={containerRef} class={proseClass()} innerHTML={html()} />
|
||||
}
|
||||
581
packages/ui/src/components/mcp-manager.tsx
Normal file
581
packages/ui/src/components/mcp-manager.tsx
Normal file
@@ -0,0 +1,581 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { ChevronDown, ExternalLink, Plus, RefreshCw, Search, Settings } from "lucide-solid"
|
||||
import { Component, For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
|
||||
type McpServerConfig = {
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
// Remote MCP server support
|
||||
type?: "remote" | "http" | "sse" | "streamable-http"
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
type McpConfig = {
|
||||
mcpServers?: Record<string, McpServerConfig>
|
||||
}
|
||||
|
||||
type McpMarketplaceEntry = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
config: McpServerConfig
|
||||
tags?: string[]
|
||||
source?: string
|
||||
requiresApiKey?: boolean
|
||||
}
|
||||
|
||||
|
||||
interface McpManagerProps {
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
const log = getLogger("mcp-manager")
|
||||
|
||||
const MCP_LINKER_RELEASES = "https://github.com/milisp/mcp-linker/releases"
|
||||
const MCP_LINKER_MARKET = "https://github.com/milisp/mcp-linker"
|
||||
const MARKETPLACE_ENTRIES: McpMarketplaceEntry[] = [
|
||||
{
|
||||
id: "zread",
|
||||
name: "Zread (Z.AI)",
|
||||
description: "Search GitHub repos, read code, analyze structure. Powered by Z.AI - requires API key from z.ai/manage-apikey.",
|
||||
config: {
|
||||
type: "remote",
|
||||
url: "https://api.z.ai/api/mcp/zread/mcp",
|
||||
headers: { "Authorization": "Bearer YOUR_ZAI_API_KEY" }
|
||||
},
|
||||
tags: ["github", "code", "search", "z.ai"],
|
||||
source: "z.ai",
|
||||
requiresApiKey: true,
|
||||
},
|
||||
{
|
||||
id: "sequential-thinking",
|
||||
name: "Sequential Thinking",
|
||||
description: "Step-by-step reasoning scratchpad for complex tasks.",
|
||||
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-sequential-thinking"] },
|
||||
tags: ["reasoning", "planning"],
|
||||
source: "curated",
|
||||
},
|
||||
{
|
||||
id: "desktop-commander",
|
||||
name: "Desktop Commander",
|
||||
description: "Control local desktop actions and automation.",
|
||||
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-desktop-commander"] },
|
||||
tags: ["automation", "local"],
|
||||
source: "curated",
|
||||
},
|
||||
{
|
||||
id: "web-reader",
|
||||
name: "Web Reader",
|
||||
description: "Fetch and summarize web pages with structured metadata.",
|
||||
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-web-reader"] },
|
||||
tags: ["web", "search"],
|
||||
source: "curated",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
description: "Query GitHub repos, issues, and pull requests.",
|
||||
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
|
||||
tags: ["git", "productivity"],
|
||||
source: "curated",
|
||||
},
|
||||
{
|
||||
id: "postgres",
|
||||
name: "PostgreSQL",
|
||||
description: "Inspect PostgreSQL schemas and run safe queries.",
|
||||
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-postgres"] },
|
||||
tags: ["database"],
|
||||
source: "curated",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
const McpManager: Component<McpManagerProps> = (props) => {
|
||||
const [config, setConfig] = createSignal<McpConfig>({ mcpServers: {} })
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
const [showManual, setShowManual] = createSignal(false)
|
||||
const [showMarketplace, setShowMarketplace] = createSignal(false)
|
||||
const [marketplaceQuery, setMarketplaceQuery] = createSignal("")
|
||||
const [marketplaceLoading, setMarketplaceLoading] = createSignal(false)
|
||||
const [marketplaceEntries, setMarketplaceEntries] = createSignal<McpMarketplaceEntry[]>([])
|
||||
const [rawMode, setRawMode] = createSignal(false)
|
||||
const [serverName, setServerName] = createSignal("")
|
||||
const [serverJson, setServerJson] = createSignal("")
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<Record<string, { connected: boolean }>>({})
|
||||
const [toolCount, setToolCount] = createSignal(0)
|
||||
const [connecting, setConnecting] = createSignal(false)
|
||||
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
const metadata = createMemo(() => metadataContext?.metadata?.() ?? null)
|
||||
const mcpStatus = createMemo(() => metadata()?.mcpStatus ?? {})
|
||||
|
||||
const servers = createMemo(() => Object.entries(config().mcpServers ?? {}))
|
||||
const filteredMarketplace = createMemo(() => {
|
||||
const combined = [...MARKETPLACE_ENTRIES, ...marketplaceEntries()]
|
||||
const query = marketplaceQuery().trim().toLowerCase()
|
||||
if (!query) return combined
|
||||
return combined.filter((entry) => {
|
||||
const haystack = `${entry.name} ${entry.description} ${entry.id} ${(entry.tags || []).join(" ")}`.toLowerCase()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const loadConfig = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await serverApi.fetchWorkspaceMcpConfig(props.instanceId)
|
||||
setConfig((data.config ?? { mcpServers: {} }) as McpConfig)
|
||||
} catch (err) {
|
||||
log.error("Failed to load MCP config", err)
|
||||
setError("Failed to load MCP configuration.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
// Fetch connection status separately (non-blocking)
|
||||
loadConnectionStatus().catch(() => { })
|
||||
}
|
||||
|
||||
const loadConnectionStatus = async () => {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
const status = await serverApi.fetchWorkspaceMcpStatus(props.instanceId)
|
||||
clearTimeout(timeoutId)
|
||||
setConnectionStatus(status.servers ?? {})
|
||||
setToolCount(status.toolCount ?? 0)
|
||||
} catch (err) {
|
||||
log.warn("Failed to fetch MCP status", err)
|
||||
// Don't block UI on status failures
|
||||
}
|
||||
}
|
||||
|
||||
const connectAllMcps = async () => {
|
||||
if (connecting()) return
|
||||
setConnecting(true)
|
||||
setError(null)
|
||||
try {
|
||||
log.info("Connecting to all MCP servers...")
|
||||
const result = await serverApi.connectWorkspaceMcps(props.instanceId)
|
||||
log.info("MCP connection result:", result)
|
||||
setConnectionStatus(result.servers ?? {})
|
||||
setToolCount(result.toolCount ?? 0)
|
||||
|
||||
// Check for any connection errors
|
||||
const connectionDetails = (result as any).connectionDetails ?? {}
|
||||
const failedServers = Object.entries(connectionDetails)
|
||||
.filter(([_, details]: [string, any]) => !details.connected)
|
||||
.map(([name, details]: [string, any]) => `${name}: ${details.error || 'Unknown error'}`)
|
||||
|
||||
if (failedServers.length > 0) {
|
||||
setError(`Some servers failed to connect: ${failedServers.join(', ')}`)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to connect MCPs", err)
|
||||
setError("Failed to connect MCP servers. Check console for details.")
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
void loadConfig()
|
||||
})
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
window.open(url, "_blank", "noopener")
|
||||
}
|
||||
|
||||
const resetManualForm = () => {
|
||||
setServerName("")
|
||||
setServerJson("")
|
||||
setRawMode(false)
|
||||
}
|
||||
|
||||
const handleManualSave = async () => {
|
||||
if (saving()) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const parsed = JSON.parse(serverJson() || "{}")
|
||||
const nextConfig: McpConfig = { ...(config() ?? {}) }
|
||||
const mcpServers = { ...(nextConfig.mcpServers ?? {}) }
|
||||
|
||||
if (rawMode()) {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Raw config must be a JSON object.")
|
||||
}
|
||||
setConfig(parsed as McpConfig)
|
||||
await serverApi.updateWorkspaceMcpConfig(props.instanceId, parsed)
|
||||
} else {
|
||||
const name = serverName().trim()
|
||||
if (!name) {
|
||||
throw new Error("Server name is required.")
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Server config must be a JSON object.")
|
||||
}
|
||||
mcpServers[name] = parsed as McpServerConfig
|
||||
nextConfig.mcpServers = mcpServers
|
||||
setConfig(nextConfig)
|
||||
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
|
||||
}
|
||||
|
||||
resetManualForm()
|
||||
setShowManual(false)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Invalid MCP configuration."
|
||||
setError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarketplaceInstall = async (entry: McpMarketplaceEntry) => {
|
||||
if (saving()) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const nextConfig: McpConfig = { ...(config() ?? {}) }
|
||||
const mcpServers = { ...(nextConfig.mcpServers ?? {}) }
|
||||
mcpServers[entry.id] = entry.config
|
||||
nextConfig.mcpServers = mcpServers
|
||||
setConfig(nextConfig)
|
||||
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
|
||||
// Auto-connect after installing
|
||||
await loadConnectionStatus()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to install MCP server."
|
||||
setError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNpmEntries = async (query: string, sourceLabel: string): Promise<McpMarketplaceEntry[]> => {
|
||||
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=50`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${sourceLabel} MCP entries`)
|
||||
}
|
||||
const data = await response.json() as {
|
||||
objects?: Array<{ package?: { name?: string; description?: string; keywords?: string[] } }>
|
||||
}
|
||||
const objects = Array.isArray(data.objects) ? data.objects : []
|
||||
return objects
|
||||
.map((entry) => entry.package)
|
||||
.filter((pkg): pkg is { name: string; description?: string; keywords?: string[] } => Boolean(pkg?.name))
|
||||
.map((pkg) => ({
|
||||
id: pkg.name,
|
||||
name: pkg.name.replace(/^@modelcontextprotocol\/server-/, ""),
|
||||
description: pkg.description || "Community MCP server package",
|
||||
config: { command: "npx", args: ["-y", pkg.name] },
|
||||
tags: pkg.keywords,
|
||||
source: sourceLabel,
|
||||
}))
|
||||
}
|
||||
|
||||
const loadMarketplace = async () => {
|
||||
if (marketplaceLoading()) return
|
||||
setMarketplaceLoading(true)
|
||||
try {
|
||||
const [official, community] = await Promise.allSettled([
|
||||
fetchNpmEntries("@modelcontextprotocol/server", "npm:official"),
|
||||
fetchNpmEntries("mcp server", "npm:community"),
|
||||
])
|
||||
|
||||
const next: McpMarketplaceEntry[] = []
|
||||
if (official.status === "fulfilled") next.push(...official.value)
|
||||
if (community.status === "fulfilled") next.push(...community.value)
|
||||
|
||||
const deduped = new Map<string, McpMarketplaceEntry>()
|
||||
for (const entry of next) {
|
||||
if (!deduped.has(entry.id)) deduped.set(entry.id, entry)
|
||||
}
|
||||
setMarketplaceEntries(Array.from(deduped.values()))
|
||||
} catch (err) {
|
||||
log.error("Failed to load marketplace", err)
|
||||
setError("Failed to load marketplace sources.")
|
||||
} finally {
|
||||
setMarketplaceLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="mcp-manager">
|
||||
<div class="mcp-manager-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</span>
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
class="mcp-icon-button"
|
||||
title="Refresh MCP servers"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mcp-manager-actions">
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
class="mcp-action-button"
|
||||
title="Add MCP"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>Add</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<Show when={menuOpen()}>
|
||||
<div class="mcp-menu">
|
||||
<button
|
||||
class="mcp-menu-item"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
void loadMarketplace()
|
||||
setShowMarketplace(true)
|
||||
}}
|
||||
>
|
||||
Add from Marketplace
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
<button
|
||||
class="mcp-menu-item"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
resetManualForm()
|
||||
setShowManual(true)
|
||||
}}
|
||||
>
|
||||
Add Manually
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openExternal(MCP_LINKER_RELEASES)}
|
||||
class="mcp-link-button"
|
||||
title="Install MCP Linker"
|
||||
>
|
||||
MCP Market
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
{(err) => <div class="text-[11px] text-amber-400">{err()}</div>}
|
||||
</Show>
|
||||
|
||||
<Show when={toolCount() > 0}>
|
||||
<div class="text-[11px] text-green-400 mb-2">
|
||||
✓ {toolCount()} MCP tools available
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!isLoading() && servers().length > 0}
|
||||
fallback={<div class="text-[11px] text-zinc-500 italic">{isLoading() ? "Loading MCP servers..." : "No MCP servers configured."}</div>}
|
||||
>
|
||||
<div class="mcp-server-list">
|
||||
<For each={servers()}>
|
||||
{([name, server]) => {
|
||||
const isConnected = () => connectionStatus()[name]?.connected ?? false
|
||||
return (
|
||||
<div class="mcp-server-card">
|
||||
<div class="mcp-server-row">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-semibold text-zinc-100">{name}</span>
|
||||
<span class="text-[11px] text-zinc-500 truncate">
|
||||
{server.command ? `${server.command} ${(server.args ?? []).join(" ")}` : server.url || "Custom config"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={isConnected()}>
|
||||
<span class="mcp-status-chip" style={{ background: "var(--status-ok, #22c55e)", color: "#fff" }}>
|
||||
connected
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!isConnected()}>
|
||||
<span class="mcp-status-chip" style={{ background: "var(--status-warning, #eab308)", color: "#000" }}>
|
||||
not connected
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<button
|
||||
onClick={connectAllMcps}
|
||||
disabled={connecting()}
|
||||
class="mt-2 px-3 py-1.5 text-xs rounded-md bg-blue-500/20 border border-blue-500/40 text-blue-200 hover:text-white disabled:opacity-60 w-full"
|
||||
>
|
||||
{connecting() ? "Connecting..." : "Connect All MCPs"}
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Dialog open={showManual()} onOpenChange={setShowManual} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-2xl p-5 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-white">Configure MCP Server</Dialog.Title>
|
||||
<Dialog.Description class="text-xs text-zinc-500">
|
||||
Paste the MCP server config JSON. Use marketplace via MCP Linker for curated servers.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs px-2 py-1 rounded border border-white/10 text-zinc-400 hover:text-white"
|
||||
onClick={() => setRawMode((prev) => !prev)}
|
||||
>
|
||||
{rawMode() ? "Server Mode" : "Raw Config (JSON)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={!rawMode()}>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
Server Name
|
||||
<input
|
||||
value={serverName()}
|
||||
onInput={(e) => setServerName(e.currentTarget.value)}
|
||||
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
|
||||
placeholder="example-server"
|
||||
/>
|
||||
</label>
|
||||
</Show>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
Config JSON
|
||||
<textarea
|
||||
value={serverJson()}
|
||||
onInput={(e) => setServerJson(e.currentTarget.value)}
|
||||
class="min-h-[200px] rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500/60"
|
||||
placeholder='{"command":"npx","args":["-y","mcp-server-example"]}'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
resetManualForm()
|
||||
setShowManual(false)
|
||||
}}
|
||||
class="px-3 py-1.5 text-xs rounded-md border border-white/10 text-zinc-300 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleManualSave}
|
||||
disabled={saving()}
|
||||
class="px-3 py-1.5 text-xs rounded-md bg-blue-500/20 border border-blue-500/40 text-blue-200 hover:text-white disabled:opacity-60"
|
||||
>
|
||||
{saving() ? "Saving..." : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showMarketplace()} onOpenChange={setShowMarketplace} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-3xl p-5 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-white">MCP Marketplace</Dialog.Title>
|
||||
<Dialog.Description class="text-xs text-zinc-500">
|
||||
Curated entries inspired by mcp-linker. Install writes to this workspace's .mcp.json.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<button
|
||||
class="mcp-link-button"
|
||||
onClick={() => openExternal(MCP_LINKER_MARKET)}
|
||||
>
|
||||
Open MCP Linker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mcp-market-search">
|
||||
<Search size={14} class="text-zinc-500" />
|
||||
<input
|
||||
value={marketplaceQuery()}
|
||||
onInput={(e) => setMarketplaceQuery(e.currentTarget.value)}
|
||||
placeholder="Search MCP servers..."
|
||||
class="mcp-market-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mcp-market-list">
|
||||
<Show
|
||||
when={!marketplaceLoading()}
|
||||
fallback={<div class="text-[11px] text-zinc-500 italic">Loading marketplace sources...</div>}
|
||||
>
|
||||
<For each={filteredMarketplace()}>
|
||||
{(entry) => (
|
||||
<div class="mcp-market-card">
|
||||
<div class="mcp-market-card-info">
|
||||
<div class="mcp-market-card-title">
|
||||
{entry.name}
|
||||
<Show when={entry.source}>
|
||||
{(source) => <span class="mcp-market-source">{source()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mcp-market-card-desc">{entry.description}</div>
|
||||
<Show when={entry.tags && entry.tags.length > 0}>
|
||||
<div class="mcp-market-tags">
|
||||
<For each={entry.tags}>
|
||||
{(tag) => <span class="mcp-market-tag">{tag}</span>}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mcp-market-card-actions">
|
||||
<button
|
||||
class="mcp-icon-button"
|
||||
title="View config"
|
||||
onClick={() => {
|
||||
setShowManual(true)
|
||||
setRawMode(false)
|
||||
setServerName(entry.id)
|
||||
setServerJson(JSON.stringify(entry.config, null, 2))
|
||||
setShowMarketplace(false)
|
||||
}}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
<button
|
||||
class="mcp-market-install"
|
||||
onClick={() => handleMarketplaceInstall(entry)}
|
||||
disabled={saving()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpManager
|
||||
64
packages/ui/src/components/message-block-list.tsx
Normal file
64
packages/ui/src/components/message-block-list.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Index, type Accessor } from "solid-js"
|
||||
import VirtualItem from "./virtual-item"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
export function getMessageAnchorId(messageId: string) {
|
||||
return `message-anchor-${messageId}`
|
||||
}
|
||||
|
||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||
|
||||
interface MessageBlockListProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageIds: () => string[]
|
||||
lastAssistantIndex: () => number
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => boolean
|
||||
showUsageMetrics: () => boolean
|
||||
scrollContainer: Accessor<HTMLDivElement | undefined>
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
onContentRendered?: () => void
|
||||
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||
suspendMeasurements?: () => boolean
|
||||
}
|
||||
|
||||
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||
return (
|
||||
<>
|
||||
<Index each={props.messageIds()}>
|
||||
{(messageId, index) => (
|
||||
<VirtualItem
|
||||
id={getMessageAnchorId(messageId())}
|
||||
cacheKey={messageId()}
|
||||
scrollContainer={props.scrollContainer}
|
||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={() => !props.loading}
|
||||
suspendMeasurements={props.suspendMeasurements}
|
||||
>
|
||||
<MessageBlock
|
||||
messageId={messageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndex={index}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showThinking={props.showThinking}
|
||||
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||
showUsageMetrics={props.showUsageMetrics}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</VirtualItem>
|
||||
)}
|
||||
</Index>
|
||||
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
739
packages/ui/src/components/message-block.tsx
Normal file
739
packages/ui/src/components/message-block.tsx
Normal file
@@ -0,0 +1,739 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { addDebugLog } from "./debug-overlay"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { ClientPart, MessageInfo } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
|
||||
const TOOL_ICON = "🔧"
|
||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||
|
||||
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
|
||||
return Boolean(state && state.status === "running")
|
||||
}
|
||||
|
||||
function isToolStateCompleted(state: ToolState | undefined): state is ToolStateCompleted {
|
||||
return Boolean(state && state.status === "completed")
|
||||
}
|
||||
|
||||
function isToolStateError(state: ToolState | undefined): state is ToolStateError {
|
||||
return Boolean(state && state.status === "error")
|
||||
}
|
||||
|
||||
function extractTaskSessionId(state: ToolState | undefined): string {
|
||||
if (!state) return ""
|
||||
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
|
||||
const directId = metadata?.sessionId ?? metadata?.sessionID
|
||||
return typeof directId === "string" ? directId : ""
|
||||
}
|
||||
|
||||
function reasoningHasRenderableContent(part: ClientPart): boolean {
|
||||
if (!part || part.type !== "reasoning") {
|
||||
return false
|
||||
}
|
||||
const checkSegment = (segment: unknown): boolean => {
|
||||
if (typeof segment === "string") {
|
||||
return segment.trim().length > 0
|
||||
}
|
||||
if (segment && typeof segment === "object") {
|
||||
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(candidate.content)) {
|
||||
return candidate.content.some((entry) => checkSegment(entry))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (checkSegment((part as any).text)) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray((part as any).content)) {
|
||||
return (part as any).content.some((entry: unknown) => checkSegment(entry))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
interface TaskSessionLocation {
|
||||
sessionId: string
|
||||
instanceId: string
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
|
||||
if (!sessionId) return null
|
||||
const allSessions = sessions()
|
||||
for (const [instanceId, sessionMap] of allSessions) {
|
||||
const session = sessionMap?.get(sessionId)
|
||||
if (session) {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
instanceId,
|
||||
parentId: session.parentId ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||
setActiveInstanceId(location.instanceId)
|
||||
const parentToActivate = location.parentId ?? location.sessionId
|
||||
setActiveParentSession(location.instanceId, parentToActivate)
|
||||
if (location.parentId) {
|
||||
setActiveSession(location.instanceId, location.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
interface CachedBlockEntry {
|
||||
signature: string
|
||||
block: MessageDisplayBlock
|
||||
contentKeys: string[]
|
||||
toolKeys: string[]
|
||||
}
|
||||
|
||||
interface SessionRenderCache {
|
||||
messageItems: Map<string, ContentDisplayItem>
|
||||
toolItems: Map<string, ToolDisplayItem>
|
||||
messageBlocks: Map<string, CachedBlockEntry>
|
||||
}
|
||||
|
||||
const renderCaches = new Map<string, SessionRenderCache>()
|
||||
|
||||
function makeSessionCacheKey(instanceId: string, sessionId: string) {
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
export function clearSessionRenderCache(instanceId: string, sessionId: string) {
|
||||
renderCaches.delete(makeSessionCacheKey(instanceId, sessionId))
|
||||
}
|
||||
|
||||
function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache {
|
||||
const key = makeSessionCacheKey(instanceId, sessionId)
|
||||
let cache = renderCaches.get(key)
|
||||
if (!cache) {
|
||||
cache = {
|
||||
messageItems: new Map(),
|
||||
toolItems: new Map(),
|
||||
messageBlocks: new Map(),
|
||||
}
|
||||
renderCaches.set(key, cache)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
function clearInstanceCaches(instanceId: string) {
|
||||
clearRecordDisplayCacheForInstance(instanceId)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of renderCaches.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
renderCaches.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
||||
|
||||
interface ContentDisplayItem {
|
||||
type: "content"
|
||||
key: string
|
||||
record: MessageRecord
|
||||
parts: ClientPart[]
|
||||
messageInfo?: MessageInfo
|
||||
isQueued: boolean
|
||||
showAgentMeta?: boolean
|
||||
}
|
||||
|
||||
interface ToolDisplayItem {
|
||||
type: "tool"
|
||||
key: string
|
||||
toolPart: ToolCallPart
|
||||
messageInfo?: MessageInfo
|
||||
messageId: string
|
||||
messageVersion: number
|
||||
partVersion: number
|
||||
}
|
||||
|
||||
interface StepDisplayItem {
|
||||
type: "step-start" | "step-finish"
|
||||
key: string
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
accentColor?: string
|
||||
}
|
||||
|
||||
type ReasoningDisplayItem = {
|
||||
type: "reasoning"
|
||||
key: string
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded: boolean
|
||||
}
|
||||
|
||||
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem
|
||||
|
||||
interface MessageDisplayBlock {
|
||||
record: MessageRecord
|
||||
items: MessageBlockItem[]
|
||||
}
|
||||
|
||||
interface MessageBlockProps {
|
||||
messageId: string
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageIndex: number
|
||||
lastAssistantIndex: () => number
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => boolean
|
||||
showUsageMetrics: () => boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
export default function MessageBlock(props: MessageBlockProps) {
|
||||
// CRITICAL FIX: Use untrack for store access to prevent cascading updates during streaming
|
||||
// The component will still re-render when needed via the Index component in MessageBlockList
|
||||
const record = createMemo(() => {
|
||||
// Only create reactive dependency on message ID, not content
|
||||
const id = props.messageId;
|
||||
return untrack(() => props.store().getMessage(id));
|
||||
})
|
||||
const messageInfo = createMemo(() => {
|
||||
const id = props.messageId;
|
||||
return untrack(() => props.store().getMessageInfo(id));
|
||||
})
|
||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||
|
||||
// CRITICAL: Use a throttled revision check to avoid re-computing on every streaming chunk
|
||||
const [lastProcessedRevision, setLastProcessedRevision] = createSignal(0);
|
||||
|
||||
const block = createMemo<MessageDisplayBlock | null>(() => {
|
||||
const current = record()
|
||||
if (!current) return null
|
||||
|
||||
// OPTIMIZATION: Skip cache during streaming (revision changes too fast)
|
||||
// Just return a basic block structure that will be updated when streaming completes
|
||||
const isStreaming = current.status === "streaming" || current.status === "sending";
|
||||
|
||||
const index = props.messageIndex
|
||||
const lastAssistantIdx = props.lastAssistantIndex()
|
||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||
const info = messageInfo()
|
||||
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
||||
const infoTimestamp =
|
||||
typeof infoTime.completed === "number"
|
||||
? infoTime.completed
|
||||
: typeof infoTime.updated === "number"
|
||||
? infoTime.updated
|
||||
: infoTime.created ?? 0
|
||||
const infoError = (info as { error?: { name?: string } } | undefined)?.error
|
||||
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
|
||||
|
||||
// Skip revision in cache signature during streaming
|
||||
const cacheSignature = [
|
||||
current.id,
|
||||
isStreaming ? "streaming" : current.revision,
|
||||
isQueued ? 1 : 0,
|
||||
props.showThinking() ? 1 : 0,
|
||||
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||
props.showUsageMetrics() ? 1 : 0,
|
||||
infoTimestamp,
|
||||
infoErrorName,
|
||||
].join("|")
|
||||
|
||||
const cachedBlock = sessionCache.messageBlocks.get(current.id)
|
||||
if (cachedBlock && cachedBlock.signature === cacheSignature) {
|
||||
return cachedBlock.block
|
||||
}
|
||||
|
||||
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
|
||||
const items: MessageBlockItem[] = []
|
||||
const blockContentKeys: string[] = []
|
||||
const blockToolKeys: string[] = []
|
||||
let segmentIndex = 0
|
||||
let pendingParts: ClientPart[] = []
|
||||
let agentMetaAttached = current.role !== "assistant"
|
||||
const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
|
||||
let lastAccentColor = defaultAccentColor
|
||||
|
||||
const flushContent = () => {
|
||||
if (pendingParts.length === 0) return
|
||||
const segmentKey = `${current.id}:segment:${segmentIndex}`
|
||||
segmentIndex += 1
|
||||
const shouldShowAgentMeta =
|
||||
current.role === "assistant" &&
|
||||
!agentMetaAttached &&
|
||||
pendingParts.some((part) => partHasRenderableText(part))
|
||||
|
||||
// Always create a fresh object to ensure granular reactivity in <For>
|
||||
// when we remove 'keyed' from <Show>. If we mutated properties
|
||||
// on an existing object, <For> would assume identity match and skip updates.
|
||||
const cached: ContentDisplayItem = {
|
||||
type: "content",
|
||||
key: segmentKey,
|
||||
record: current,
|
||||
parts: pendingParts.slice(),
|
||||
messageInfo: info,
|
||||
isQueued,
|
||||
showAgentMeta: shouldShowAgentMeta,
|
||||
}
|
||||
|
||||
// Update cache with the new version (for potential stability elsewhere, though less critical now)
|
||||
sessionCache.messageItems.set(segmentKey, cached)
|
||||
|
||||
if (shouldShowAgentMeta) {
|
||||
agentMetaAttached = true
|
||||
}
|
||||
items.push(cached)
|
||||
blockContentKeys.push(segmentKey)
|
||||
lastAccentColor = defaultAccentColor
|
||||
pendingParts = []
|
||||
}
|
||||
|
||||
orderedParts.forEach((part, partIndex) => {
|
||||
if (part.type === "tool") {
|
||||
flushContent()
|
||||
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0
|
||||
const messageVersion = current.revision
|
||||
const key = `${current.id}:${part.id ?? partIndex}`
|
||||
let toolItem = sessionCache.toolItems.get(key)
|
||||
if (!toolItem) {
|
||||
toolItem = {
|
||||
type: "tool",
|
||||
key,
|
||||
toolPart: part as ToolCallPart,
|
||||
messageInfo: info,
|
||||
messageId: current.id,
|
||||
messageVersion,
|
||||
partVersion,
|
||||
}
|
||||
sessionCache.toolItems.set(key, toolItem)
|
||||
} else {
|
||||
toolItem.key = key
|
||||
toolItem.toolPart = part as ToolCallPart
|
||||
toolItem.messageInfo = info
|
||||
toolItem.messageId = current.id
|
||||
toolItem.messageVersion = messageVersion
|
||||
toolItem.partVersion = partVersion
|
||||
}
|
||||
items.push(toolItem)
|
||||
blockToolKeys.push(key)
|
||||
lastAccentColor = TOOL_BORDER_COLOR
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "step-start") {
|
||||
flushContent()
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "step-finish") {
|
||||
flushContent()
|
||||
if (props.showUsageMetrics()) {
|
||||
const key = `${current.id}:${part.id ?? partIndex}:${part.type}`
|
||||
const accentColor = lastAccentColor || defaultAccentColor
|
||||
items.push({ type: part.type, key, part, messageInfo: info, accentColor })
|
||||
lastAccentColor = accentColor
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
flushContent()
|
||||
if (props.showThinking() && reasoningHasRenderableContent(part)) {
|
||||
const key = `${current.id}:${part.id ?? partIndex}:reasoning`
|
||||
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
|
||||
if (showAgentMeta) {
|
||||
agentMetaAttached = true
|
||||
}
|
||||
items.push({
|
||||
type: "reasoning",
|
||||
key,
|
||||
part,
|
||||
messageInfo: info,
|
||||
showAgentMeta,
|
||||
defaultExpanded: props.thinkingDefaultExpanded(),
|
||||
})
|
||||
lastAccentColor = ASSISTANT_BORDER_COLOR
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pendingParts.push(part)
|
||||
})
|
||||
|
||||
flushContent()
|
||||
|
||||
const resultBlock: MessageDisplayBlock = { record: current, items }
|
||||
sessionCache.messageBlocks.set(current.id, {
|
||||
signature: cacheSignature,
|
||||
block: resultBlock,
|
||||
contentKeys: blockContentKeys.slice(),
|
||||
toolKeys: blockToolKeys.slice(),
|
||||
})
|
||||
|
||||
const messagePrefix = `${current.id}:`
|
||||
for (const [key] of sessionCache.messageItems) {
|
||||
if (key.startsWith(messagePrefix) && !blockContentKeys.includes(key)) {
|
||||
sessionCache.messageItems.delete(key)
|
||||
}
|
||||
}
|
||||
for (const [key] of sessionCache.toolItems) {
|
||||
if (key.startsWith(messagePrefix) && !blockToolKeys.includes(key)) {
|
||||
sessionCache.toolItems.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return resultBlock
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={block()}>
|
||||
{(resolvedBlock) => (
|
||||
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
|
||||
<For each={resolvedBlock().items}>
|
||||
{(item) => (
|
||||
<Switch>
|
||||
<Match when={item.type === "content"}>
|
||||
<MessageItem
|
||||
record={(item as ContentDisplayItem).record}
|
||||
messageInfo={(item as ContentDisplayItem).messageInfo}
|
||||
parts={(item as ContentDisplayItem).parts}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={(item as ContentDisplayItem).isQueued}
|
||||
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "tool"}>
|
||||
{(() => {
|
||||
const toolItem = item as ToolDisplayItem
|
||||
const toolState = toolItem.toolPart.state as ToolState | undefined
|
||||
const hasToolState =
|
||||
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
||||
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
||||
const handleGoToTaskSession = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!taskLocation) return
|
||||
navigateToTaskSession(taskLocation)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-message" data-key={toolItem.key}>
|
||||
<div class="tool-call-header-label">
|
||||
<div class="tool-call-header-meta">
|
||||
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||
<span>Tool Call</span>
|
||||
<span class="tool-name">{toolItem.toolPart.tool || "unknown"}</span>
|
||||
</div>
|
||||
<Show when={taskSessionId}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={!taskLocation ? "Session not available yet" : "Go to session"}
|
||||
>
|
||||
Go to Session
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<ToolCall
|
||||
toolCall={toolItem.toolPart}
|
||||
toolCallId={toolItem.key}
|
||||
messageId={toolItem.messageId}
|
||||
messageVersion={toolItem.messageVersion}
|
||||
partVersion={toolItem.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={item.type === "step-start"}>
|
||||
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
|
||||
</Match>
|
||||
<Match when={item.type === "step-finish"}>
|
||||
<StepCard
|
||||
kind="finish"
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
showUsage={props.showUsageMetrics()}
|
||||
borderColor={(item as StepDisplayItem).accentColor}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
<ReasoningCard
|
||||
part={(item as ReasoningDisplayItem).part}
|
||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepCardProps {
|
||||
kind: "start" | "finish"
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
showAgentMeta?: boolean
|
||||
showUsage?: boolean
|
||||
borderColor?: string
|
||||
}
|
||||
|
||||
function StepCard(props: StepCardProps) {
|
||||
const timestamp = () => {
|
||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||
const date = new Date(value)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const agentIdentifier = () => {
|
||||
if (!props.showAgentMeta) return ""
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
return info.mode || ""
|
||||
}
|
||||
|
||||
const modelIdentifier = () => {
|
||||
if (!props.showAgentMeta) return ""
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
const modelID = info.modelID || ""
|
||||
const providerID = info.providerID || ""
|
||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||
return modelID
|
||||
}
|
||||
|
||||
const usageStats = () => {
|
||||
if (props.kind !== "finish" || !props.showUsage) {
|
||||
return null
|
||||
}
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant" || !info.tokens) {
|
||||
return null
|
||||
}
|
||||
const tokens = info.tokens
|
||||
return {
|
||||
input: tokens.input ?? 0,
|
||||
output: tokens.output ?? 0,
|
||||
reasoning: tokens.reasoning ?? 0,
|
||||
cacheRead: tokens.cache?.read ?? 0,
|
||||
cacheWrite: tokens.cache?.write ?? 0,
|
||||
cost: info.cost ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
|
||||
|
||||
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
||||
const entries = [
|
||||
{ label: "Input", value: usage.input, formatter: formatTokenTotal },
|
||||
{ label: "Output", value: usage.output, formatter: formatTokenTotal },
|
||||
{ label: "Reasoning", value: usage.reasoning, formatter: formatTokenTotal },
|
||||
{ label: "Cache Read", value: usage.cacheRead, formatter: formatTokenTotal },
|
||||
{ label: "Cache Write", value: usage.cacheWrite, formatter: formatTokenTotal },
|
||||
{ label: "Cost", value: usage.cost, formatter: formatCostValue },
|
||||
]
|
||||
|
||||
return (
|
||||
<div class="message-step-usage">
|
||||
<For each={entries}>
|
||||
{(entry) => (
|
||||
<span class="message-step-usage-chip" data-label={entry.label}>
|
||||
{entry.formatter(entry.value)}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (props.kind === "finish") {
|
||||
const usage = usageStats()
|
||||
if (!usage) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}>
|
||||
{renderUsageChips(usage)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`message-step-card message-step-start`}>
|
||||
<div class="message-step-heading">
|
||||
<div class="message-step-title">
|
||||
<div class="message-step-title-left">
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="message-step-time">{timestamp()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCostValue(value: number) {
|
||||
if (!value) return "$0.00"
|
||||
if (value < 0.01) return `$${value.toPrecision(2)}`
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
interface ReasoningCardProps {
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
})
|
||||
|
||||
const timestamp = () => {
|
||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||
const date = new Date(value)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const agentIdentifier = () => {
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
return info.mode || ""
|
||||
}
|
||||
|
||||
const modelIdentifier = () => {
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
const modelID = info.modelID || ""
|
||||
const providerID = info.providerID || ""
|
||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||
return modelID
|
||||
}
|
||||
|
||||
const reasoningText = () => {
|
||||
const part = props.part as any
|
||||
if (!part) return ""
|
||||
|
||||
const stringifySegment = (segment: unknown): string => {
|
||||
if (typeof segment === "string") {
|
||||
return segment
|
||||
}
|
||||
if (segment && typeof segment === "object") {
|
||||
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
const pieces: string[] = []
|
||||
if (typeof obj.text === "string") {
|
||||
pieces.push(obj.text)
|
||||
}
|
||||
if (typeof obj.value === "string") {
|
||||
pieces.push(obj.value)
|
||||
}
|
||||
if (Array.isArray(obj.content)) {
|
||||
pieces.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
|
||||
}
|
||||
return pieces.filter((piece) => piece && piece.trim().length > 0).join("\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const textValue = stringifySegment(part.text)
|
||||
if (textValue.trim().length > 0) {
|
||||
return textValue
|
||||
}
|
||||
if (Array.isArray(part.content)) {
|
||||
return part.content.map((entry: unknown) => stringifySegment(entry)).join("\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const toggle = () => setExpanded((prev) => !prev)
|
||||
|
||||
return (
|
||||
<div class="message-reasoning-card">
|
||||
<button
|
||||
type="button"
|
||||
class="message-reasoning-toggle"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
aria-label={expanded() ? "Collapse thinking" : "Expand thinking"}
|
||||
>
|
||||
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
||||
<span>Thinking</span>
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="message-reasoning-meta">
|
||||
<span class="message-reasoning-indicator">{expanded() ? "Hide" : "View"}</span>
|
||||
<span class="message-reasoning-time">{timestamp()}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Show when={expanded()}>
|
||||
<div class="message-reasoning-expanded">
|
||||
<div class="message-reasoning-body">
|
||||
<div class="message-reasoning-output" role="region" aria-label="Reasoning details">
|
||||
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
439
packages/ui/src/components/message-item.tsx
Normal file
439
packages/ui/src/components/message-item.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { compactSession } from "../stores/session-actions"
|
||||
import { clearCompactionSuggestion } from "../stores/session-compaction"
|
||||
import MessagePart from "./message-part"
|
||||
|
||||
interface MessageItemProps {
|
||||
record: MessageRecord
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
isQueued?: boolean
|
||||
parts: ClientPart[]
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
showAgentMeta?: boolean
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const isUser = () => props.record.role === "user"
|
||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||
|
||||
const timestamp = () => {
|
||||
const date = new Date(createdTimestamp())
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const timestampIso = () => new Date(createdTimestamp()).toISOString()
|
||||
|
||||
type FilePart = Extract<ClientPart, { type: "file" }> & {
|
||||
url?: string
|
||||
mime?: string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
const messageParts = () => props.parts
|
||||
|
||||
const fileAttachments = () =>
|
||||
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||
|
||||
|
||||
const getAttachmentName = (part: FilePart) => {
|
||||
if (part.filename && part.filename.trim().length > 0) {
|
||||
return part.filename
|
||||
}
|
||||
const url = part.url || ""
|
||||
if (url.startsWith("data:")) {
|
||||
return "attachment"
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const segments = parsed.pathname.split("/")
|
||||
return segments.pop() || "attachment"
|
||||
} catch (error) {
|
||||
const fallback = url.split("/").pop()
|
||||
return fallback && fallback.length > 0 ? fallback : "attachment"
|
||||
}
|
||||
}
|
||||
|
||||
const isImageAttachment = (part: FilePart) => {
|
||||
if (part.mime && typeof part.mime === "string" && part.mime.startsWith("image/")) {
|
||||
return true
|
||||
}
|
||||
return typeof part.url === "string" && part.url.startsWith("data:image/")
|
||||
}
|
||||
|
||||
const handleAttachmentDownload = async (part: FilePart) => {
|
||||
const url = part.url
|
||||
if (!url) return
|
||||
|
||||
const filename = getAttachmentName(part)
|
||||
const directDownload = (href: string) => {
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = href
|
||||
anchor.download = filename
|
||||
anchor.target = "_blank"
|
||||
anchor.rel = "noopener"
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
}
|
||||
|
||||
if (url.startsWith("data:")) {
|
||||
directDownload(url)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.startsWith("file://")) {
|
||||
window.open(url, "_blank", "noopener")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error(`Failed to fetch attachment: ${response.status}`)
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
directDownload(objectUrl)
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
} catch (error) {
|
||||
directDownload(url)
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = () => {
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant" || !info.error) return null
|
||||
|
||||
const error = info.error
|
||||
if (error.name === "ProviderAuthError") {
|
||||
return error.data?.message || "Authentication error"
|
||||
}
|
||||
if (error.name === "MessageOutputLengthError") {
|
||||
return "Message output length exceeded"
|
||||
}
|
||||
if (error.name === "MessageAbortedError") {
|
||||
return "Request was aborted"
|
||||
}
|
||||
if (error.name === "UnknownError") {
|
||||
return error.data?.message || "Unknown error occurred"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const isContextError = () => {
|
||||
const info = props.messageInfo
|
||||
if (!info) return false
|
||||
const errorMessage = (info as any).error?.data?.message || (info as any).error?.message || ""
|
||||
return (
|
||||
errorMessage.includes("maximum context length") ||
|
||||
errorMessage.includes("context_length_exceeded") ||
|
||||
errorMessage.includes("token count exceeds") ||
|
||||
errorMessage.includes("token limit")
|
||||
)
|
||||
}
|
||||
|
||||
const handleCompact = async () => {
|
||||
try {
|
||||
clearCompactionSuggestion(props.instanceId, props.sessionId)
|
||||
await compactSession(props.instanceId, props.sessionId)
|
||||
} catch (error) {
|
||||
console.error("Failed to compact session:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const hasContent = () => {
|
||||
if (errorMessage() !== null) {
|
||||
return true
|
||||
}
|
||||
|
||||
return messageParts().some((part) => partHasRenderableText(part))
|
||||
}
|
||||
|
||||
const isGenerating = () => {
|
||||
const info = props.messageInfo
|
||||
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
|
||||
}
|
||||
|
||||
const isStreaming = () => {
|
||||
return props.record.status === "streaming"
|
||||
}
|
||||
|
||||
const currentTokenCount = () => {
|
||||
if (!isStreaming()) return null
|
||||
const textParts = props.parts.filter(p => p.type === "text")
|
||||
return textParts.reduce((sum, p) => {
|
||||
const text = (p as { text?: string }).text || ""
|
||||
return sum + text.length
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const handleRevert = () => {
|
||||
if (props.onRevert && isUser()) {
|
||||
props.onRevert(props.record.id)
|
||||
}
|
||||
}
|
||||
|
||||
const getRawContent = () => {
|
||||
return props.parts
|
||||
.filter(part => part.type === "text")
|
||||
.map(part => (part as { text?: string }).text || "")
|
||||
.filter(text => text.trim().length > 0)
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
const content = getRawContent()
|
||||
if (!content) return
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!isUser() && !hasContent()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const containerClass = () =>
|
||||
isUser()
|
||||
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
||||
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
||||
|
||||
const speakerLabel = () => (isUser() ? "You" : "Assistant")
|
||||
|
||||
const agentIdentifier = () => {
|
||||
if (isUser()) return ""
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
return info.mode || ""
|
||||
}
|
||||
|
||||
const modelIdentifier = () => {
|
||||
if (isUser()) return ""
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
const modelID = info.modelID || ""
|
||||
const providerID = info.providerID || ""
|
||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||
return modelID || "unknown"
|
||||
}
|
||||
|
||||
const agentMeta = () => {
|
||||
if (isUser() || !props.showAgentMeta) return ""
|
||||
const segments: string[] = []
|
||||
const agent = agentIdentifier()
|
||||
const model = modelIdentifier()
|
||||
if (agent) {
|
||||
segments.push(`Agent: ${agent}`)
|
||||
}
|
||||
if (model) {
|
||||
segments.push(`Model: ${model}`)
|
||||
}
|
||||
return segments.join(" • ")
|
||||
}
|
||||
|
||||
const modelBadge = () => {
|
||||
if (isUser()) return null
|
||||
const model = modelIdentifier()
|
||||
if (!model) return null
|
||||
return (
|
||||
<span class="message-model-badge" title={`Model: ${model}`}>
|
||||
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="text-xs font-medium text-zinc-400">{model}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div class={containerClass()}>
|
||||
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||
<div class="message-speaker">
|
||||
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||
{speakerLabel()}
|
||||
</span>
|
||||
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
|
||||
</div>
|
||||
<div class="message-item-actions">
|
||||
<Show when={isUser()}>
|
||||
<div class="message-action-group">
|
||||
<Show when={props.onRevert}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleRevert}
|
||||
title="Revert to this message"
|
||||
aria-label="Revert to this message"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onFork}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => props.onFork?.(props.record.id)}
|
||||
title="Fork from this message"
|
||||
aria-label="Fork from this message"
|
||||
>
|
||||
Fork
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
>
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isUser()}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
>
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={modelBadge()}>
|
||||
{(badge) => (
|
||||
<span class="ml-2">{badge()}</span>
|
||||
)}
|
||||
</Show>
|
||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||
|
||||
<Show when={isStreaming()}>
|
||||
<div class="message-streaming-indicator">
|
||||
<span class="streaming-status">
|
||||
<span class="streaming-pulse"></span>
|
||||
<span class="streaming-text">Thinking</span>
|
||||
</span>
|
||||
<Show when={currentTokenCount() !== null}>
|
||||
{(count) => (
|
||||
<span class="streaming-tokens">
|
||||
<span class="streaming-token-count">{count()}</span>
|
||||
<span class="streaming-token-label">tokens</span>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.isQueued && isUser()}>
|
||||
<div class="message-queued-badge">QUEUED</div>
|
||||
</Show>
|
||||
|
||||
<Show when={errorMessage()}>
|
||||
<div class="message-error-block">
|
||||
<div class="flex items-start gap-2">
|
||||
<span>⚠️ {errorMessage()}</span>
|
||||
<Show when={isContextError()}>
|
||||
<button
|
||||
onClick={handleCompact}
|
||||
class="compact-button"
|
||||
title="Compact session to reduce context usage"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v16l6-6-6 6M4 20l6-6 6-6" />
|
||||
</svg>
|
||||
Compact
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isGenerating()}>
|
||||
<div class="message-generating">
|
||||
<span class="generating-spinner">⏳</span> Generating...
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={messageParts()}>
|
||||
{(part) => (
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.record.role}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={fileAttachments().length > 0}>
|
||||
<div class="message-attachments mt-1">
|
||||
<For each={fileAttachments()}>
|
||||
{(attachment) => {
|
||||
const name = getAttachmentName(attachment)
|
||||
const isImage = isImageAttachment(attachment)
|
||||
return (
|
||||
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
|
||||
<Show when={isImage} fallback={
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
}>
|
||||
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
||||
</Show>
|
||||
<span class="truncate max-w-[180px]">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAttachmentDownload(attachment)}
|
||||
class="attachment-download"
|
||||
aria-label={`Download ${name}`}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isImage}>
|
||||
<div class="attachment-chip-preview">
|
||||
<img src={attachment.url} alt={name} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.record.status === "sending"}>
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.record.status === "error"}>
|
||||
<div class="message-error">⚠ Message failed to send</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
packages/ui/src/components/message-list-header.tsx
Normal file
85
packages/ui/src/components/message-list-header.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Show } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||
|
||||
interface MessageListHeaderProps {
|
||||
usedTokens: number
|
||||
|
||||
availableTokens?: number | null
|
||||
connectionStatus: "connected" | "connecting" | "error" | "disconnected" | "unknown" | null
|
||||
onCommandPalette: () => void
|
||||
formatTokens: (value: number) => string
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
}
|
||||
|
||||
export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
|
||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||
|
||||
return (
|
||||
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
||||
<Show when={props.showSidebarToggle}>
|
||||
<div class="connection-status-menu">
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-menu-button"
|
||||
onClick={() => props.onSidebarToggle?.()}
|
||||
aria-label="Open session list"
|
||||
>
|
||||
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="connection-status-text connection-status-info">
|
||||
<div class="connection-status-usage">
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>Used</span>
|
||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||
</div>
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>Avail</span>
|
||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status-text connection-status-shortcut">
|
||||
<div class="connection-status-shortcut-action">
|
||||
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
|
||||
Command Palette
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||
<Show when={props.connectionStatus === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connected</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.connectionStatus === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connecting...</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Disconnected</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
packages/ui/src/components/message-part.tsx
Normal file
130
packages/ui/src/components/message-part.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Show, Match, Switch } from "solid-js"
|
||||
import ToolCall from "./tool-call"
|
||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||
import { Markdown } from "./markdown"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
interface MessagePartProps {
|
||||
part: ClientPart
|
||||
messageType?: "user" | "assistant"
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
onRendered?: () => void
|
||||
}
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { preferences } = useConfig()
|
||||
const partType = () => props.part?.type || ""
|
||||
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
||||
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
||||
const isAssistantMessage = () => props.messageType === "assistant"
|
||||
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
|
||||
|
||||
const plainTextContent = () => {
|
||||
const part = props.part
|
||||
|
||||
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
||||
return part.text
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function reasoningSegmentHasText(segment: unknown): boolean {
|
||||
if (typeof segment === "string") {
|
||||
return segment.trim().length > 0
|
||||
}
|
||||
if (segment && typeof segment === "object") {
|
||||
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(candidate.content)) {
|
||||
return candidate.content.some((entry) => reasoningSegmentHasText(entry))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const hasReasoningContent = () => {
|
||||
if (props.part?.type !== "reasoning") {
|
||||
return false
|
||||
}
|
||||
if (reasoningSegmentHasText((props.part as any).text)) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray((props.part as any).content)) {
|
||||
return (props.part as any).content.some((entry: unknown) => reasoningSegmentHasText(entry))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const createTextPartForMarkdown = (): TextPart => {
|
||||
const part = props.part
|
||||
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "text",
|
||||
text: part.text,
|
||||
synthetic: part.type === "text" ? part.synthetic : false,
|
||||
version: (part as { version?: number }).version
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: part.id,
|
||||
type: "text",
|
||||
text: "",
|
||||
synthetic: false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReasoningClick(e: Event) {
|
||||
e.preventDefault()
|
||||
toggleItemExpanded(reasoningId())
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={partType() === "text"}>
|
||||
<Show when={!(props.part.type === "text" && props.part.synthetic && isAssistantMessage()) && partHasRenderableText(props.part)}>
|
||||
<div class={textContainerClass()}>
|
||||
<Show
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span>{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
instanceId={props.instanceId}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
<Match when={partType() === "tool"}>
|
||||
<ToolCall
|
||||
toolCall={props.part as ToolCallPart}
|
||||
toolCallId={props.part?.id}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
|
||||
|
||||
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
32
packages/ui/src/components/message-preview.tsx
Normal file
32
packages/ui/src/components/message-preview.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Component } from "solid-js"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
interface MessagePreviewProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
store: () => InstanceMessageStore
|
||||
}
|
||||
|
||||
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||
const lastAssistantIndex = () => 0
|
||||
|
||||
return (
|
||||
<div class="message-preview message-stream">
|
||||
<MessageBlock
|
||||
messageId={props.messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndex={0}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => false}
|
||||
thinkingDefaultExpanded={() => false}
|
||||
showUsageMetrics={() => false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessagePreview
|
||||
889
packages/ui/src/components/message-section.tsx
Normal file
889
packages/ui/src/components/message-section.tsx
Normal file
@@ -0,0 +1,889 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
||||
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getSessionInfo } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { compactSession } from "../stores/session-actions"
|
||||
import { clearCompactionSuggestion, getCompactionSuggestion } from "../stores/session-compaction"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
export interface MessageSectionProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
registerScrollToBottom?: (fn: () => void) => void
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
onQuoteSelection?: (text: string, mode: "quote" | "code") => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export default function MessageSection(props: MessageSectionProps) {
|
||||
const { preferences } = useConfig()
|
||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||
|
||||
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
||||
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
||||
const sessionInfo = createMemo(() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: 0,
|
||||
contextAvailableTokens: null,
|
||||
},
|
||||
)
|
||||
const isCompacting = createMemo(() => getSessionStatus(props.instanceId, props.sessionId) === "compacting")
|
||||
const compactionSuggestion = createMemo(() =>
|
||||
getCompactionSuggestion(props.instanceId, props.sessionId),
|
||||
)
|
||||
|
||||
const tokenStats = createMemo(() => {
|
||||
const usage = usageSnapshot()
|
||||
const info = sessionInfo()
|
||||
return {
|
||||
used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0,
|
||||
avail: info.contextAvailableTokens,
|
||||
}
|
||||
})
|
||||
|
||||
const preferenceSignature = createMemo(() => {
|
||||
const pref = preferences()
|
||||
const showThinking = pref.showThinkingBlocks ? 1 : 0
|
||||
const thinkingExpansion = pref.thinkingBlocksExpansion ?? "expanded"
|
||||
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
|
||||
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
||||
})
|
||||
|
||||
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
||||
if (typeof document === "undefined") return
|
||||
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
||||
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||
}
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
for (let index = ids.length - 1; index >= 0; index--) {
|
||||
const record = resolvedStore.getMessage(ids[index])
|
||||
if (record?.role === "assistant") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
})
|
||||
|
||||
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||
const hasTimelineSegments = () => timelineSegments().length > 0
|
||||
|
||||
const seenTimelineMessageIds = new Set<string>()
|
||||
const seenTimelineSegmentKeys = new Set<string>()
|
||||
|
||||
function makeTimelineKey(segment: TimelineSegment) {
|
||||
return `${segment.messageId}:${segment.id}:${segment.type}`
|
||||
}
|
||||
|
||||
function seedTimeline() {
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
const ids = untrack(messageIds)
|
||||
const resolvedStore = untrack(store)
|
||||
const segments: TimelineSegment[] = []
|
||||
ids.forEach((messageId) => {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return
|
||||
seenTimelineMessageIds.add(messageId)
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
segments.push(segment)
|
||||
})
|
||||
})
|
||||
setTimelineSegments(segments)
|
||||
}
|
||||
|
||||
function appendTimelineForMessage(messageId: string) {
|
||||
const record = untrack(() => store().getMessage(messageId))
|
||||
if (!record) return
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
if (built.length === 0) return
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
newSegments.push(segment)
|
||||
})
|
||||
if (newSegments.length > 0) {
|
||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||
}
|
||||
}
|
||||
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
||||
|
||||
const changeToken = createMemo(() => String(sessionRevision()))
|
||||
const isActive = createMemo(() => props.isActive !== false)
|
||||
|
||||
|
||||
const scrollCache = useScrollCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: SCROLL_SCOPE,
|
||||
})
|
||||
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
|
||||
const bottomSentinel = () => bottomSentinelSignal()
|
||||
const setBottomSentinel = (element: HTMLDivElement | null) => {
|
||||
setBottomSentinelSignal(element)
|
||||
resolvePendingActiveScroll()
|
||||
}
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let shellRef: HTMLDivElement | undefined
|
||||
let pendingScrollFrame: number | null = null
|
||||
|
||||
let pendingAnchorScroll: number | null = null
|
||||
|
||||
let pendingScrollPersist: number | null = null
|
||||
let userScrollIntentUntil = 0
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
let hasRestoredScroll = false
|
||||
let suppressAutoScrollOnce = false
|
||||
let pendingActiveScroll = false
|
||||
let scrollToBottomFrame: number | null = null
|
||||
let scrollToBottomDelayedFrame: number | null = null
|
||||
let pendingInitialScroll = true
|
||||
|
||||
function markUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
if (!element) return
|
||||
const handlePointerIntent = () => markUserScrollIntent()
|
||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||
if (SCROLL_INTENT_KEYS.has(event.key)) {
|
||||
markUserScrollIntent()
|
||||
}
|
||||
}
|
||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("pointerdown", handlePointerIntent)
|
||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("keydown", handleKeyIntent)
|
||||
detachScrollIntentListeners = () => {
|
||||
element.removeEventListener("wheel", handlePointerIntent)
|
||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||
element.removeEventListener("touchstart", handlePointerIntent)
|
||||
element.removeEventListener("keydown", handleKeyIntent)
|
||||
}
|
||||
}
|
||||
|
||||
function setContainerRef(element: HTMLDivElement | null) {
|
||||
containerRef = element || undefined
|
||||
setScrollElement(containerRef)
|
||||
attachScrollIntentListeners(containerRef)
|
||||
if (!containerRef) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
resolvePendingActiveScroll()
|
||||
}
|
||||
|
||||
function setShellElement(element: HTMLDivElement | null) {
|
||||
shellRef = element || undefined
|
||||
if (!shellRef) {
|
||||
clearQuoteSelection()
|
||||
}
|
||||
}
|
||||
|
||||
function updateScrollIndicatorsFromVisibility() {
|
||||
|
||||
const hasItems = messageIds().length > 0
|
||||
const bottomVisible = bottomSentinelVisible()
|
||||
const topVisible = topSentinelVisible()
|
||||
setShowScrollBottomButton(hasItems && !bottomVisible)
|
||||
setShowScrollTopButton(hasItems && !topVisible)
|
||||
}
|
||||
|
||||
function scheduleScrollPersist() {
|
||||
if (pendingScrollPersist !== null) return
|
||||
pendingScrollPersist = requestAnimationFrame(() => {
|
||||
pendingScrollPersist = null
|
||||
if (!containerRef) return
|
||||
// scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
|
||||
if (!containerRef) return
|
||||
const sentinel = bottomSentinel()
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
|
||||
if (suppressAutoAnchor) {
|
||||
suppressAutoScrollOnce = true
|
||||
}
|
||||
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
||||
setAutoScroll(true)
|
||||
scheduleScrollPersist()
|
||||
}
|
||||
|
||||
function clearScrollToBottomFrames() {
|
||||
if (scrollToBottomFrame !== null) {
|
||||
cancelAnimationFrame(scrollToBottomFrame)
|
||||
scrollToBottomFrame = null
|
||||
}
|
||||
if (scrollToBottomDelayedFrame !== null) {
|
||||
cancelAnimationFrame(scrollToBottomDelayedFrame)
|
||||
scrollToBottomDelayedFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
function requestScrollToBottom(immediate = true) {
|
||||
if (!isActive()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
if (!containerRef || !bottomSentinel()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
pendingActiveScroll = false
|
||||
clearScrollToBottomFrames()
|
||||
scrollToBottomFrame = requestAnimationFrame(() => {
|
||||
scrollToBottomFrame = null
|
||||
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
|
||||
scrollToBottomDelayedFrame = null
|
||||
scrollToBottom(immediate)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePendingActiveScroll() {
|
||||
if (!pendingActiveScroll) return
|
||||
if (!isActive()) return
|
||||
requestScrollToBottom(true)
|
||||
}
|
||||
|
||||
function scrollToTop(immediate = false) {
|
||||
if (!containerRef) return
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
setAutoScroll(false)
|
||||
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
|
||||
scheduleScrollPersist()
|
||||
}
|
||||
|
||||
|
||||
function scheduleAnchorScroll(immediate = false) {
|
||||
if (!autoScroll()) return
|
||||
if (!isActive()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
const sentinel = bottomSentinel()
|
||||
if (!sentinel) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||
pendingAnchorScroll = null
|
||||
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
|
||||
})
|
||||
}
|
||||
|
||||
function clearQuoteSelection() {
|
||||
setQuoteSelection(null)
|
||||
}
|
||||
|
||||
function isSelectionWithinStream(range: Range | null) {
|
||||
if (!range || !containerRef) return false
|
||||
const node = range.commonAncestorContainer
|
||||
if (!node) return false
|
||||
return containerRef.contains(node)
|
||||
}
|
||||
|
||||
function updateQuoteSelectionFromSelection() {
|
||||
if (!props.onQuoteSelection || typeof window === "undefined") {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!isSelectionWithinStream(range)) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const shell = shellRef
|
||||
if (!shell) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const rawText = selection.toString().trim()
|
||||
if (!rawText) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const limited =
|
||||
rawText.length > QUOTE_SELECTION_MAX_LENGTH ? rawText.slice(0, QUOTE_SELECTION_MAX_LENGTH).trimEnd() : rawText
|
||||
if (!limited) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const rects = range.getClientRects()
|
||||
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
|
||||
const shellRect = shell.getBoundingClientRect()
|
||||
const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8)
|
||||
const maxLeft = Math.max(shell.clientWidth - 180, 8)
|
||||
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
|
||||
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
|
||||
}
|
||||
|
||||
function handleStreamMouseUp() {
|
||||
updateQuoteSelectionFromSelection()
|
||||
}
|
||||
|
||||
function handleQuoteSelectionRequest(mode: "quote" | "code") {
|
||||
const info = quoteSelection()
|
||||
if (!info || !props.onQuoteSelection) return
|
||||
props.onQuoteSelection(info.text, mode)
|
||||
clearQuoteSelection()
|
||||
if (typeof window !== "undefined") {
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
if (props.loading) {
|
||||
return
|
||||
}
|
||||
scheduleAnchorScroll()
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
|
||||
if (!containerRef) return
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
pendingScrollFrame = requestAnimationFrame(() => {
|
||||
pendingScrollFrame = null
|
||||
if (!containerRef) return
|
||||
const atBottom = bottomSentinelVisible()
|
||||
|
||||
if (isUserScroll) {
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
|
||||
clearQuoteSelection()
|
||||
scheduleScrollPersist()
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (props.registerScrollToBottom) {
|
||||
props.registerScrollToBottom(() => requestScrollToBottom(true))
|
||||
}
|
||||
})
|
||||
|
||||
let lastActiveState = false
|
||||
createEffect(() => {
|
||||
const active = isActive()
|
||||
if (active) {
|
||||
resolvePendingActiveScroll()
|
||||
if (!lastActiveState && autoScroll()) {
|
||||
requestScrollToBottom(true)
|
||||
}
|
||||
} else if (autoScroll()) {
|
||||
pendingActiveScroll = true
|
||||
}
|
||||
lastActiveState = active
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const loading = Boolean(props.loading)
|
||||
if (loading) {
|
||||
pendingInitialScroll = true
|
||||
return
|
||||
}
|
||||
if (!pendingInitialScroll) {
|
||||
return
|
||||
}
|
||||
const container = scrollElement()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel || messageIds().length === 0) {
|
||||
return
|
||||
}
|
||||
pendingInitialScroll = false
|
||||
requestScrollToBottom(true)
|
||||
})
|
||||
|
||||
let previousTimelineIds: string[] = []
|
||||
let previousLastTimelineMessageId: string | null = null
|
||||
let previousLastTimelinePartCount = 0
|
||||
|
||||
createEffect(() => {
|
||||
const loading = Boolean(props.loading)
|
||||
const ids = messageIds()
|
||||
|
||||
if (loading) {
|
||||
previousTimelineIds = []
|
||||
previousLastTimelineMessageId = null
|
||||
previousLastTimelinePartCount = 0
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length < previousTimelineIds.length) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length === previousTimelineIds.length) {
|
||||
let changedIndex = -1
|
||||
let changeCount = 0
|
||||
for (let index = 0; index < ids.length; index++) {
|
||||
if (ids[index] !== previousTimelineIds[index]) {
|
||||
changedIndex = index
|
||||
changeCount += 1
|
||||
if (changeCount > 1) break
|
||||
}
|
||||
}
|
||||
if (changeCount === 1 && changedIndex >= 0) {
|
||||
const oldId = previousTimelineIds[changedIndex]
|
||||
const newId = ids[changedIndex]
|
||||
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||
seenTimelineMessageIds.delete(oldId)
|
||||
seenTimelineMessageIds.add(newId)
|
||||
setTimelineSegments((prev) => {
|
||||
const next = prev.map((segment) => {
|
||||
if (segment.messageId !== oldId) return segment
|
||||
const updatedId = segment.id.replace(oldId, newId)
|
||||
return { ...segment, messageId: newId, id: updatedId }
|
||||
})
|
||||
seenTimelineSegmentKeys.clear()
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newIds: string[] = []
|
||||
ids.forEach((id) => {
|
||||
if (!seenTimelineMessageIds.has(id)) {
|
||||
newIds.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
if (newIds.length > 0) {
|
||||
newIds.forEach((id) => {
|
||||
seenTimelineMessageIds.add(id)
|
||||
appendTimelineForMessage(id)
|
||||
})
|
||||
}
|
||||
|
||||
previousTimelineIds = ids.slice()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.loading) return
|
||||
const ids = messageIds()
|
||||
if (ids.length === 0) return
|
||||
const lastId = ids[ids.length - 1]
|
||||
if (!lastId) return
|
||||
const record = store().getMessage(lastId)
|
||||
if (!record) return
|
||||
const partCount = record.partIds.length
|
||||
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
|
||||
return
|
||||
}
|
||||
previousLastTimelineMessageId = lastId
|
||||
previousLastTimelinePartCount = partCount
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
newSegments.push(segment)
|
||||
})
|
||||
if (newSegments.length > 0) {
|
||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.onQuoteSelection) {
|
||||
clearQuoteSelection()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const handleSelectionChange = () => updateQuoteSelectionFromSelection()
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!shellRef) return
|
||||
if (!shellRef.contains(event.target as Node)) {
|
||||
clearQuoteSelection()
|
||||
}
|
||||
}
|
||||
document.addEventListener("selectionchange", handleSelectionChange)
|
||||
document.addEventListener("pointerdown", handlePointerDown)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("selectionchange", handleSelectionChange)
|
||||
document.removeEventListener("pointerdown", handlePointerDown)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.loading) {
|
||||
clearQuoteSelection()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const target = containerRef
|
||||
const loading = props.loading
|
||||
if (!target || loading || hasRestoredScroll) return
|
||||
|
||||
|
||||
// scrollCache.restore(target, {
|
||||
// onApplied: (snapshot) => {
|
||||
// if (snapshot) {
|
||||
// setAutoScroll(snapshot.atBottom)
|
||||
// } else {
|
||||
// setAutoScroll(bottomSentinelVisible())
|
||||
// }
|
||||
// updateScrollIndicatorsFromVisibility()
|
||||
// },
|
||||
// })
|
||||
|
||||
hasRestoredScroll = true
|
||||
})
|
||||
|
||||
let previousToken: string | undefined
|
||||
createEffect(() => {
|
||||
const token = changeToken()
|
||||
const loading = props.loading
|
||||
if (loading || !token || token === previousToken) {
|
||||
return
|
||||
}
|
||||
previousToken = token
|
||||
if (suppressAutoScrollOnce) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
if (autoScroll()) {
|
||||
scheduleAnchorScroll(true)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
preferenceSignature()
|
||||
if (props.loading || !autoScroll()) {
|
||||
return
|
||||
}
|
||||
if (suppressAutoScrollOnce) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
scheduleAnchorScroll(true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (messageIds().length === 0) {
|
||||
setShowScrollTopButton(false)
|
||||
setShowScrollBottomButton(false)
|
||||
setAutoScroll(true)
|
||||
return
|
||||
}
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
})
|
||||
createEffect(() => {
|
||||
const container = scrollElement()
|
||||
const topTarget = topSentinel()
|
||||
const bottomTarget = bottomSentinel()
|
||||
if (!container || !topTarget || !bottomTarget) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let visibilityChanged = false
|
||||
for (const entry of entries) {
|
||||
if (entry.target === topTarget) {
|
||||
setTopSentinelVisible(entry.isIntersecting)
|
||||
visibilityChanged = true
|
||||
} else if (entry.target === bottomTarget) {
|
||||
setBottomSentinelVisible(entry.isIntersecting)
|
||||
visibilityChanged = true
|
||||
}
|
||||
}
|
||||
if (visibilityChanged) {
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
}
|
||||
},
|
||||
{ root: container, threshold: 0, rootMargin: `${SCROLL_SENTINEL_MARGIN_PX}px 0px ${SCROLL_SENTINEL_MARGIN_PX}px 0px` },
|
||||
)
|
||||
observer.observe(topTarget)
|
||||
observer.observe(bottomTarget)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollElement()
|
||||
const ids = messageIds()
|
||||
if (!container || ids.length === 0) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) {
|
||||
best = entry
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const anchorId = (best.target as HTMLElement).id
|
||||
const messageId = anchorId.startsWith("message-anchor-") ? anchorId.slice("message-anchor-".length) : anchorId
|
||||
setActiveMessageId((current) => (current === messageId ? current : messageId))
|
||||
}
|
||||
},
|
||||
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
|
||||
)
|
||||
|
||||
ids.forEach((messageId) => {
|
||||
const anchor = document.getElementById(getMessageAnchorId(messageId))
|
||||
if (anchor) {
|
||||
observer.observe(anchor)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
if (pendingScrollPersist !== null) {
|
||||
cancelAnimationFrame(pendingScrollPersist)
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
}
|
||||
clearScrollToBottomFrames()
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
}
|
||||
if (containerRef) {
|
||||
// scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
||||
}
|
||||
clearQuoteSelection()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
|
||||
<div class="message-stream-shell" ref={setShellElement}>
|
||||
<Show when={isCompacting()}>
|
||||
<div class="compaction-banner" role="status" aria-live="polite">
|
||||
<span class="spinner compaction-banner-spinner" aria-hidden="true" />
|
||||
<span>Compacting context…</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isCompacting() && compactionSuggestion()}>
|
||||
<div class="compaction-suggestion" role="status" aria-live="polite">
|
||||
<div class="compaction-suggestion-text">
|
||||
<span class="compaction-suggestion-label">Compact suggested</span>
|
||||
<span class="compaction-suggestion-message">{compactionSuggestion()!.reason}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="compaction-suggestion-action"
|
||||
onClick={() => {
|
||||
clearCompactionSuggestion(props.instanceId, props.sessionId)
|
||||
void compactSession(props.instanceId, props.sessionId)
|
||||
}}
|
||||
>
|
||||
Compact now
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
</div>
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or open the Command Palette:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span>Command Palette</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
</li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>
|
||||
Attach files with <code>@</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<MessageBlockList
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
messageIds={messageIds}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => preferences().showThinkingBlocks}
|
||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||
showUsageMetrics={showUsagePreference}
|
||||
scrollContainer={scrollElement}
|
||||
loading={props.loading}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={handleContentRendered}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
suspendMeasurements={() => !isActive()}
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
|
||||
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={quoteSelection()}>
|
||||
{(selection) => (
|
||||
<div
|
||||
class="message-quote-popover"
|
||||
style={{ top: `${selection().top}px`, left: `${selection().left}px` }}
|
||||
>
|
||||
<div class="message-quote-button-group">
|
||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
|
||||
Add as quote
|
||||
</button>
|
||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
||||
Add as code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={hasTimelineSegments()}>
|
||||
<div class="message-timeline-sidebar">
|
||||
<MessageTimeline
|
||||
segments={timelineSegments()}
|
||||
onSegmentClick={handleTimelineSegmentClick}
|
||||
activeMessageId={activeMessageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
showToolSegments={showTimelineToolsPreference()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
396
packages/ui/src/components/message-timeline.tsx
Normal file
396
packages/ui/src/components/message-timeline.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
|
||||
import MessagePreview from "./message-preview"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import type { ClientPart } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getToolIcon } from "./tool-call/utils"
|
||||
import { User as UserIcon, Bot as BotIcon } from "lucide-solid"
|
||||
|
||||
export type TimelineSegmentType = "user" | "assistant" | "tool"
|
||||
|
||||
export interface TimelineSegment {
|
||||
id: string
|
||||
messageId: string
|
||||
type: TimelineSegmentType
|
||||
label: string
|
||||
tooltip: string
|
||||
shortLabel?: string
|
||||
}
|
||||
|
||||
interface MessageTimelineProps {
|
||||
segments: TimelineSegment[]
|
||||
onSegmentClick?: (segment: TimelineSegment) => void
|
||||
activeMessageId?: string | null
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
showToolSegments?: boolean
|
||||
}
|
||||
|
||||
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
||||
user: "You",
|
||||
assistant: "Asst",
|
||||
tool: "Tool",
|
||||
}
|
||||
|
||||
const TOOL_FALLBACK_LABEL = "Tool Call"
|
||||
const MAX_TOOLTIP_LENGTH = 220
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
interface PendingSegment {
|
||||
type: TimelineSegmentType
|
||||
texts: string[]
|
||||
reasoningTexts: string[]
|
||||
toolTitles: string[]
|
||||
toolTypeLabels: string[]
|
||||
toolIcons: string[]
|
||||
hasPrimaryText: boolean
|
||||
}
|
||||
|
||||
function truncateText(value: string): string {
|
||||
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
||||
return value
|
||||
}
|
||||
return `${value.slice(0, MAX_TOOLTIP_LENGTH - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
function collectReasoningText(part: ClientPart): string {
|
||||
const stringifySegment = (segment: unknown): string => {
|
||||
if (typeof segment === "string") {
|
||||
return segment
|
||||
}
|
||||
if (segment && typeof segment === "object") {
|
||||
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
const parts: string[] = []
|
||||
if (typeof obj.text === "string") {
|
||||
parts.push(obj.text)
|
||||
}
|
||||
if (typeof obj.value === "string") {
|
||||
parts.push(obj.value)
|
||||
}
|
||||
if (Array.isArray(obj.content)) {
|
||||
parts.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
|
||||
}
|
||||
return parts.filter(Boolean).join("\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if (typeof (part as any)?.text === "string") {
|
||||
return (part as any).text
|
||||
}
|
||||
if (Array.isArray((part as any)?.content)) {
|
||||
return (part as any).content.map((entry: unknown) => stringifySegment(entry)).join("\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function collectTextFromPart(part: ClientPart): string {
|
||||
if (!part) return ""
|
||||
if (typeof (part as any).text === "string") {
|
||||
return (part as any).text as string
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
return collectReasoningText(part)
|
||||
}
|
||||
if (Array.isArray((part as any)?.content)) {
|
||||
return ((part as any).content as unknown[])
|
||||
.map((entry) => (typeof entry === "string" ? entry : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
if (part.type === "file") {
|
||||
const filename = (part as any)?.filename
|
||||
return typeof filename === "string" && filename.length > 0 ? `[File] ${filename}` : "Attachment"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function getToolTitle(part: ToolCallPart): string {
|
||||
const metadata = (((part as unknown as { state?: { metadata?: unknown } })?.state?.metadata) || {}) as { title?: unknown }
|
||||
const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined
|
||||
if (title) return title
|
||||
if (typeof part.tool === "string" && part.tool.length > 0) {
|
||||
return part.tool
|
||||
}
|
||||
return TOOL_FALLBACK_LABEL
|
||||
}
|
||||
|
||||
function getToolTypeLabel(part: ToolCallPart): string {
|
||||
if (typeof part.tool === "string" && part.tool.trim().length > 0) {
|
||||
return part.tool.trim().slice(0, 4)
|
||||
}
|
||||
return TOOL_FALLBACK_LABEL.slice(0, 4)
|
||||
}
|
||||
|
||||
function formatTextsTooltip(texts: string[], fallback: string): string {
|
||||
const combined = texts
|
||||
.map((text) => text.trim())
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n")
|
||||
if (combined.length > 0) {
|
||||
return truncateText(combined)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function formatToolTooltip(titles: string[]): string {
|
||||
if (titles.length === 0) {
|
||||
return TOOL_FALLBACK_LABEL
|
||||
}
|
||||
return truncateText(`${TOOL_FALLBACK_LABEL}: ${titles.join(", ")}`)
|
||||
}
|
||||
|
||||
export function buildTimelineSegments(instanceId: string, record: MessageRecord): TimelineSegment[] {
|
||||
if (!record) return []
|
||||
const { orderedParts } = buildRecordDisplayData(instanceId, record)
|
||||
if (!orderedParts || orderedParts.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result: TimelineSegment[] = []
|
||||
let segmentIndex = 0
|
||||
let pending: PendingSegment | null = null
|
||||
const flushPending = () => {
|
||||
if (!pending) return
|
||||
if (pending.type === "assistant" && !pending.hasPrimaryText) {
|
||||
pending = null
|
||||
return
|
||||
}
|
||||
const isToolSegment = pending.type === "tool"
|
||||
const label = isToolSegment
|
||||
? pending.toolTypeLabels[0] || TOOL_FALLBACK_LABEL.slice(0, 4)
|
||||
: SEGMENT_LABELS[pending.type]
|
||||
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
||||
const tooltip = isToolSegment
|
||||
? formatToolTooltip(pending.toolTitles)
|
||||
: formatTextsTooltip(
|
||||
[...pending.texts, ...pending.reasoningTexts],
|
||||
pending.type === "user" ? "User message" : "Assistant response",
|
||||
)
|
||||
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
type: pending.type,
|
||||
label,
|
||||
tooltip,
|
||||
shortLabel,
|
||||
})
|
||||
segmentIndex += 1
|
||||
pending = null
|
||||
}
|
||||
|
||||
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
||||
if (!pending || pending.type !== type) {
|
||||
flushPending()
|
||||
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], hasPrimaryText: type !== "assistant" }
|
||||
}
|
||||
return pending!
|
||||
}
|
||||
|
||||
|
||||
const defaultContentType: TimelineSegmentType = record.role === "user" ? "user" : "assistant"
|
||||
|
||||
for (const part of orderedParts) {
|
||||
if (!part || typeof part !== "object") continue
|
||||
|
||||
if (part.type === "tool") {
|
||||
const target = ensureSegment("tool")
|
||||
const toolPart = part as ToolCallPart
|
||||
target.toolTitles.push(getToolTitle(toolPart))
|
||||
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
|
||||
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
const text = collectReasoningText(part)
|
||||
if (text.trim().length === 0) continue
|
||||
const target = ensureSegment(defaultContentType)
|
||||
if (target) {
|
||||
target.reasoningTexts.push(text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "step-start" || part.type === "step-finish") {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = collectTextFromPart(part)
|
||||
if (text.trim().length === 0) continue
|
||||
const target = ensureSegment(defaultContentType)
|
||||
if (target) {
|
||||
target.texts.push(text)
|
||||
target.hasPrimaryText = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
flushPending()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
const buttonRefs = new Map<string, HTMLButtonElement>()
|
||||
const store = () => messageStoreBus.getOrCreate(props.instanceId)
|
||||
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
|
||||
const [tooltipCoords, setTooltipCoords] = createSignal<{ top: number; left: number }>({ top: 0, left: 0 })
|
||||
const [hoverAnchorRect, setHoverAnchorRect] = createSignal<{ top: number; left: number; width: number; height: number } | null>(null)
|
||||
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
|
||||
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
|
||||
let hoverTimer: number | null = null
|
||||
const showTools = () => props.showToolSegments ?? true
|
||||
|
||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||
if (element) {
|
||||
buttonRefs.set(segmentId, element)
|
||||
} else {
|
||||
buttonRefs.delete(segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
const clearHoverTimer = () => {
|
||||
if (hoverTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(hoverTimer)
|
||||
hoverTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
const target = event.currentTarget as HTMLButtonElement
|
||||
hoverTimer = window.setTimeout(() => {
|
||||
const rect = target.getBoundingClientRect()
|
||||
setHoverAnchorRect({ top: rect.top, left: rect.left, width: rect.width, height: rect.height })
|
||||
setHoveredSegment(segment)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearHoverTimer()
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const anchor = hoverAnchorRect()
|
||||
const segment = hoveredSegment()
|
||||
if (!anchor || !segment) return
|
||||
const { width, height } = tooltipSize()
|
||||
const verticalGap = 16
|
||||
const horizontalGap = 16
|
||||
const preferredTop = anchor.top + anchor.height / 2 - height / 2
|
||||
const maxTop = window.innerHeight - height - verticalGap
|
||||
const clampedTop = Math.min(maxTop, Math.max(verticalGap, preferredTop))
|
||||
const preferredLeft = anchor.left - width - horizontalGap
|
||||
const clampedLeft = Math.max(horizontalGap, preferredLeft)
|
||||
setTooltipCoords({ top: clampedTop, left: clampedLeft })
|
||||
})
|
||||
|
||||
onCleanup(() => clearHoverTimer())
|
||||
|
||||
createEffect(() => {
|
||||
const activeId = props.activeMessageId
|
||||
|
||||
if (!activeId) return
|
||||
const targetSegment = props.segments.find((segment) => segment.messageId === activeId)
|
||||
if (!targetSegment) return
|
||||
const element = buttonRefs.get(targetSegment.id)
|
||||
if (!element) return
|
||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}, 120) : null
|
||||
onCleanup(() => {
|
||||
if (timer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const element = tooltipElement()
|
||||
if (!element || typeof window === "undefined") return
|
||||
const updateSize = () => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
setTooltipSize({ width: rect.width, height: rect.height })
|
||||
}
|
||||
updateSize()
|
||||
if (typeof ResizeObserver === "undefined") return
|
||||
const observer = new ResizeObserver(() => updateSize())
|
||||
observer.observe(element)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
const previewData = createMemo(() => {
|
||||
|
||||
const segment = hoveredSegment()
|
||||
if (!segment) return null
|
||||
const record = store().getMessage(segment.messageId)
|
||||
if (!record) return null
|
||||
return { messageId: segment.messageId }
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-timeline" role="navigation" aria-label="Message timeline">
|
||||
<For each={props.segments}>
|
||||
{(segment) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
const isActive = () => props.activeMessageId === segment.messageId
|
||||
const isHidden = () => segment.type === "tool" && !(showTools() || isActive())
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
return segment.shortLabel ?? getToolIcon("tool")
|
||||
}
|
||||
if (segment.type === "user") {
|
||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={previewData()}>
|
||||
{(data) => {
|
||||
onCleanup(() => setTooltipElement(null))
|
||||
return (
|
||||
<div
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageTimeline
|
||||
|
||||
248
packages/ui/src/components/model-selector.tsx
Normal file
248
packages/ui/src/components/model-selector.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Combobox } from "@kobalte/core/combobox"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown, Database } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getUserScopedKey } from "../lib/user-storage"
|
||||
const log = getLogger("session")
|
||||
|
||||
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
currentModel: { providerId: string; modelId: string }
|
||||
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
|
||||
}
|
||||
|
||||
interface FlatModel extends Model {
|
||||
providerName: string
|
||||
key: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
import { useQwenOAuth } from "../lib/integrations/qwen-oauth"
|
||||
|
||||
export default function ModelSelector(props: ModelSelectorProps) {
|
||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const qwenAuth = useQwenOAuth()
|
||||
const [offlineModels, setOfflineModels] = createSignal<Set<string>>(new Set())
|
||||
|
||||
// Context-Engine status: "stopped" | "ready" | "indexing" | "error"
|
||||
type ContextEngineStatus = "stopped" | "ready" | "indexing" | "error"
|
||||
const [contextEngineStatus, setContextEngineStatus] = createSignal<ContextEngineStatus>("stopped")
|
||||
|
||||
let triggerRef!: HTMLButtonElement
|
||||
let searchInputRef!: HTMLInputElement
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceProviders().length === 0) {
|
||||
fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error))
|
||||
}
|
||||
})
|
||||
|
||||
const readOfflineModels = () => {
|
||||
if (typeof window === "undefined") return new Set<string>()
|
||||
try {
|
||||
const raw = window.localStorage.getItem(getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY))
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : [])
|
||||
} catch {
|
||||
return new Set<string>()
|
||||
}
|
||||
}
|
||||
|
||||
const refreshOfflineModels = () => {
|
||||
setOfflineModels(readOfflineModels())
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refreshOfflineModels()
|
||||
if (typeof window === "undefined") return
|
||||
const handleCustom = () => refreshOfflineModels()
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)) {
|
||||
refreshOfflineModels()
|
||||
}
|
||||
}
|
||||
window.addEventListener("opencode-zen-offline-models", handleCustom as EventListener)
|
||||
window.addEventListener("storage", handleStorage)
|
||||
|
||||
// DISABLED: Context-Engine polling was causing performance issues
|
||||
// const pollContextEngine = async () => {
|
||||
// try {
|
||||
// const response = await fetch("/api/context-engine/status")
|
||||
// if (response.ok) {
|
||||
// const data = await response.json() as { status: ContextEngineStatus }
|
||||
// setContextEngineStatus(data.status ?? "stopped")
|
||||
// } else {
|
||||
// setContextEngineStatus("stopped")
|
||||
// }
|
||||
// } catch {
|
||||
// setContextEngineStatus("stopped")
|
||||
// }
|
||||
// }
|
||||
// pollContextEngine()
|
||||
// const pollInterval = setInterval(pollContextEngine, 5000)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("opencode-zen-offline-models", handleCustom as EventListener)
|
||||
window.removeEventListener("storage", handleStorage)
|
||||
// clearInterval(pollInterval)
|
||||
})
|
||||
})
|
||||
|
||||
const isOfflineModel = (model: FlatModel) =>
|
||||
model.providerId === "opencode-zen" && offlineModels().has(model.id)
|
||||
|
||||
const allModels = createMemo<FlatModel[]>(() =>
|
||||
instanceProviders().flatMap((p) =>
|
||||
p.models.map((m) => ({
|
||||
...m,
|
||||
providerName: p.name,
|
||||
key: `${m.providerId}/${m.id}`,
|
||||
searchText: `${m.name} ${p.name} ${m.providerId} ${m.id} ${m.providerId}/${m.id}`,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
const currentModelValue = createMemo(() =>
|
||||
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
|
||||
)
|
||||
|
||||
const handleChange = async (value: FlatModel | null) => {
|
||||
if (!value) return
|
||||
|
||||
// Auto-trigger Qwen OAuth if needed
|
||||
if (value.providerId === 'qwen-oauth' && !qwenAuth.isAuthenticated()) {
|
||||
const confirmed = window.confirm("Qwen Code requires authentication. Sign in now?")
|
||||
if (confirmed) {
|
||||
try {
|
||||
await qwenAuth.signIn()
|
||||
} catch (error) {
|
||||
log.error("Qwen authentication failed", error)
|
||||
// Continue to set model even if auth failed, to allow user to try again later
|
||||
// or user might have authenticatd in another tab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
||||
}
|
||||
|
||||
const customFilter = (option: FlatModel, inputValue: string) => {
|
||||
return option.searchText.toLowerCase().includes(inputValue.toLowerCase())
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (isOpen()) {
|
||||
setTimeout(() => {
|
||||
searchInputRef?.focus()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="sidebar-selector">
|
||||
<Combobox<FlatModel>
|
||||
value={currentModelValue()}
|
||||
onChange={handleChange}
|
||||
onOpenChange={setIsOpen}
|
||||
options={allModels()}
|
||||
optionValue="key"
|
||||
optionTextValue="searchText"
|
||||
optionLabel="name"
|
||||
placeholder="Search models..."
|
||||
defaultFilter={customFilter}
|
||||
allowsEmptyCollection
|
||||
itemComponent={(itemProps) => (
|
||||
<Combobox.Item
|
||||
item={itemProps.item}
|
||||
class="selector-option"
|
||||
>
|
||||
<div class="selector-option-content">
|
||||
<Combobox.ItemLabel class="selector-option-label flex items-center gap-2">
|
||||
<span class="truncate">{itemProps.item.rawValue.name}</span>
|
||||
{isOfflineModel(itemProps.item.rawValue) && (
|
||||
<span class="selector-badge selector-badge-warning">Offline</span>
|
||||
)}
|
||||
</Combobox.ItemLabel>
|
||||
<Combobox.ItemDescription class="selector-option-description">
|
||||
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/
|
||||
{itemProps.item.rawValue.id}
|
||||
</Combobox.ItemDescription>
|
||||
</div>
|
||||
<Combobox.ItemIndicator class="selector-option-indicator">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</Combobox.ItemIndicator>
|
||||
</Combobox.Item>
|
||||
)}
|
||||
>
|
||||
<Combobox.Control class="relative w-full" data-model-selector-control>
|
||||
<Combobox.Input class="sr-only" data-model-selector />
|
||||
<Combobox.Trigger
|
||||
ref={triggerRef}
|
||||
class="selector-trigger"
|
||||
>
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left flex items-center gap-2">
|
||||
<span class="truncate">Model: {currentModelValue()?.name ?? "None"}</span>
|
||||
{currentModelValue() && isOfflineModel(currentModelValue() as FlatModel) && (
|
||||
<span class="selector-badge selector-badge-warning">Offline</span>
|
||||
)}
|
||||
{/* Context-Engine RAG Status Indicator */}
|
||||
<Show when={contextEngineStatus() !== "stopped"}>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-[10px]"
|
||||
title={
|
||||
contextEngineStatus() === "ready"
|
||||
? "Context Engine is active - RAG enabled"
|
||||
: contextEngineStatus() === "indexing"
|
||||
? "Context Engine is indexing files..."
|
||||
: "Context Engine error"
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={`w-2 h-2 rounded-full ${contextEngineStatus() === "ready"
|
||||
? "bg-emerald-500"
|
||||
: contextEngineStatus() === "indexing"
|
||||
? "bg-blue-500 animate-pulse"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<Database class="w-3 h-3 text-zinc-400" />
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
{currentModelValue() && (
|
||||
<span class="selector-trigger-secondary">
|
||||
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Combobox.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Combobox.Icon>
|
||||
</Combobox.Trigger>
|
||||
</Combobox.Control>
|
||||
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="selector-popover">
|
||||
<div class="selector-search-container">
|
||||
<Combobox.Input
|
||||
ref={searchInputRef}
|
||||
class="selector-search-input"
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
</div>
|
||||
<Combobox.Listbox class="selector-listbox" />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
packages/ui/src/components/model-status-selector.tsx
Normal file
95
packages/ui/src/components/model-status-selector.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown, ShieldCheck, Cpu } from "lucide-solid"
|
||||
import type { Model, Provider } from "../types/session"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
|
||||
interface ModelStatusSelectorProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
currentModel: { providerId: string; modelId: string }
|
||||
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export default function ModelStatusSelector(props: ModelStatusSelectorProps) {
|
||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
|
||||
const currentProvider = createMemo(() =>
|
||||
instanceProviders().find(p => p.id === props.currentModel.providerId)
|
||||
)
|
||||
|
||||
const currentModel = createMemo(() =>
|
||||
currentProvider()?.models.find(m => m.id === props.currentModel.modelId)
|
||||
)
|
||||
|
||||
// Simple auth status check: if we have providers and the current provider is in the list, we consider it "authenticated"
|
||||
const isAuthenticated = createMemo(() => !!currentProvider())
|
||||
|
||||
return (
|
||||
<div class="flex items-center space-x-2">
|
||||
{/* Auth Status Indicator */}
|
||||
<div class="flex items-center bg-white/5 border border-white/5 rounded-full px-2 py-1 space-x-1.5 h-[26px]">
|
||||
<div class={`w-1.5 h-1.5 rounded-full transition-all duration-500 ${isAuthenticated() ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]' : 'bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.4)]'}`} />
|
||||
<span class="text-[9px] font-black uppercase tracking-widest text-zinc-500 whitespace-nowrap">
|
||||
{isAuthenticated() ? 'AUTHED' : 'NO AUTH'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Model Selector HUD */}
|
||||
<Popover open={isOpen()} onOpenChange={setIsOpen}>
|
||||
<Popover.Trigger class="flex items-center bg-white/5 border border-white/5 rounded-full px-3 py-1 space-x-2 text-zinc-400 hover:border-white/10 hover:bg-white/10 transition-all group h-[26px]">
|
||||
<Cpu size={12} class="text-indigo-400 shrink-0" />
|
||||
<div class="flex flex-col items-start leading-none">
|
||||
<span class="text-[8px] font-black text-zinc-500 uppercase tracking-widest">AI MODEL</span>
|
||||
<span class="text-[10px] font-bold text-zinc-200 truncate max-w-[100px]">
|
||||
{currentModel()?.name ?? currentProvider()?.name ?? "Select Model"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={10} class={`transition-transform duration-200 shrink-0 ${isOpen() ? 'rotate-180' : ''}`} />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content class="z-[1000] min-w-[240px] bg-[#0c0c0d] border border-white/10 rounded-2xl shadow-2xl shadow-black/50 p-2 animate-in fade-in zoom-in-95 duration-200 origin-top">
|
||||
<div class="max-h-[400px] overflow-y-auto custom-scrollbar no-scrollbar">
|
||||
<For each={instanceProviders()}>
|
||||
{(provider) => (
|
||||
<div class="mb-2 last:mb-0">
|
||||
<div class="px-2 py-1 text-[9px] font-black text-zinc-600 uppercase tracking-widest flex items-center justify-between border-b border-white/5 mb-1">
|
||||
<span>{provider.name}</span>
|
||||
<Show when={provider.id === props.currentModel.providerId}>
|
||||
<ShieldCheck size={10} class="text-emerald-500/50" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<For each={provider.models}>
|
||||
{(model) => (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await props.onModelChange({ providerId: provider.id, modelId: model.id })
|
||||
setIsOpen(false)
|
||||
}}
|
||||
class={`w-full flex items-center justify-between px-2 py-2 rounded-lg text-[11px] transition-all border ${
|
||||
model.id === props.currentModel.modelId && provider.id === props.currentModel.providerId
|
||||
? 'bg-indigo-500/15 text-indigo-400 border-indigo-500/20'
|
||||
: 'text-zinc-400 hover:bg-white/5 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span class="font-bold">{model.name}</span>
|
||||
<Show when={model.id === props.currentModel.modelId && provider.id === props.currentModel.providerId}>
|
||||
<div class="w-1 h-1 bg-indigo-400 rounded-full animate-pulse" />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
468
packages/ui/src/components/opencode-binary-selector.tsx
Normal file
468
packages/ui/src/components/opencode-binary-selector.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus, Sparkles } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
// Special constant for Native mode (no OpenCode binary)
|
||||
const NATIVE_MODE_PATH = "__nomadarch_native__"
|
||||
|
||||
interface BinaryOption {
|
||||
path: string
|
||||
version?: string
|
||||
lastUsed?: number
|
||||
isDefault?: boolean
|
||||
isNative?: boolean
|
||||
}
|
||||
|
||||
interface OpenCodeBinarySelectorProps {
|
||||
selectedBinary: string
|
||||
onBinaryChange: (binary: string) => void
|
||||
disabled?: boolean
|
||||
isVisible?: boolean
|
||||
}
|
||||
|
||||
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
|
||||
const {
|
||||
opencodeBinaries,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
} = useConfig()
|
||||
const [customPath, setCustomPath] = createSignal("")
|
||||
const [validating, setValidating] = createSignal(false)
|
||||
const [validationError, setValidationError] = createSignal<string | null>(null)
|
||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
|
||||
const binaries = () => opencodeBinaries()
|
||||
|
||||
const lastUsedBinary = () => preferences().lastUsedBinary
|
||||
|
||||
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
||||
|
||||
// Include NomadArch Native as the first option
|
||||
const binaryOptions = createMemo<BinaryOption[]>(() => [
|
||||
{ path: NATIVE_MODE_PATH, isNative: true },
|
||||
{ path: "opencode", isDefault: true },
|
||||
...customBinaries()
|
||||
])
|
||||
|
||||
const currentSelectionPath = () => props.selectedBinary || "opencode"
|
||||
|
||||
const isNativeMode = () => currentSelectionPath() === NATIVE_MODE_PATH
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.selectedBinary && lastUsedBinary()) {
|
||||
props.onBinaryChange(lastUsedBinary()!)
|
||||
} else if (!props.selectedBinary) {
|
||||
const firstBinary = binaries()[0]
|
||||
if (firstBinary) {
|
||||
props.onBinaryChange(firstBinary.path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const cache = new Map(versionInfo())
|
||||
let updated = false
|
||||
|
||||
binaries().forEach((binary) => {
|
||||
if (binary.version && !cache.has(binary.path)) {
|
||||
cache.set(binary.path, binary.version)
|
||||
updated = true
|
||||
}
|
||||
})
|
||||
|
||||
if (updated) {
|
||||
setVersionInfo(cache)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.isVisible) return
|
||||
const cache = versionInfo()
|
||||
const pathsToValidate = ["opencode", ...customBinaries().map((binary) => binary.path)].filter(
|
||||
(path) => !cache.has(path),
|
||||
)
|
||||
|
||||
if (pathsToValidate.length === 0) return
|
||||
|
||||
setTimeout(() => {
|
||||
pathsToValidate.forEach((path) => {
|
||||
validateBinary(path).catch((error) => log.error("Failed to validate binary", { path, error }))
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
setValidatingPaths(new Set<string>())
|
||||
setValidating(false)
|
||||
})
|
||||
|
||||
async function validateBinary(path: string): Promise<{ valid: boolean; version?: string; error?: string }> {
|
||||
// Native mode is always valid
|
||||
if (path === NATIVE_MODE_PATH) {
|
||||
return { valid: true, version: "Native" }
|
||||
}
|
||||
|
||||
if (versionInfo().has(path)) {
|
||||
const cachedVersion = versionInfo().get(path)
|
||||
return cachedVersion ? { valid: true, version: cachedVersion } : { valid: true }
|
||||
}
|
||||
|
||||
if (validatingPaths().has(path)) {
|
||||
return { valid: false, error: "Already validating" }
|
||||
}
|
||||
|
||||
try {
|
||||
setValidatingPaths((prev) => new Set(prev).add(path))
|
||||
setValidating(true)
|
||||
setValidationError(null)
|
||||
|
||||
const result = await serverApi.validateBinary(path)
|
||||
|
||||
if (result.valid && result.version) {
|
||||
const updatedVersionInfo = new Map(versionInfo())
|
||||
updatedVersionInfo.set(path, result.version)
|
||||
setVersionInfo(updatedVersionInfo)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||
} finally {
|
||||
setValidatingPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(path)
|
||||
if (next.size === 0) {
|
||||
setValidating(false)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowseBinary() {
|
||||
if (props.disabled) return
|
||||
setValidationError(null)
|
||||
if (nativeDialogsAvailable) {
|
||||
const selected = await openNativeFileDialog({
|
||||
title: "Select OpenCode Binary",
|
||||
})
|
||||
if (selected) {
|
||||
setCustomPath(selected)
|
||||
void handleValidateAndAdd(selected)
|
||||
}
|
||||
return
|
||||
}
|
||||
setIsBinaryBrowserOpen(true)
|
||||
}
|
||||
|
||||
async function handleValidateAndAdd(path: string) {
|
||||
const validation = await validateBinary(path)
|
||||
|
||||
if (validation.valid) {
|
||||
addOpenCodeBinary(path, validation.version)
|
||||
props.onBinaryChange(path)
|
||||
updatePreferences({ lastUsedBinary: path })
|
||||
setCustomPath("")
|
||||
setValidationError(null)
|
||||
} else {
|
||||
setValidationError(validation.error || "Invalid OpenCode binary")
|
||||
}
|
||||
}
|
||||
|
||||
function handleBinaryBrowserSelect(path: string) {
|
||||
setIsBinaryBrowserOpen(false)
|
||||
setCustomPath(path)
|
||||
void handleValidateAndAdd(path)
|
||||
}
|
||||
|
||||
async function handleCustomPathSubmit() {
|
||||
|
||||
const path = customPath().trim()
|
||||
if (!path) return
|
||||
await handleValidateAndAdd(path)
|
||||
}
|
||||
|
||||
function handleSelectBinary(path: string) {
|
||||
if (props.disabled) return
|
||||
if (path === props.selectedBinary) return
|
||||
props.onBinaryChange(path)
|
||||
updatePreferences({ lastUsedBinary: path })
|
||||
}
|
||||
|
||||
function handleRemoveBinary(path: string, event: Event) {
|
||||
event.stopPropagation()
|
||||
if (props.disabled) return
|
||||
removeOpenCodeBinary(path)
|
||||
|
||||
if (props.selectedBinary === path) {
|
||||
props.onBinaryChange("opencode")
|
||||
updatePreferences({ lastUsedBinary: "opencode" })
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp?: number): string {
|
||||
if (!timestamp) return ""
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return "just now"
|
||||
}
|
||||
|
||||
function getDisplayName(path: string): string {
|
||||
if (path === NATIVE_MODE_PATH) return "🚀 NomadArch Native"
|
||||
if (path === "opencode") return "opencode (system PATH)"
|
||||
const parts = path.split(/[/\\]/)
|
||||
return parts[parts.length - 1] ?? path
|
||||
}
|
||||
|
||||
const isPathValidating = (path: string) => validatingPaths().has(path)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="panel">
|
||||
<div class="panel-header flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="panel-title">OpenCode Binary</h3>
|
||||
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
|
||||
</div>
|
||||
<Show when={validating()}>
|
||||
<div class="selector-loading text-xs">
|
||||
<Loader2 class="selector-loading-spinner" />
|
||||
<span>Checking versions…</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel-body space-y-3">
|
||||
<div class="selector-input-group">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath()}
|
||||
onInput={(e) => setCustomPath(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleCustomPathSubmit()
|
||||
}
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
placeholder="Enter path to opencode binary…"
|
||||
class="selector-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomPathSubmit}
|
||||
disabled={props.disabled || !customPath().trim()}
|
||||
class="selector-button selector-button-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBrowseBinary()}
|
||||
disabled={props.disabled}
|
||||
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" />
|
||||
Browse for Binary…
|
||||
</button>
|
||||
|
||||
<Show when={validationError()}>
|
||||
<div class="selector-validation-error">
|
||||
<div class="selector-validation-error-content">
|
||||
<AlertCircle class="selector-validation-error-icon" />
|
||||
<span class="selector-validation-error-text">{validationError()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Mode Comparison Info */}
|
||||
<div class="rounded-lg border border-white/10 overflow-hidden">
|
||||
<details class="group">
|
||||
<summary class="flex items-center justify-between px-3 py-2 cursor-pointer bg-white/5 hover:bg-white/10 transition-colors">
|
||||
<span class="text-xs font-medium text-muted">📊 Compare: Native vs SDK Mode</span>
|
||||
<svg class="w-4 h-4 text-muted transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="p-3 space-y-3 text-xs bg-black/20">
|
||||
{/* Native Mode */}
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2 text-emerald-400 font-medium">
|
||||
<Sparkles class="w-3.5 h-3.5" />
|
||||
<span>NomadArch Native (Recommended)</span>
|
||||
</div>
|
||||
<div class="pl-5 space-y-1 text-muted">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-emerald-400">✓</span>
|
||||
<span>No external binary required</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-emerald-400">✓</span>
|
||||
<span>Free Zen models (GPT-5 Nano, Grok Code, GLM-4.7)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-emerald-400">✓</span>
|
||||
<span>Faster startup, simpler setup</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-emerald-400">✓</span>
|
||||
<span>Full MCP tool support</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-amber-400">○</span>
|
||||
<span>No LSP integration (coming soon)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SDK Mode */}
|
||||
<div class="space-y-1.5 pt-2 border-t border-white/10">
|
||||
<div class="flex items-center gap-2 text-blue-400 font-medium">
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
<span>OpenCode SDK Mode</span>
|
||||
</div>
|
||||
<div class="pl-5 space-y-1 text-muted">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-blue-400">✓</span>
|
||||
<span>Full LSP integration</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-blue-400">✓</span>
|
||||
<span>All OpenCode features</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-blue-400">✓</span>
|
||||
<span>More provider options</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-amber-400">○</span>
|
||||
<span>Requires binary download</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-amber-400">○</span>
|
||||
<span>Platform-specific binaries</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
|
||||
<For each={binaryOptions()}>
|
||||
{(binary) => {
|
||||
const isDefault = binary.isDefault
|
||||
const isNative = binary.isNative
|
||||
const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
|
||||
|
||||
return (
|
||||
<div
|
||||
class="panel-list-item flex items-center"
|
||||
classList={{
|
||||
"panel-list-item-highlight": currentSelectionPath() === binary.path,
|
||||
"bg-gradient-to-r from-emerald-500/10 to-cyan-500/10 border-l-2 border-emerald-500": isNative && currentSelectionPath() === binary.path,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-list-item-content flex-1"
|
||||
onClick={() => handleSelectBinary(binary.path)}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={isNative}>
|
||||
<Sparkles
|
||||
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "text-emerald-400" : "text-muted"}`}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!isNative}>
|
||||
<Check
|
||||
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
</Show>
|
||||
<span class={`text-sm font-medium truncate ${isNative ? "text-emerald-400" : "text-primary"}`}>
|
||||
{getDisplayName(binary.path)}
|
||||
</span>
|
||||
<Show when={isNative}>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 font-medium">
|
||||
RECOMMENDED
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!isDefault && !isNative}>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">{binary.path}</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
|
||||
<Show when={versionLabel() && !isNative}>
|
||||
<span class="selector-badge-version">v{versionLabel()}</span>
|
||||
</Show>
|
||||
<Show when={isPathValidating(binary.path)}>
|
||||
<span class="selector-badge-time">Checking…</span>
|
||||
</Show>
|
||||
<Show when={!isDefault && !isNative && binary.lastUsed}>
|
||||
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
|
||||
</Show>
|
||||
<Show when={isDefault}>
|
||||
<span class="selector-badge-time">Use binary from system PATH</span>
|
||||
</Show>
|
||||
<Show when={isNative}>
|
||||
<span class="text-emerald-400/70">No OpenCode binary needed • Free Zen models included</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<Show when={!isDefault && !isNative}>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-muted hover:text-primary"
|
||||
onClick={(event) => handleRemoveBinary(binary.path, event)}
|
||||
disabled={props.disabled}
|
||||
title="Remove binary"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileSystemBrowserDialog
|
||||
open={isBinaryBrowserOpen()}
|
||||
mode="files"
|
||||
title="Select OpenCode Binary"
|
||||
description="Browse files exposed by the CLI server."
|
||||
onClose={() => setIsBinaryBrowserOpen(false)}
|
||||
onSelect={handleBinaryBrowserSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Export the native mode constant for use elsewhere
|
||||
export const NOMADARCH_NATIVE_MODE = NATIVE_MODE_PATH
|
||||
|
||||
export default OpenCodeBinarySelector
|
||||
1211
packages/ui/src/components/prompt-input.tsx
Normal file
1211
packages/ui/src/components/prompt-input.tsx
Normal file
File diff suppressed because it is too large
Load Diff
243
packages/ui/src/components/remote-access-overlay.tsx
Normal file
243
packages/ui/src/components/remote-access-overlay.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Switch } from "@kobalte/core/switch"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { toDataURL } from "qrcode"
|
||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { restartCli } from "../lib/native/cli"
|
||||
import { preferences, setListeningMode } from "../stores/preferences"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
interface RemoteAccessOverlayProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const list = addresses()
|
||||
if (allowExternalConnections()) {
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
}
|
||||
return list.filter((address) => address.scope === "loopback")
|
||||
})
|
||||
|
||||
const refreshMeta = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await serverApi.fetchServerMeta()
|
||||
setMeta(result)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
void refreshMeta()
|
||||
}
|
||||
})
|
||||
|
||||
const toggleExpanded = async (url: string) => {
|
||||
if (expandedUrl() === url) {
|
||||
setExpandedUrl(null)
|
||||
return
|
||||
}
|
||||
setExpandedUrl(url)
|
||||
if (!qrCodes()[url]) {
|
||||
try {
|
||||
const dataUrl = await toDataURL(url, { margin: 1, scale: 4 })
|
||||
setQrCodes((prev) => ({ ...prev, [url]: dataUrl }))
|
||||
} catch (err) {
|
||||
log.error("Failed to generate QR code", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAllowConnectionsChange = async (checked: boolean) => {
|
||||
const allow = Boolean(checked)
|
||||
const targetMode: "local" | "all" = allow ? "all" : "local"
|
||||
if (targetMode === currentMode()) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmDialog("Restart to apply listening mode? This will stop all running instances.", {
|
||||
title: allow ? "Open to other devices" : "Limit to this device",
|
||||
variant: "warning",
|
||||
confirmLabel: "Restart now",
|
||||
cancelLabel: "Cancel",
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
// Switch will revert automatically since `checked` is derived from store state
|
||||
return
|
||||
}
|
||||
|
||||
setListeningMode(targetMode)
|
||||
const restarted = await restartCli()
|
||||
if (!restarted) {
|
||||
setError("Unable to restart automatically. Please restart the app to apply the change.")
|
||||
} else {
|
||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||
}
|
||||
|
||||
void refreshMeta()
|
||||
}
|
||||
|
||||
const handleOpenUrl = (url: string) => {
|
||||
try {
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
} catch (err) {
|
||||
log.error("Failed to open URL", err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
modal
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
props.onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay remote-overlay-backdrop" />
|
||||
<div class="remote-overlay">
|
||||
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
|
||||
<header class="remote-header">
|
||||
<div>
|
||||
<p class="remote-eyebrow">Remote handover</p>
|
||||
<h2 class="remote-title">Connect to NomadArch remotely</h2>
|
||||
<p class="remote-subtitle">Use the addresses below to open NomadArch from another device.</p>
|
||||
</div>
|
||||
<button type="button" class="remote-close" onClick={props.onClose} aria-label="Close remote access">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="remote-body">
|
||||
<section class="remote-section">
|
||||
<div class="remote-section-heading">
|
||||
<div class="remote-section-title">
|
||||
<Shield class="remote-icon" />
|
||||
<div>
|
||||
<p class="remote-label">Listening mode</p>
|
||||
<p class="remote-help">Allow or limit remote handovers by binding to all interfaces or just localhost.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
|
||||
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
|
||||
<span class="remote-refresh-label">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
class="remote-toggle"
|
||||
checked={allowExternalConnections()}
|
||||
onChange={(nextChecked) => {
|
||||
void handleAllowConnectionsChange(nextChecked)
|
||||
}}
|
||||
>
|
||||
<Switch.Input />
|
||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||
<span class="remote-toggle-state">{allowExternalConnections() ? "On" : "Off"}</span>
|
||||
<Switch.Thumb class="remote-toggle-thumb" />
|
||||
</Switch.Control>
|
||||
<div class="remote-toggle-copy">
|
||||
<span class="remote-toggle-title">Allow connections from other IPs</span>
|
||||
<span class="remote-toggle-caption">
|
||||
{allowExternalConnections() ? "Binding to 0.0.0.0" : "Binding to 127.0.0.1"}
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
<p class="remote-toggle-note">
|
||||
Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the
|
||||
server restarts.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="remote-section">
|
||||
<div class="remote-section-heading">
|
||||
<div class="remote-section-title">
|
||||
<Wifi class="remote-icon" />
|
||||
<div>
|
||||
<p class="remote-label">Reachable addresses</p>
|
||||
<p class="remote-help">Launch or scan from another machine to hand over control.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!loading()} fallback={<div class="remote-card">Loading addresses…</div>}>
|
||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">No addresses available yet.</div>}>
|
||||
<div class="remote-address-list">
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
const expandedState = () => expandedUrl() === address.url
|
||||
const qr = () => qrCodes()[address.url]
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{address.url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {address.scope === "external" ? "Network" : address.scope === "loopback" ? "Loopback" : "Internal"} • {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(address.url)}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? "Hide QR" : "Show QR"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => <img src={dataUrl()} alt={`QR for ${address.url}`} class="remote-qr-img" />}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
344
packages/ui/src/components/session-list.tsx
Normal file
344
packages/ui/src/components/session-list.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js"
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { MessageSquare, Info, X, Copy, Trash2, Pencil } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import Kbd from "./kbd"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { formatShortcut } from "../lib/keyboard-utils"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { deleteSession, loading, renameSession } from "../stores/sessions"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
|
||||
interface SessionListProps {
|
||||
instanceId: string
|
||||
sessions: Map<string, Session>
|
||||
activeSessionId: string | null
|
||||
onSelect: (sessionId: string) => void
|
||||
onClose: (sessionId: string) => void
|
||||
onNew: () => void
|
||||
showHeader?: boolean
|
||||
showFooter?: boolean
|
||||
headerContent?: JSX.Element
|
||||
footerContent?: JSX.Element
|
||||
}
|
||||
|
||||
function formatSessionStatus(status: SessionStatus): string {
|
||||
switch (status) {
|
||||
case "working":
|
||||
return "Working"
|
||||
case "compacting":
|
||||
return "Compacting"
|
||||
default:
|
||||
return "Idle"
|
||||
}
|
||||
}
|
||||
|
||||
function arraysEqual(prev: readonly string[] | undefined, next: readonly string[]): boolean {
|
||||
if (!prev) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (prev.length !== next.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < prev.length; i++) {
|
||||
if (prev[i] !== next[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const SessionList: Component<SessionListProps> = (props) => {
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||
const infoShortcut = keyboardRegistry.get("switch-to-info")
|
||||
|
||||
const isSessionDeleting = (sessionId: string) => {
|
||||
const deleting = loading().deletingSession.get(props.instanceId)
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
}
|
||||
|
||||
const selectSession = (sessionId: string) => {
|
||||
props.onSelect(sessionId)
|
||||
}
|
||||
|
||||
const copySessionId = async (event: MouseEvent, sessionId: string) => {
|
||||
event.stopPropagation()
|
||||
|
||||
try {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard) {
|
||||
throw new Error("Clipboard API unavailable")
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(sessionId)
|
||||
showToastNotification({ message: "Session ID copied", variant: "success" })
|
||||
} catch (error) {
|
||||
log.error(`Failed to copy session ID ${sessionId}:`, error)
|
||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSession = async (event: MouseEvent, sessionId: string) => {
|
||||
event.stopPropagation()
|
||||
if (isSessionDeleting(sessionId)) return
|
||||
|
||||
try {
|
||||
await deleteSession(props.instanceId, sessionId)
|
||||
} catch (error) {
|
||||
log.error(`Failed to delete session ${sessionId}:`, error)
|
||||
showToastNotification({ message: "Unable to delete session", variant: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const openRenameDialog = (sessionId: string) => {
|
||||
const session = props.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
const label = session.title && session.title.trim() ? session.title : sessionId
|
||||
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
||||
}
|
||||
|
||||
const closeRenameDialog = () => {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
|
||||
const handleRenameSubmit = async (nextTitle: string) => {
|
||||
const target = renameTarget()
|
||||
if (!target) return
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
await renameSession(props.instanceId, target.id, nextTitle)
|
||||
setRenameTarget(null)
|
||||
} catch (error) {
|
||||
log.error(`Failed to rename session ${target.id}:`, error)
|
||||
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => {
|
||||
const session = () => props.sessions.get(rowProps.sessionId)
|
||||
if (!session()) {
|
||||
return <></>
|
||||
}
|
||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
||||
const title = () => session()?.title || "Untitled"
|
||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||
const statusLabel = () => formatSessionStatus(status())
|
||||
const pendingPermission = () => Boolean(session()?.pendingPermission)
|
||||
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
|
||||
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
|
||||
|
||||
return (
|
||||
<div class="session-list-item group">
|
||||
|
||||
<button
|
||||
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
onClick={() => selectSession(rowProps.sessionId)}
|
||||
title={title()}
|
||||
role="button"
|
||||
aria-selected={isActive()}
|
||||
>
|
||||
<div class="session-item-row session-item-header">
|
||||
<div class="session-item-title-row">
|
||||
<MessageSquare class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="session-item-title truncate">{title()}</span>
|
||||
</div>
|
||||
<Show when={rowProps.canClose}>
|
||||
<span
|
||||
class="session-item-close opacity-80 hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onClose(rowProps.sessionId)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close session"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="session-item-row session-item-meta">
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
<span class="status-dot" />
|
||||
{statusText()}
|
||||
</span>
|
||||
<div class="session-item-actions">
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => copySessionId(event, rowProps.sessionId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Copy session ID"
|
||||
title="Copy session ID"
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openRenameDialog(rowProps.sessionId)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Rename session"
|
||||
title="Rename session"
|
||||
>
|
||||
<Pencil class="w-3 h-3" />
|
||||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Delete session"
|
||||
title="Delete session"
|
||||
>
|
||||
<Show
|
||||
when={!isSessionDeleting(rowProps.sessionId)}
|
||||
fallback={
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const userSessionIds = createMemo(
|
||||
() => {
|
||||
const ids: string[] = []
|
||||
for (const session of props.sessions.values()) {
|
||||
if (session.parentId === null) {
|
||||
ids.push(session.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
},
|
||||
undefined,
|
||||
{ equals: arraysEqual },
|
||||
)
|
||||
|
||||
const childSessionIds = createMemo(
|
||||
() => {
|
||||
const children: { id: string; updated: number }[] = []
|
||||
for (const session of props.sessions.values()) {
|
||||
if (session.parentId !== null) {
|
||||
children.push({ id: session.id, updated: session.time.updated ?? 0 })
|
||||
}
|
||||
}
|
||||
if (children.length <= 1) {
|
||||
return children.map((entry) => entry.id)
|
||||
}
|
||||
children.sort((a, b) => b.updated - a.updated)
|
||||
return children.map((entry) => entry.id)
|
||||
},
|
||||
undefined,
|
||||
{ equals: arraysEqual },
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
||||
>
|
||||
<Show when={props.showHeader !== false}>
|
||||
<div class="session-list-header p-3 border-b border-base">
|
||||
{props.headerContent ?? (
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-primary">Sessions</h3>
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="session-list flex-1 overflow-y-auto">
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
Instance
|
||||
</div>
|
||||
<div class="session-list-item group">
|
||||
<button
|
||||
class={`session-item-base ${props.activeSessionId === "info" ? "session-item-active" : "session-item-inactive"}`}
|
||||
onClick={() => selectSession("info")}
|
||||
title="Instance Info"
|
||||
role="button"
|
||||
aria-selected={props.activeSessionId === "info"}
|
||||
>
|
||||
<div class="session-item-row session-item-header">
|
||||
<div class="session-item-title-row">
|
||||
<Info class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="session-item-title truncate">Instance Info</span>
|
||||
</div>
|
||||
{infoShortcut && <Kbd shortcut={formatShortcut(infoShortcut)} class="ml-2 not-italic" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Show when={userSessionIds().length > 0}>
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
User Session
|
||||
</div>
|
||||
<For each={userSessionIds()}>{(id) => <SessionRow sessionId={id} canClose />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={childSessionIds().length > 0}>
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
Agent Sessions
|
||||
</div>
|
||||
<For each={childSessionIds()}>{(id) => <SessionRow sessionId={id} />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.showFooter !== false}>
|
||||
<div class="session-list-footer p-3 border-t border-base">
|
||||
{props.footerContent ?? null}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<SessionRenameDialog
|
||||
open={Boolean(renameTarget())}
|
||||
currentTitle={renameTarget()?.title ?? ""}
|
||||
sessionLabel={renameTarget()?.label}
|
||||
isSubmitting={isRenaming()}
|
||||
onRename={handleRenameSubmit}
|
||||
onClose={closeRenameDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionList
|
||||
|
||||
193
packages/ui/src/components/session-picker.tsx
Normal file
193
packages/ui/src/components/session-picker.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Component, createSignal, Show, For, createEffect } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import type { Session, Agent } from "../types/session"
|
||||
import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions"
|
||||
import { instances, stopInstance } from "../stores/instances"
|
||||
import { agents } from "../stores/sessions"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
interface SessionPickerProps {
|
||||
instanceId: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
|
||||
const [isCreating, setIsCreating] = createSignal(false)
|
||||
|
||||
const instance = () => instances().get(props.instanceId)
|
||||
const parentSessions = () => getParentSessions(props.instanceId)
|
||||
const agentList = () => agents().get(props.instanceId) || []
|
||||
|
||||
createEffect(() => {
|
||||
const list = agentList()
|
||||
if (list.length === 0) {
|
||||
setSelectedAgent("")
|
||||
return
|
||||
}
|
||||
const current = selectedAgent()
|
||||
if (!current || !list.some((agent) => agent.name === current)) {
|
||||
setSelectedAgent(list[0].name)
|
||||
}
|
||||
})
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return "just now"
|
||||
}
|
||||
|
||||
async function handleSessionSelect(sessionId: string) {
|
||||
setActiveParentSession(props.instanceId, sessionId)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
async function handleNewSession() {
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const session = await createSession(props.instanceId, selectedAgent())
|
||||
setActiveParentSession(props.instanceId, session.id)
|
||||
props.onClose()
|
||||
} catch (error) {
|
||||
log.error("Failed to create session:", error)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
await stopInstance(props.instanceId)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-lg p-6">
|
||||
<Dialog.Title class="text-xl font-semibold text-primary mb-4">
|
||||
OpenCode • {instance()?.folder.split("/").pop()}
|
||||
</Dialog.Title>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Show
|
||||
when={parentSessions().length > 0}
|
||||
fallback={<div class="text-center py-4 text-sm text-muted">No previous sessions</div>}
|
||||
>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-secondary mb-2">
|
||||
Resume a session ({parentSessions().length}):
|
||||
</h3>
|
||||
<div class="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
<For each={parentSessions()}>
|
||||
{(session) => (
|
||||
<button
|
||||
type="button"
|
||||
class="selector-option w-full text-left hover:bg-surface-hover focus:bg-surface-hover"
|
||||
onClick={() => handleSessionSelect(session.id)}
|
||||
>
|
||||
<div class="selector-option-content w-full">
|
||||
<span class="selector-option-label truncate">
|
||||
{session.title || "Untitled"}
|
||||
</span>
|
||||
</div>
|
||||
<span class="selector-badge-time flex-shrink-0">
|
||||
{formatRelativeTime(session.time.updated)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-base" />
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-surface-base text-muted">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-secondary mb-2">Start new session:</h3>
|
||||
<div class="space-y-3">
|
||||
<Show
|
||||
when={agentList().length > 0}
|
||||
fallback={<div class="text-sm text-muted">Loading agents...</div>}
|
||||
>
|
||||
<select
|
||||
class="selector-input w-full"
|
||||
value={selectedAgent()}
|
||||
onChange={(e) => setSelectedAgent(e.currentTarget.value)}
|
||||
>
|
||||
<For each={agentList()}>{(agent) => <option value={agent.name}>{agent.name}</option>}</For>
|
||||
</select>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating() || agentList().length === 0}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show
|
||||
when={!isCreating()}
|
||||
fallback={
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isCreating()}
|
||||
fallback={<span>Creating...</span>}
|
||||
>
|
||||
<span>{agentList().length === 0 ? "Loading agents..." : "Create Session"}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<kbd class="kbd ml-2">
|
||||
Cmd+Enter
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionPicker
|
||||
130
packages/ui/src/components/session-rename-dialog.tsx
Normal file
130
packages/ui/src/components/session-rename-dialog.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Component, Show, createEffect, createSignal } from "solid-js"
|
||||
|
||||
interface SessionRenameDialogProps {
|
||||
open: boolean
|
||||
currentTitle: string
|
||||
sessionLabel?: string
|
||||
isSubmitting?: boolean
|
||||
onRename: (nextTitle: string) => Promise<void> | void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
const [title, setTitle] = createSignal("")
|
||||
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
setTitle(props.currentTitle ?? "")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return
|
||||
window.requestAnimationFrame(() => {
|
||||
inputRef?.focus()
|
||||
inputRef?.select()
|
||||
})
|
||||
})
|
||||
|
||||
const isSubmitting = () => Boolean(props.isSubmitting)
|
||||
const isRenameDisabled = () => isSubmitting() || !title().trim()
|
||||
|
||||
async function handleRename(event?: Event) {
|
||||
event?.preventDefault()
|
||||
if (isRenameDisabled()) return
|
||||
await props.onRename(title().trim())
|
||||
}
|
||||
|
||||
const description = () => {
|
||||
if (props.sessionLabel && props.sessionLabel.trim()) {
|
||||
return `Update the title for "${props.sessionLabel}".`
|
||||
}
|
||||
return "Set a new title for this session."
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isSubmitting()) {
|
||||
props.onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">Rename Session</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1">
|
||||
{description()}
|
||||
</Dialog.Description>
|
||||
|
||||
<form class="mt-4 space-y-4" onSubmit={handleRename}>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-secondary" for={inputId}>
|
||||
Session name
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
ref={(element) => {
|
||||
inputRef = element
|
||||
}}
|
||||
type="text"
|
||||
value={title()}
|
||||
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||
placeholder="Enter a session name"
|
||||
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary"
|
||||
onClick={() => {
|
||||
if (!isSubmitting()) {
|
||||
props.onClose()
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="button-primary flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled={isRenameDisabled()}
|
||||
>
|
||||
<Show
|
||||
when={!isSubmitting()}
|
||||
fallback={
|
||||
<>
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Renaming…</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Rename
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionRenameDialog
|
||||
81
packages/ui/src/components/session/context-usage-panel.tsx
Normal file
81
packages/ui/src/components/session/context-usage-panel.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createMemo, type Component } from "solid-js"
|
||||
import { getSessionInfo } from "../../stores/sessions"
|
||||
import { formatTokenTotal } from "../../lib/formatters"
|
||||
|
||||
interface ContextUsagePanelProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
|
||||
|
||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
const info = createMemo(
|
||||
() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: 0,
|
||||
contextAvailableTokens: null,
|
||||
},
|
||||
)
|
||||
|
||||
const inputTokens = createMemo(() => info().inputTokens ?? 0)
|
||||
const outputTokens = createMemo(() => info().outputTokens ?? 0)
|
||||
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
|
||||
const availableTokens = createMemo(() => info().contextAvailableTokens)
|
||||
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
|
||||
const costValue = createMemo(() => {
|
||||
const value = info().isSubscriptionModel ? 0 : info().cost
|
||||
return value > 0 ? value : 0
|
||||
})
|
||||
|
||||
|
||||
const formatTokenValue = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return "--"
|
||||
return formatTokenTotal(value)
|
||||
}
|
||||
|
||||
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
|
||||
|
||||
return (
|
||||
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class={headingClass}>Tokens</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Input</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
||||
</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Output</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
|
||||
</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Cost</span>
|
||||
<span class="font-semibold text-primary">{costDisplay()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||
<div class={headingClass}>Context</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Used</span>
|
||||
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
|
||||
</div>
|
||||
<div class={chipClass}>
|
||||
<span class={chipLabelClass}>Avail</span>
|
||||
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContextUsagePanel
|
||||
240
packages/ui/src/components/session/session-view.tsx
Normal file
240
packages/ui/src/components/session/session-view.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Show, createMemo, createEffect, type Component } from "solid-js"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import MessageSection from "../message-section"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import PromptInput from "../prompt-input"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
function isTextPart(part: ClientPart): part is ClientPart & { type: "text"; text: string } {
|
||||
return part?.type === "text" && typeof (part as any).text === "string"
|
||||
}
|
||||
|
||||
interface SessionViewProps {
|
||||
sessionId: string
|
||||
activeSessions: Map<string, Session>
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
escapeInDebounce: boolean
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
const session = () => props.activeSessions.get(props.sessionId)
|
||||
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
const sessionBusy = createMemo(() => {
|
||||
const currentSession = session()
|
||||
if (!currentSession) return false
|
||||
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
||||
})
|
||||
let scrollToBottomHandle: (() => void) | undefined
|
||||
function scheduleScrollToBottom() {
|
||||
if (!scrollToBottomHandle) return
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => scrollToBottomHandle?.())
|
||||
})
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!props.isActive) return
|
||||
scheduleScrollToBottom()
|
||||
})
|
||||
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
|
||||
|
||||
createEffect(() => {
|
||||
const currentSession = session()
|
||||
if (currentSession) {
|
||||
loadMessages(props.instanceId, currentSession.id).catch((error) => log.error("Failed to load messages", error))
|
||||
}
|
||||
})
|
||||
|
||||
function registerQuoteHandler(handler: (text: string, mode: "quote" | "code") => void) {
|
||||
quoteHandler = handler
|
||||
return () => {
|
||||
if (quoteHandler === handler) {
|
||||
quoteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuoteSelection(text: string, mode: "quote" | "code") {
|
||||
if (quoteHandler) {
|
||||
quoteHandler(text, mode)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||
scheduleScrollToBottom()
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
||||
}
|
||||
|
||||
async function handleRunShell(command: string) {
|
||||
await runShellCommand(props.instanceId, props.sessionId, command)
|
||||
}
|
||||
|
||||
async function handleAbortSession() {
|
||||
const currentSession = session()
|
||||
if (!currentSession) return
|
||||
|
||||
try {
|
||||
await abortSession(props.instanceId, currentSession.id)
|
||||
log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id })
|
||||
} catch (error) {
|
||||
log.error("Failed to abort session", error)
|
||||
showAlertDialog("Failed to stop session", {
|
||||
title: "Stop failed",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getUserMessageText(messageId: string): string | null {
|
||||
|
||||
const normalizedMessage = messageStore().getMessage(messageId)
|
||||
if (normalizedMessage && normalizedMessage.role === "user") {
|
||||
const parts = normalizedMessage.partIds
|
||||
.map((partId) => normalizedMessage.parts[partId]?.data)
|
||||
.filter((part): part is ClientPart => Boolean(part))
|
||||
const textParts = parts.filter(isTextPart)
|
||||
if (textParts.length > 0) {
|
||||
return textParts.map((part) => part.text).join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
async function handleRevert(messageId: string) {
|
||||
const instance = instances().get(props.instanceId)
|
||||
if (!instance || !instance.client) return
|
||||
|
||||
try {
|
||||
await instance.client.session.revert({
|
||||
path: { id: props.sessionId },
|
||||
body: { messageID: messageId },
|
||||
})
|
||||
|
||||
const restoredText = getUserMessageText(messageId)
|
||||
if (restoredText) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to revert message", error)
|
||||
showAlertDialog("Failed to revert to message", {
|
||||
title: "Revert failed",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFork(messageId?: string) {
|
||||
if (!messageId) {
|
||||
log.warn("Fork requires a user message id")
|
||||
return
|
||||
}
|
||||
|
||||
const restoredText = getUserMessageText(messageId)
|
||||
|
||||
try {
|
||||
const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId })
|
||||
|
||||
const parentToActivate = forkedSession.parentId ?? forkedSession.id
|
||||
setActiveParentSession(props.instanceId, parentToActivate)
|
||||
if (forkedSession.parentId) {
|
||||
setActiveSession(props.instanceId, forkedSession.id)
|
||||
}
|
||||
|
||||
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
|
||||
|
||||
if (restoredText) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to fork session", error)
|
||||
showAlertDialog("Failed to fork session", {
|
||||
title: "Fork failed",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={session()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500">Session not found</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(sessionAccessor) => {
|
||||
const activeSession = sessionAccessor()
|
||||
if (!activeSession) return null
|
||||
return (
|
||||
<div class="session-view">
|
||||
<MessageSection
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeSession.id}
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onFork={handleFork}
|
||||
isActive={props.isActive}
|
||||
registerScrollToBottom={(fn) => {
|
||||
scrollToBottomHandle = fn
|
||||
if (props.isActive) {
|
||||
scheduleScrollToBottom()
|
||||
}
|
||||
}}
|
||||
|
||||
|
||||
|
||||
|
||||
showSidebarToggle={props.showSidebarToggle}
|
||||
onSidebarToggle={props.onSidebarToggle}
|
||||
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
||||
onQuoteSelection={handleQuoteSelection}
|
||||
/>
|
||||
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={activeSession.id}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isSessionBusy={sessionBusy()}
|
||||
onAbortSession={handleAbortSession}
|
||||
registerQuoteHandler={registerQuoteHandler}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionView
|
||||
285
packages/ui/src/components/settings/OllamaCloudSettings.tsx
Normal file
285
packages/ui/src/components/settings/OllamaCloudSettings.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { Component, createSignal, onMount, Show } from 'solid-js'
|
||||
import toast from 'solid-toast'
|
||||
import { Button } from '@suid/material'
|
||||
import { Cloud, CheckCircle, XCircle, Loader } from 'lucide-solid'
|
||||
import { instances } from '../../stores/instances'
|
||||
import { fetchProviders } from '../../stores/session-api'
|
||||
|
||||
interface OllamaCloudConfig {
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
interface OllamaCloudModelsResponse {
|
||||
models: Array<{
|
||||
name: string
|
||||
model?: string
|
||||
size?: string | number
|
||||
digest?: string
|
||||
modified_at?: string
|
||||
details?: any
|
||||
}>
|
||||
}
|
||||
|
||||
const OllamaCloudSettings: Component = () => {
|
||||
const [config, setConfig] = createSignal<OllamaCloudConfig>({ enabled: false })
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [isTesting, setIsTesting] = createSignal(false)
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
|
||||
const [models, setModels] = createSignal<string[]>([])
|
||||
const [isLoadingModels, setIsLoadingModels] = createSignal(false)
|
||||
const [hasStoredApiKey, setHasStoredApiKey] = createSignal(false)
|
||||
|
||||
// Load config on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/ollama/config')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const maskedKey = typeof data.config?.apiKey === "string" && /^\*+$/.test(data.config.apiKey)
|
||||
setHasStoredApiKey(Boolean(data.config?.apiKey) && maskedKey)
|
||||
setConfig({
|
||||
...data.config,
|
||||
apiKey: maskedKey ? "" : data.config?.apiKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Ollama config:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleConfigChange = (field: keyof OllamaCloudConfig, value: any) => {
|
||||
setConfig(prev => ({ ...prev, [field]: value }))
|
||||
setConnectionStatus('idle')
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const payload: OllamaCloudConfig = { ...config() }
|
||||
if (!payload.apiKey && hasStoredApiKey()) {
|
||||
delete payload.apiKey
|
||||
}
|
||||
|
||||
const response = await fetch('/api/ollama/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Ollama Cloud configuration saved', {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
|
||||
// Refresh providers for all instances so models appear in selector
|
||||
const instanceList = Array.from(instances().values())
|
||||
for (const instance of instanceList) {
|
||||
try {
|
||||
await fetchProviders(instance.id)
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh providers for instance ${instance.id}:`, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to save config')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to save Ollama Cloud configuration', {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
setIsTesting(true)
|
||||
setConnectionStatus('testing')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ollama/test', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setConnectionStatus(data.connected ? 'connected' : 'failed')
|
||||
|
||||
if (data.connected) {
|
||||
toast.success('Successfully connected to Ollama Cloud', {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
|
||||
// Load models after successful connection
|
||||
loadModels()
|
||||
} else {
|
||||
toast.error('Failed to connect to Ollama Cloud', {
|
||||
duration: 3000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
}
|
||||
} else {
|
||||
throw new Error('Connection test failed')
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionStatus('failed')
|
||||
toast.error('Connection test failed', {
|
||||
duration: 3000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadModels = async () => {
|
||||
setIsLoadingModels(true)
|
||||
try {
|
||||
const response = await fetch('/api/ollama/models')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Handle different response formats
|
||||
if (data.models && Array.isArray(data.models)) {
|
||||
setModels(data.models.map((model: any) => model.name || model.model || 'unknown'))
|
||||
if (data.models.length > 0) {
|
||||
toast.success(`Loaded ${data.models.length} models`, { duration: 2000 })
|
||||
}
|
||||
} else {
|
||||
console.warn('Unexpected models response format:', data)
|
||||
setModels([])
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
toast.error(`Failed to load models: ${errorData.error || response.statusText}`, {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error)
|
||||
toast.error('Failed to load models - network error', {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
} finally {
|
||||
setIsLoadingModels(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (connectionStatus()) {
|
||||
case 'testing':
|
||||
return <Loader class="w-4 h-4 animate-spin" />
|
||||
case 'connected':
|
||||
return <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle class="w-4 h-4 text-red-500" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Cloud class="w-6 h-6" />
|
||||
<h2 class="text-xl font-semibold">Ollama Cloud Integration</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="font-medium">Enable Ollama Cloud</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config().enabled}
|
||||
onChange={(e) => handleConfigChange('enabled', e.target.checked)}
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label class="block font-medium mb-2">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={hasStoredApiKey() ? "API key stored (leave empty to keep)" : "Enter your Ollama Cloud API key"}
|
||||
value={config().apiKey || ''}
|
||||
onChange={(e) => handleConfigChange('apiKey', e.target.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={!config().enabled}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Get your API key from <a href="https://ollama.com/settings/keys" target="_blank" class="text-blue-500 underline">ollama.com/settings/keys</a></p>
|
||||
</div>
|
||||
|
||||
{/* Endpoint */}
|
||||
<div>
|
||||
<label class="block font-medium mb-2">Endpoint</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://ollama.com"
|
||||
value={config().endpoint || ''}
|
||||
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={!config().enabled}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Default: https://ollama.com (for local Ollama use: http://localhost:11434)</p>
|
||||
</div>
|
||||
|
||||
{/* Test Connection */}
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={testConnection}
|
||||
disabled={!config().enabled || isTesting()}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{getStatusIcon()}
|
||||
{isTesting() ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
|
||||
<Show when={connectionStatus() === 'connected'}>
|
||||
<span class="text-green-600 text-sm">Connected successfully</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === 'failed'}>
|
||||
<span class="text-red-600 text-sm">Connection failed</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Available Models */}
|
||||
<Show when={models().length > 0}>
|
||||
<div>
|
||||
<label class="block font-medium mb-2">Available Models</label>
|
||||
<div class="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto">
|
||||
{models().map(model => (
|
||||
<div class="p-3 border border-gray-200 rounded-md bg-gray-50">
|
||||
<code class="text-sm font-mono">{model}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Save Configuration */}
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={saveConfig}
|
||||
disabled={isLoading()}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{isLoading() ? <Loader class="w-4 h-4 animate-spin" /> : null}
|
||||
Save Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OllamaCloudSettings
|
||||
222
packages/ui/src/components/settings/OpenCodeZenSettings.tsx
Normal file
222
packages/ui/src/components/settings/OpenCodeZenSettings.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Component, createSignal, onMount, For, Show } from 'solid-js'
|
||||
import { Zap, CheckCircle, XCircle, Loader, Sparkles } from 'lucide-solid'
|
||||
|
||||
interface ZenModel {
|
||||
id: string
|
||||
name: string
|
||||
family?: string
|
||||
free: boolean
|
||||
reasoning?: boolean
|
||||
tool_call?: boolean
|
||||
limit?: {
|
||||
context: number
|
||||
output: number
|
||||
}
|
||||
}
|
||||
|
||||
const OpenCodeZenSettings: Component = () => {
|
||||
const [models, setModels] = createSignal<ZenModel[]>([])
|
||||
const [isLoading, setIsLoading] = createSignal(true)
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
|
||||
// Load models on mount
|
||||
onMount(async () => {
|
||||
await loadModels()
|
||||
await testConnection()
|
||||
})
|
||||
|
||||
const loadModels = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/opencode-zen/models')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setModels(data.models || [])
|
||||
setError(null)
|
||||
} else {
|
||||
throw new Error('Failed to load models')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load OpenCode Zen models:', err)
|
||||
setError('Failed to load models')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
setConnectionStatus('testing')
|
||||
try {
|
||||
const response = await fetch('/api/opencode-zen/test')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setConnectionStatus(data.connected ? 'connected' : 'failed')
|
||||
} else {
|
||||
setConnectionStatus('failed')
|
||||
}
|
||||
} catch (err) {
|
||||
setConnectionStatus('failed')
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-gradient-to-br from-orange-500/20 to-yellow-500/20 rounded-lg">
|
||||
<Zap class="w-6 h-6 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white">OpenCode Zen</h2>
|
||||
<p class="text-sm text-zinc-400">Free AI models - No API key required!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{connectionStatus() === 'testing' && (
|
||||
<span class="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<Loader class="w-4 h-4 animate-spin" />
|
||||
Testing...
|
||||
</span>
|
||||
)}
|
||||
{connectionStatus() === 'connected' && (
|
||||
<span class="flex items-center gap-2 text-sm text-emerald-400">
|
||||
<CheckCircle class="w-4 h-4" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{connectionStatus() === 'failed' && (
|
||||
<span class="flex items-center gap-2 text-sm text-red-400">
|
||||
<XCircle class="w-4 h-4" />
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div class="bg-gradient-to-r from-orange-500/10 via-yellow-500/10 to-orange-500/10 border border-orange-500/20 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Sparkles class="w-5 h-5 text-orange-400 mt-0.5" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-orange-300 mb-1">Free Models Available!</h3>
|
||||
<p class="text-sm text-zinc-300">
|
||||
OpenCode Zen provides access to powerful AI models completely free of charge.
|
||||
These models are ready to use immediately - no API keys or authentication required!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Models Grid */}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-white">Available Free Models</h3>
|
||||
<button
|
||||
onClick={loadModels}
|
||||
disabled={isLoading()}
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading() ? <Loader class="w-4 h-4 animate-spin" /> : null}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isLoading()}>
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center gap-3 text-zinc-400">
|
||||
<Loader class="w-6 h-6 animate-spin" />
|
||||
<span>Loading models...</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoading() && models().length > 0}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<For each={models()}>
|
||||
{(model) => (
|
||||
<div class="group bg-zinc-900/50 border border-zinc-800 hover:border-orange-500/50 rounded-xl p-4 transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 class="font-semibold text-white group-hover:text-orange-300 transition-colors">
|
||||
{model.name}
|
||||
</h4>
|
||||
<p class="text-xs text-zinc-500 font-mono">{model.id}</p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-[10px] font-bold uppercase bg-emerald-500/20 text-emerald-400 rounded">
|
||||
FREE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
{model.reasoning && (
|
||||
<span class="px-2 py-0.5 text-[10px] bg-purple-500/20 text-purple-400 rounded">
|
||||
Reasoning
|
||||
</span>
|
||||
)}
|
||||
{model.tool_call && (
|
||||
<span class="px-2 py-0.5 text-[10px] bg-blue-500/20 text-blue-400 rounded">
|
||||
Tool Use
|
||||
</span>
|
||||
)}
|
||||
{model.family && (
|
||||
<span class="px-2 py-0.5 text-[10px] bg-zinc-700 text-zinc-400 rounded">
|
||||
{model.family}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{model.limit && (
|
||||
<div class="flex items-center gap-4 text-xs text-zinc-500">
|
||||
<span>Context: {formatNumber(model.limit.context)}</span>
|
||||
<span>Output: {formatNumber(model.limit.output)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoading() && models().length === 0 && !error()}>
|
||||
<div class="text-center py-12 text-zinc-500">
|
||||
<p>No free models available at this time.</p>
|
||||
<button
|
||||
onClick={loadModels}
|
||||
class="mt-4 px-4 py-2 text-sm bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Usage Info */}
|
||||
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-4">
|
||||
<h4 class="font-medium text-white mb-2">How to Use</h4>
|
||||
<ul class="text-sm text-zinc-400 space-y-1">
|
||||
<li>• Select any Zen model from the model picker in chat</li>
|
||||
<li>• No API key configuration needed - just start chatting!</li>
|
||||
<li>• Models support streaming, reasoning, and tool use</li>
|
||||
<li>• Rate limits may apply during high demand periods</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenCodeZenSettings
|
||||
236
packages/ui/src/components/settings/QwenCodeSettings.tsx
Normal file
236
packages/ui/src/components/settings/QwenCodeSettings.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Component, createSignal, onMount, Show } from 'solid-js'
|
||||
import toast from 'solid-toast'
|
||||
import { Button } from '@suid/material'
|
||||
import { User, CheckCircle, XCircle, Loader, LogOut, ExternalLink } from 'lucide-solid'
|
||||
import { useQwenOAuth } from '../../lib/integrations/qwen-oauth'
|
||||
import { instances } from '../../stores/instances'
|
||||
import { fetchProviders } from '../../stores/session-api'
|
||||
|
||||
interface QwenUser {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
tier: string
|
||||
limits: {
|
||||
requests_per_day: number
|
||||
requests_per_minute: number
|
||||
}
|
||||
}
|
||||
|
||||
const QwenCodeSettings: Component = () => {
|
||||
const { isAuthenticated, user, isLoading, signIn, signOut, tokenInfo } = useQwenOAuth()
|
||||
const [isSigningOut, setIsSigningOut] = createSignal(false)
|
||||
|
||||
const handleSignIn = async () => {
|
||||
try {
|
||||
await signIn()
|
||||
toast.success('Successfully authenticated with Qwen Code', {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
for (const instance of instances().values()) {
|
||||
try {
|
||||
await fetchProviders(instance.id)
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh providers for instance ${instance.id}:`, error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to authenticate with Qwen Code', {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
console.error('Qwen OAuth error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignOut = () => {
|
||||
setIsSigningOut(true)
|
||||
try {
|
||||
signOut()
|
||||
toast.success('Successfully signed out from Qwen Code', {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('Failed to sign out from Qwen Code', {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
console.error('Qwen signout error:', error)
|
||||
} finally {
|
||||
setIsSigningOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatRemainingRequests = (user: QwenUser) => {
|
||||
return `${user.limits.requests_per_day} requests/day, ${user.limits.requests_per_minute}/min`
|
||||
}
|
||||
|
||||
const formatTokenExpiry = () => {
|
||||
const token = tokenInfo()
|
||||
if (!token) return "Token not available"
|
||||
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
|
||||
const expiresAt = (createdAt + token.expires_in) * 1000
|
||||
const remainingMs = Math.max(0, expiresAt - Date.now())
|
||||
const remainingMin = Math.floor(remainingMs / 60000)
|
||||
return `${remainingMin} min remaining`
|
||||
}
|
||||
|
||||
const tokenStatus = () => {
|
||||
const token = tokenInfo()
|
||||
if (!token) return "Unknown"
|
||||
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
|
||||
const expiresAt = (createdAt + token.expires_in) * 1000
|
||||
return Date.now() < expiresAt ? "Active" : "Expired"
|
||||
}
|
||||
|
||||
const tokenId = () => {
|
||||
const token = tokenInfo()
|
||||
if (!token?.access_token) return "Unavailable"
|
||||
const value = token.access_token
|
||||
if (value.length <= 12) return value
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<User class="w-6 h-6" />
|
||||
<h2 class="text-xl font-semibold">Qwen Code Integration</h2>
|
||||
</div>
|
||||
|
||||
{/* Authentication Status */}
|
||||
<div class="space-y-4">
|
||||
<Show
|
||||
when={isAuthenticated()}
|
||||
fallback={
|
||||
/* Not Authenticated State */
|
||||
<div class="text-center py-8">
|
||||
<div class="mb-4">
|
||||
<User class="w-12 h-12 mx-auto text-gray-400" />
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Connect your Qwen Code account to access AI-powered coding assistance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSignIn}
|
||||
disabled={isLoading()}
|
||||
class="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
{isLoading() ? <Loader class="w-4 h-4 animate-spin" /> : null}
|
||||
Connect Qwen Code Account
|
||||
</Button>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>Get 2,000 free requests per day with Qwen OAuth</p>
|
||||
<a
|
||||
href="https://qwen.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:underline inline-flex items-center gap-1 mt-2"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3" />
|
||||
Learn more about Qwen Code
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Authenticated State */}
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-green-100 dark:bg-green-800 rounded-full flex items-center justify-center">
|
||||
<User class="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-green-900 dark:text-green-100">
|
||||
{user()?.username || 'Qwen User'}
|
||||
</h3>
|
||||
<p class="text-sm text-green-700 dark:text-green-300">
|
||||
{user()?.email}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs px-2 py-1 bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200 rounded-full">
|
||||
{user()?.tier || 'Free'} Tier
|
||||
</span>
|
||||
<Show when={user()}>
|
||||
<span class="text-xs text-green-600 dark:text-green-400">
|
||||
{formatRemainingRequests(user()!)}
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-xs text-green-600 dark:text-green-400">
|
||||
{formatTokenExpiry()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-2 text-xs text-green-700 dark:text-green-300">
|
||||
<span class="font-semibold">Token ID:</span>
|
||||
<span class="font-mono">{tokenId()}</span>
|
||||
<span class="px-2 py-0.5 rounded-full bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200">
|
||||
{tokenStatus()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleSignOut}
|
||||
disabled={isSigningOut()}
|
||||
size="small"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
{isSigningOut() ? 'Signing out...' : 'Sign Out'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Feature Information */}
|
||||
<div class="border-t pt-4">
|
||||
<h3 class="font-semibold mb-3">Available Features</h3>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div class="p-3 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<h4 class="font-medium text-sm">Code Understanding & Editing</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Query and edit large codebases beyond traditional context window limits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<h4 class="font-medium text-sm">Workflow Automation</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Automate operational tasks like handling pull requests and complex rebases
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<h4 class="font-medium text-sm">Vision Model Support</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Automatically detect images and switch to vision-capable models for multimodal analysis
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Tips */}
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">Usage Tips</h3>
|
||||
<ul class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<li>• Use <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">/compress</code> to compress conversation history</li>
|
||||
<li>• Use <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">/stats</code> to check token usage</li>
|
||||
<li>• Vision models automatically switch when images are detected</li>
|
||||
<li>• Configure behavior in <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">~/.qwen/settings.json</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QwenCodeSettings
|
||||
248
packages/ui/src/components/settings/ZAISettings.tsx
Normal file
248
packages/ui/src/components/settings/ZAISettings.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Component, createSignal, onMount, Show } from 'solid-js'
|
||||
import toast from 'solid-toast'
|
||||
import { Button } from '@suid/material'
|
||||
import { Cpu, CheckCircle, XCircle, Loader, Key, ExternalLink } from 'lucide-solid'
|
||||
|
||||
interface ZAIConfig {
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
const ZAISettings: Component = () => {
|
||||
const [config, setConfig] = createSignal<ZAIConfig>({ enabled: false })
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [isTesting, setIsTesting] = createSignal(false)
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
|
||||
const [models, setModels] = createSignal<string[]>([])
|
||||
|
||||
// Load config on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/zai/config')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setConfig(data.config)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Z.AI config:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleConfigChange = (field: keyof ZAIConfig, value: any) => {
|
||||
setConfig(prev => ({ ...prev, [field]: value }))
|
||||
setConnectionStatus('idle')
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/zai/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config())
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Z.AI configuration saved', {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to save config')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to save Z.AI configuration', {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
setIsTesting(true)
|
||||
setConnectionStatus('testing')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/zai/test', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setConnectionStatus(data.connected ? 'connected' : 'failed')
|
||||
|
||||
if (data.connected) {
|
||||
toast.success('Successfully connected to Z.AI', {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
|
||||
// Load models after successful connection
|
||||
loadModels()
|
||||
} else {
|
||||
toast.error('Failed to connect to Z.AI', {
|
||||
duration: 3000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
}
|
||||
} else {
|
||||
throw new Error('Connection test failed')
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionStatus('failed')
|
||||
toast.error('Connection test failed', {
|
||||
duration: 3000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/zai/models')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setModels(data.models.map((m: any) => m.name))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (connectionStatus()) {
|
||||
case 'testing':
|
||||
return <Loader class="w-4 h-4 animate-spin" />
|
||||
case 'connected':
|
||||
return <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle class="w-4 h-4 text-red-500" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Cpu class="w-6 h-6 text-blue-500" />
|
||||
<h2 class="text-xl font-semibold">Z.AI Integration</h2>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
|
||||
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">GLM Coding Plan</h3>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
Z.AI provides access to GLM-4.7, GLM-4.6, GLM-4.5, and other GLM models through their PaaS/v4 API. Get your API key from the{' '}
|
||||
<a
|
||||
href="https://z.ai/manage-apikey/apikey-list"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:no-underline inline-flex items-center gap-1"
|
||||
>
|
||||
Z.AI Platform <ExternalLink class="w-3 h-3" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="font-medium">Enable Z.AI</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config().enabled}
|
||||
onChange={(e) => handleConfigChange('enabled', e.target.checked)}
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label class="block font-medium mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Key class="w-4 h-4" />
|
||||
API Key
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter your Z.AI API key"
|
||||
value={config().apiKey || ''}
|
||||
onChange={(e) => handleConfigChange('apiKey', e.target.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-800"
|
||||
disabled={!config().enabled}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Get your key from <a href="https://z.ai/manage-apikey/apikey-list" target="_blank" class="text-blue-500 hover:underline">z.ai/manage-apikey</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium mb-2">Endpoint</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://api.z.ai/api/coding/paas/v4"
|
||||
value={config().endpoint || ''}
|
||||
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-800"
|
||||
disabled={!config().enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test Connection */}
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={testConnection}
|
||||
disabled={!config().enabled || isTesting()}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{getStatusIcon()}
|
||||
{isTesting() ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
|
||||
<Show when={connectionStatus() === 'connected'}>
|
||||
<span class="text-green-600 text-sm">Connected successfully</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === 'failed'}>
|
||||
<span class="text-red-600 text-sm">Connection failed</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Available Models */}
|
||||
<Show when={models().length > 0}>
|
||||
<div>
|
||||
<label class="block font-medium mb-2">Available Models</label>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
{models().map(model => (
|
||||
<div class="p-3 border border-gray-200 dark:border-gray-700 rounded-md bg-gray-50 dark:bg-gray-800">
|
||||
<code class="text-sm font-mono">{model}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Save Configuration */}
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={saveConfig}
|
||||
disabled={isLoading()}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{isLoading() ? <Loader class="w-4 h-4 animate-spin" /> : null}
|
||||
Save Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ZAISettings
|
||||
937
packages/ui/src/components/tool-call.tsx
Normal file
937
packages/ui/src/components/tool-call.tsx
Normal file
@@ -0,0 +1,937 @@
|
||||
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { Markdown } from "./markdown"
|
||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
import { sendPermissionResponse } from "../stores/instances"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { resolveToolRenderer } from "./tool-call/renderers"
|
||||
import type {
|
||||
DiffPayload,
|
||||
DiffRenderOptions,
|
||||
MarkdownRenderOptions,
|
||||
ToolCallPart,
|
||||
ToolRendererContext,
|
||||
ToolScrollHelpers,
|
||||
} from "./tool-call/types"
|
||||
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
|
||||
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
|
||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
|
||||
|
||||
function makeRenderCacheKey(
|
||||
toolCallId?: string | null,
|
||||
messageId?: string,
|
||||
partId?: string | null,
|
||||
variant = "default",
|
||||
) {
|
||||
const messageComponent = messageId ?? "unknown-message"
|
||||
const toolCallComponent = partId ?? toolCallId ?? "unknown-tool-call"
|
||||
return `${messageComponent}:${toolCallComponent}:${variant}`
|
||||
}
|
||||
|
||||
|
||||
interface ToolCallProps {
|
||||
toolCall: ToolCallPart
|
||||
toolCallId?: string
|
||||
messageId?: string
|
||||
messageVersion?: number
|
||||
partVersion?: number
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface LspRangePosition {
|
||||
line?: number
|
||||
character?: number
|
||||
}
|
||||
|
||||
interface LspRange {
|
||||
start?: LspRangePosition
|
||||
}
|
||||
|
||||
interface LspDiagnostic {
|
||||
message?: string
|
||||
severity?: number
|
||||
range?: LspRange
|
||||
}
|
||||
|
||||
interface DiagnosticEntry {
|
||||
id: string
|
||||
severity: number
|
||||
tone: "error" | "warning" | "info"
|
||||
label: string
|
||||
icon: string
|
||||
message: string
|
||||
filePath: string
|
||||
displayPath: string
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
|
||||
function normalizeDiagnosticPath(path: string) {
|
||||
return path.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
if (severity === 1) return "error"
|
||||
if (severity === 2) return "warning"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
||||
return { label: "INFO", icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
||||
if (!state) return []
|
||||
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||
if (!supportsMetadata) return []
|
||||
|
||||
const metadata = (state.metadata || {}) as Record<string, unknown>
|
||||
const input = (state.input || {}) as Record<string, unknown>
|
||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
|
||||
if (!diagnosticsMap) return []
|
||||
|
||||
const preferredPath = [
|
||||
input.filePath,
|
||||
metadata.filePath,
|
||||
metadata.filepath,
|
||||
input.path,
|
||||
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
|
||||
|
||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
||||
if (!normalizedPreferred) return []
|
||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
||||
if (candidateEntries.length === 0) return []
|
||||
|
||||
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
||||
const normalized = normalizeDiagnosticPath(path)
|
||||
return normalized === normalizedPreferred
|
||||
})
|
||||
|
||||
if (prioritizedEntries.length === 0) return []
|
||||
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (const [pathKey, list] of prioritizedEntries) {
|
||||
if (!Array.isArray(list)) continue
|
||||
const normalizedPath = normalizeDiagnosticPath(pathKey)
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
entries.push({
|
||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedPath,
|
||||
displayPath: getRelativePath(normalizedPath),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
}
|
||||
|
||||
function diagnosticFileName(entries: DiagnosticEntry[]) {
|
||||
const first = entries[0]
|
||||
return first ? first.displayPath : ""
|
||||
}
|
||||
|
||||
function renderDiagnosticsSection(
|
||||
entries: DiagnosticEntry[],
|
||||
expanded: boolean,
|
||||
toggle: () => void,
|
||||
fileLabel: string,
|
||||
) {
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div class="tool-call-diagnostics-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-diagnostics-heading"
|
||||
aria-expanded={expanded}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span class="tool-call-icon" aria-hidden="true">
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
<span class="tool-call-emoji" aria-hidden="true">🛠</span>
|
||||
<span class="tool-call-summary">Diagnostics</span>
|
||||
<span class="tool-call-diagnostics-file" title={fileLabel}>{fileLabel}</span>
|
||||
</button>
|
||||
<Show when={expanded}>
|
||||
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
|
||||
<div class="tool-call-diagnostics-body" role="list">
|
||||
<For each={entries}>
|
||||
{(entry) => (
|
||||
<div class="tool-call-diagnostic-row" role="listitem">
|
||||
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
|
||||
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
|
||||
<span>{entry.label}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-path" title={entry.filePath}>
|
||||
{entry.displayPath}
|
||||
<span class="tool-call-diagnostic-coords">
|
||||
:L{entry.line || "-"}:C{entry.column || "-"}
|
||||
</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const { preferences, setDiffViewMode } = useConfig()
|
||||
const { isDark } = useTheme()
|
||||
const toolCallMemo = createMemo(() => props.toolCall)
|
||||
const toolName = createMemo(() => toolCallMemo()?.tool || "")
|
||||
const toolCallIdentifier = createMemo(() => toolCallMemo()?.callID || props.toolCallId || toolCallMemo()?.id || "")
|
||||
const toolState = createMemo(() => toolCallMemo()?.state)
|
||||
|
||||
const cacheContext = createMemo(() => ({
|
||||
toolCallId: toolCallIdentifier(),
|
||||
messageId: props.messageId,
|
||||
partId: toolCallMemo()?.id ?? null,
|
||||
}))
|
||||
|
||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
|
||||
const createVariantCache = (variant: string) =>
|
||||
|
||||
useGlobalCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: TOOL_CALL_CACHE_SCOPE,
|
||||
key: () => {
|
||||
const context = cacheContext()
|
||||
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, variant)
|
||||
},
|
||||
})
|
||||
|
||||
const diffCache = createVariantCache("diff")
|
||||
const permissionDiffCache = createVariantCache("permission-diff")
|
||||
const markdownCache = createVariantCache("markdown")
|
||||
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
|
||||
const pendingPermission = createMemo(() => {
|
||||
const state = permissionState()
|
||||
if (state) {
|
||||
return { permission: state.entry.permission, active: state.active }
|
||||
}
|
||||
return toolCallMemo()?.pendingPermission
|
||||
})
|
||||
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||
|
||||
const defaultExpandedForTool = createMemo(() => {
|
||||
const prefExpanded = toolOutputDefaultExpanded()
|
||||
const toolName = toolCallMemo()?.tool || ""
|
||||
if (toolName === "read") {
|
||||
return false
|
||||
}
|
||||
return prefExpanded
|
||||
})
|
||||
|
||||
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
|
||||
|
||||
const expanded = () => {
|
||||
const permission = pendingPermission()
|
||||
if (permission?.active) return true
|
||||
const override = userExpanded()
|
||||
if (override !== null) return override
|
||||
return defaultExpandedForTool()
|
||||
}
|
||||
|
||||
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
||||
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
|
||||
const activePermissionKey = createMemo(() => {
|
||||
const permission = permissionDetails()
|
||||
return permission && isPermissionActive() ? permission.id : ""
|
||||
})
|
||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined)
|
||||
|
||||
const diagnosticsExpanded = () => {
|
||||
const permission = pendingPermission()
|
||||
if (permission?.active) return true
|
||||
const override = diagnosticsOverride()
|
||||
if (override !== undefined) return override
|
||||
return diagnosticsDefaultExpanded()
|
||||
}
|
||||
const diagnosticsEntries = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return []
|
||||
return extractDiagnostics(state)
|
||||
})
|
||||
|
||||
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
|
||||
let toolCallRootRef: HTMLDivElement | undefined
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
let lastFileEventKey = ""
|
||||
|
||||
let pendingScrollFrame: number | null = null
|
||||
let pendingAnchorScroll: number | null = null
|
||||
let userScrollIntentUntil = 0
|
||||
let lastKnownScrollTop = 0
|
||||
|
||||
function restoreScrollPosition(forceBottom = false) {
|
||||
const container = scrollContainerRef
|
||||
if (!container) return
|
||||
if (forceBottom) {
|
||||
container.scrollTop = container.scrollHeight
|
||||
lastKnownScrollTop = container.scrollTop
|
||||
} else {
|
||||
container.scrollTop = lastKnownScrollTop
|
||||
}
|
||||
}
|
||||
|
||||
const persistScrollSnapshot = (element?: HTMLElement | null) => {
|
||||
if (!element) return
|
||||
lastKnownScrollTop = element.scrollTop
|
||||
}
|
||||
|
||||
const handleScrollRendered = () => {
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollPosition(autoScroll())
|
||||
if (!expanded()) return
|
||||
scheduleAnchorScroll()
|
||||
})
|
||||
}
|
||||
|
||||
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
|
||||
scrollContainerRef = element || undefined
|
||||
setScrollContainer(scrollContainerRef)
|
||||
if (scrollContainerRef) {
|
||||
restoreScrollPosition(autoScroll())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function markUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement) {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
const handlePointerIntent = () => markUserScrollIntent()
|
||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||
if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) {
|
||||
markUserScrollIntent()
|
||||
}
|
||||
}
|
||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("pointerdown", handlePointerIntent)
|
||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("keydown", handleKeyIntent)
|
||||
detachScrollIntentListeners = () => {
|
||||
element.removeEventListener("wheel", handlePointerIntent)
|
||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||
element.removeEventListener("touchstart", handlePointerIntent)
|
||||
element.removeEventListener("keydown", handleKeyIntent)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAnchorScroll(immediate = false) {
|
||||
if (!autoScroll()) return
|
||||
const sentinel = bottomSentinel()
|
||||
const container = scrollContainerRef
|
||||
if (!sentinel || !container) return
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||
pendingAnchorScroll = null
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const sentinelRect = sentinel.getBoundingClientRect()
|
||||
const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX
|
||||
if (Math.abs(delta) > 1) {
|
||||
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
||||
}
|
||||
lastKnownScrollTop = container.scrollTop
|
||||
})
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const container = scrollContainer()
|
||||
if (!container) return
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
pendingScrollFrame = requestAnimationFrame(() => {
|
||||
pendingScrollFrame = null
|
||||
const atBottom = bottomSentinelVisible()
|
||||
if (isUserScroll) {
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
handleScroll()
|
||||
persistScrollSnapshot(event.currentTarget)
|
||||
}
|
||||
|
||||
const scrollHelpers: ToolScrollHelpers = {
|
||||
registerContainer: (element, options) => {
|
||||
if (options?.disableTracking) return
|
||||
initializeScrollContainer(element)
|
||||
},
|
||||
handleScroll: handleScrollEvent,
|
||||
renderSentinel: (options) => {
|
||||
if (options?.disableTracking) return null
|
||||
return <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
|
||||
},
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
const container = scrollContainer()
|
||||
if (!container) return
|
||||
|
||||
attachScrollIntentListeners(container)
|
||||
onCleanup(() => {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollContainer()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.target === sentinel) {
|
||||
setBottomSentinelVisible(entry.isIntersecting)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!expanded()) {
|
||||
setScrollContainer(undefined)
|
||||
scrollContainerRef = undefined
|
||||
setBottomSentinel(null)
|
||||
setAutoScroll(true)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const permission = permissionDetails()
|
||||
if (!permission) {
|
||||
setPermissionSubmitting(false)
|
||||
setPermissionError(null)
|
||||
} else {
|
||||
setPermissionError(null)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const activeKey = activePermissionKey()
|
||||
if (!activeKey) return
|
||||
requestAnimationFrame(() => {
|
||||
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const state = toolState()
|
||||
if (!state || state.status !== "completed") return
|
||||
const tool = toolName()
|
||||
if (!["write", "edit", "patch"].includes(tool)) return
|
||||
const key = `${toolCallIdentifier()}:${tool}:${state.status}`
|
||||
if (key === lastFileEventKey) return
|
||||
lastFileEventKey = key
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId: props.instanceId } }))
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const activeKey = activePermissionKey()
|
||||
if (!activeKey) return
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handlePermissionResponse("once")
|
||||
} else if (event.key === "a" || event.key === "A") {
|
||||
event.preventDefault()
|
||||
handlePermissionResponse("always")
|
||||
} else if (event.key === "d" || event.key === "D") {
|
||||
event.preventDefault()
|
||||
handlePermissionResponse("reject")
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handler)
|
||||
onCleanup(() => document.removeEventListener("keydown", handler))
|
||||
})
|
||||
|
||||
|
||||
const statusIcon = () => {
|
||||
const status = toolState()?.status || ""
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "⏸"
|
||||
case "running":
|
||||
return "⏳"
|
||||
case "completed":
|
||||
return "✓"
|
||||
case "error":
|
||||
return "✗"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass = () => {
|
||||
const status = toolState()?.status || "pending"
|
||||
return `tool-call-status-${status}`
|
||||
}
|
||||
|
||||
const combinedStatusClass = () => {
|
||||
const base = statusClass()
|
||||
return pendingPermission() ? `${base} tool-call-awaiting-permission` : base
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
const permission = pendingPermission()
|
||||
if (permission?.active) {
|
||||
return
|
||||
}
|
||||
setUserExpanded((prev) => {
|
||||
const current = prev === null ? defaultExpandedForTool() : prev
|
||||
return !current
|
||||
})
|
||||
}
|
||||
|
||||
const renderer = createMemo(() => resolveToolRenderer(toolName()))
|
||||
|
||||
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions) {
|
||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||
const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache
|
||||
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
||||
const themeKey = isDark() ? "dark" : "light"
|
||||
|
||||
let cachedHtml: string | undefined
|
||||
const cached = cacheHandle.get<RenderCache>()
|
||||
const currentMode = diffMode()
|
||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
||||
cachedHtml = cached.html
|
||||
}
|
||||
|
||||
const handleModeChange = (mode: DiffViewMode) => {
|
||||
setDiffViewMode(mode)
|
||||
}
|
||||
|
||||
const handleDiffRendered = () => {
|
||||
if (!options?.disableScrollTracking) {
|
||||
handleScrollRendered()
|
||||
}
|
||||
props.onContentRendered?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||
ref={(element) => scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
|
||||
onScroll={options?.disableScrollTracking ? undefined : scrollHelpers.handleScroll}
|
||||
>
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||
<div class="tool-call-diff-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "split"}
|
||||
onClick={() => handleModeChange("split")}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "unified"}
|
||||
onClick={() => handleModeChange("unified")}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolCallDiffViewer
|
||||
diffText={payload.diffText}
|
||||
filePath={payload.filePath}
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
cachedHtml={cachedHtml}
|
||||
cacheEntryParams={cacheHandle.params()}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
{scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMarkdownContent(options: MarkdownRenderOptions) {
|
||||
if (!options.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const size = options.size || "default"
|
||||
const disableHighlight = options.disableHighlight || false
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
|
||||
const state = toolState()
|
||||
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||
if (shouldDeferMarkdown) {
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||
{scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const markdownPart: TextPart = { type: "text", text: options.content }
|
||||
const cached = markdownCache.get<RenderCache>()
|
||||
if (cached) {
|
||||
markdownPart.renderCache = cached
|
||||
}
|
||||
|
||||
const handleMarkdownRendered = () => {
|
||||
markdownCache.set(markdownPart.renderCache)
|
||||
handleScrollRendered()
|
||||
props.onContentRendered?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
isDark={isDark()}
|
||||
disableHighlight={disableHighlight}
|
||||
onRendered={handleMarkdownRendered}
|
||||
/>
|
||||
{scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const messageVersionAccessor = createMemo(() => props.messageVersion)
|
||||
const partVersionAccessor = createMemo(() => props.partVersion)
|
||||
|
||||
const rendererContext: ToolRendererContext = {
|
||||
toolCall: toolCallMemo,
|
||||
toolState,
|
||||
toolName,
|
||||
messageVersion: messageVersionAccessor,
|
||||
partVersion: partVersionAccessor,
|
||||
renderMarkdown: renderMarkdownContent,
|
||||
renderDiff: renderDiffContent,
|
||||
scrollHelpers,
|
||||
}
|
||||
|
||||
let previousPartVersion: number | undefined
|
||||
createEffect(() => {
|
||||
const version = partVersionAccessor()
|
||||
if (!expanded()) {
|
||||
return
|
||||
}
|
||||
if (version === undefined) {
|
||||
return
|
||||
}
|
||||
if (previousPartVersion !== undefined && version === previousPartVersion) {
|
||||
return
|
||||
}
|
||||
previousPartVersion = version
|
||||
scheduleAnchorScroll()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (expanded() && autoScroll()) {
|
||||
scheduleAnchorScroll(true)
|
||||
}
|
||||
})
|
||||
|
||||
const getRendererAction = () => renderer().getAction?.(rendererContext) ?? getDefaultToolAction(toolName())
|
||||
|
||||
|
||||
const renderToolTitle = () => {
|
||||
const state = toolState()
|
||||
const currentTool = toolName()
|
||||
|
||||
if (currentTool !== "task") {
|
||||
return resolveTitleForTool({ toolName: currentTool, state })
|
||||
}
|
||||
|
||||
if (!state) return getRendererAction()
|
||||
if (state.status === "pending") return getRendererAction()
|
||||
|
||||
const customTitle = renderer().getTitle?.(rendererContext)
|
||||
if (customTitle) return customTitle
|
||||
|
||||
if (isToolStateRunning(state) && state.title) {
|
||||
return state.title
|
||||
}
|
||||
|
||||
if (isToolStateCompleted(state) && state.title) {
|
||||
return state.title
|
||||
}
|
||||
|
||||
return getToolName(currentTool)
|
||||
}
|
||||
|
||||
const renderToolBody = () => {
|
||||
return renderer().renderBody(rendererContext)
|
||||
}
|
||||
|
||||
async function handlePermissionResponse(response: "once" | "always" | "reject") {
|
||||
const permission = permissionDetails()
|
||||
if (!permission || !isPermissionActive()) {
|
||||
return
|
||||
}
|
||||
setPermissionSubmitting(true)
|
||||
setPermissionError(null)
|
||||
try {
|
||||
const sessionId = permission.sessionID || props.sessionId
|
||||
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
|
||||
} catch (error) {
|
||||
log.error("Failed to send permission response", error)
|
||||
setPermissionError(error instanceof Error ? error.message : "Unable to update permission")
|
||||
} finally {
|
||||
setPermissionSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const renderError = () => {
|
||||
const state = toolState() || {}
|
||||
if (state.status === "error" && state.error) {
|
||||
return (
|
||||
<div class="tool-call-error-content">
|
||||
<strong>Error:</strong> {state.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const renderPermissionBlock = () => {
|
||||
const permission = permissionDetails()
|
||||
if (!permission) return null
|
||||
const active = isPermissionActive()
|
||||
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
|
||||
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
|
||||
const diffPathRaw = (() => {
|
||||
if (typeof metadata.filePath === "string") {
|
||||
return metadata.filePath as string
|
||||
}
|
||||
if (typeof metadata.path === "string") {
|
||||
return metadata.path as string
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null
|
||||
|
||||
return (
|
||||
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
|
||||
<span class="tool-call-permission-type">{permission.type}</span>
|
||||
</div>
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="tool-call-permission-title">
|
||||
<code>{permission.title}</code>
|
||||
</div>
|
||||
<Show when={diffPayload}>
|
||||
{(payload) => (
|
||||
<div class="tool-call-permission-diff">
|
||||
{renderDiffContent(payload(), {
|
||||
variant: "permission-diff",
|
||||
disableScrollTracking: true,
|
||||
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={active}
|
||||
fallback={<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>}
|
||||
>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => handlePermissionResponse("once")}
|
||||
>
|
||||
Allow Once
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => handlePermissionResponse("always")}
|
||||
>
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => handlePermissionResponse("reject")}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Allow once</span>
|
||||
<kbd class="kbd">A</kbd>
|
||||
<span>Always allow</span>
|
||||
<kbd class="kbd">D</kbd>
|
||||
<span>Deny</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={permissionError()}>
|
||||
<div class="tool-call-permission-error">{permissionError()}</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = () => toolState()?.status || ""
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
pendingScrollFrame = null
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
ref={(element) => {
|
||||
toolCallRootRef = element || undefined
|
||||
}}
|
||||
class={`tool-call ${combinedStatusClass()}`}
|
||||
>
|
||||
<button
|
||||
class="tool-call-header"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
data-status-icon={statusIcon()}
|
||||
>
|
||||
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
|
||||
{renderToolTitle()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded() && (
|
||||
<div class="tool-call-details">
|
||||
{renderToolBody()}
|
||||
|
||||
{renderError()}
|
||||
|
||||
{renderPermissionBlock()}
|
||||
|
||||
<Show when={status() === "pending" && !pendingPermission()}>
|
||||
<div class="tool-call-pending-message">
|
||||
<span class="spinner-small"></span>
|
||||
<span>Waiting to run...</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Show when={diagnosticsEntries().length}>
|
||||
|
||||
{renderDiagnosticsSection(
|
||||
diagnosticsEntries(),
|
||||
diagnosticsExpanded(),
|
||||
() => setDiagnosticsOverride((prev) => {
|
||||
const current = prev === undefined ? diagnosticsDefaultExpanded() : prev
|
||||
return !current
|
||||
}),
|
||||
diagnosticFileName(diagnosticsEntries()),
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
packages/ui/src/components/tool-call/renderers/bash.tsx
Normal file
44
packages/ui/src/components/tool-call/renderers/bash.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||
|
||||
export const bashRenderer: ToolRenderer = {
|
||||
tools: ["bash"],
|
||||
getAction: () => "Writing command...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const name = getToolName("bash")
|
||||
const description = typeof input.description === "string" && input.description.length > 0 ? input.description : ""
|
||||
const timeout = typeof input.timeout === "number" && input.timeout > 0 ? input.timeout : undefined
|
||||
|
||||
const baseTitle = description ? `${name} ${description}` : name
|
||||
if (!timeout) {
|
||||
return baseTitle
|
||||
}
|
||||
|
||||
const timeoutLabel = `${timeout}ms`
|
||||
return `${baseTitle} · Timeout: ${timeoutLabel}`
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const { input, metadata } = readToolStatePayload(state)
|
||||
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
||||
const outputResult = formatUnknown(
|
||||
isToolStateCompleted(state)
|
||||
? state.output
|
||||
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
|
||||
? metadata.output
|
||||
: undefined,
|
||||
)
|
||||
const parts = [command, outputResult?.text].filter(Boolean)
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const content = ensureMarkdownContent(parts.join("\n"), "bash", true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
25
packages/ui/src/components/tool-call/renderers/default.tsx
Normal file
25
packages/ui/src/components/tool-call/renderers/default.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, formatUnknown, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||
|
||||
export const defaultRenderer: ToolRenderer = {
|
||||
tools: ["*"],
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const { metadata, input } = readToolStatePayload(state)
|
||||
const primaryOutput = isToolStateCompleted(state)
|
||||
? state.output
|
||||
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
|
||||
? metadata.output
|
||||
: metadata.diff ?? metadata.preview ?? input.content
|
||||
|
||||
const result = formatUnknown(primaryOutput)
|
||||
if (!result) return null
|
||||
|
||||
const content = ensureMarkdownContent(result.text, result.language, true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
32
packages/ui/src/components/tool-call/renderers/edit.tsx
Normal file
32
packages/ui/src/components/tool-call/renderers/edit.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||
|
||||
export const editRenderer: ToolRenderer = {
|
||||
tools: ["edit"],
|
||||
getAction: () => "Preparing edit...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||
if (!filePath) return getToolName("edit")
|
||||
return `${getToolName("edit")} ${getRelativePath(filePath)}`
|
||||
},
|
||||
renderBody({ toolState, toolName, renderDiff, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const diffPayload = extractDiffPayload(toolName(), state)
|
||||
if (diffPayload) {
|
||||
return renderDiff(diffPayload)
|
||||
}
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const diffText = typeof metadata.diff === "string" ? metadata.diff : null
|
||||
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
|
||||
const content = ensureMarkdownContent(diffText || fallback, "diff", true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
36
packages/ui/src/components/tool-call/renderers/index.ts
Normal file
36
packages/ui/src/components/tool-call/renderers/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { bashRenderer } from "./bash"
|
||||
import { defaultRenderer } from "./default"
|
||||
import { editRenderer } from "./edit"
|
||||
import { patchRenderer } from "./patch"
|
||||
import { readRenderer } from "./read"
|
||||
import { taskRenderer } from "./task"
|
||||
import { todoRenderer } from "./todo"
|
||||
import { webfetchRenderer } from "./webfetch"
|
||||
import { writeRenderer } from "./write"
|
||||
import { invalidRenderer } from "./invalid"
|
||||
|
||||
const TOOL_RENDERERS: ToolRenderer[] = [
|
||||
bashRenderer,
|
||||
readRenderer,
|
||||
writeRenderer,
|
||||
editRenderer,
|
||||
patchRenderer,
|
||||
webfetchRenderer,
|
||||
todoRenderer,
|
||||
taskRenderer,
|
||||
invalidRenderer,
|
||||
]
|
||||
|
||||
const rendererMap = TOOL_RENDERERS.reduce<Record<string, ToolRenderer>>((acc, renderer) => {
|
||||
renderer.tools.forEach((tool) => {
|
||||
acc[tool] = renderer
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
export function resolveToolRenderer(toolName: string): ToolRenderer {
|
||||
return rendererMap[toolName] ?? defaultRenderer
|
||||
}
|
||||
|
||||
export { defaultRenderer }
|
||||
19
packages/ui/src/components/tool-call/renderers/invalid.tsx
Normal file
19
packages/ui/src/components/tool-call/renderers/invalid.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { defaultRenderer } from "./default"
|
||||
import { getToolName, readToolStatePayload } from "../utils"
|
||||
|
||||
export const invalidRenderer: ToolRenderer = {
|
||||
tools: ["invalid"],
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return getToolName("invalid")
|
||||
const { input } = readToolStatePayload(state)
|
||||
if (typeof input.tool === "string") {
|
||||
return getToolName(input.tool)
|
||||
}
|
||||
return getToolName("invalid")
|
||||
},
|
||||
renderBody(context) {
|
||||
return defaultRenderer.renderBody(context)
|
||||
},
|
||||
}
|
||||
32
packages/ui/src/components/tool-call/renderers/patch.tsx
Normal file
32
packages/ui/src/components/tool-call/renderers/patch.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||
|
||||
export const patchRenderer: ToolRenderer = {
|
||||
tools: ["patch"],
|
||||
getAction: () => "Preparing patch...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||
if (!filePath) return getToolName("patch")
|
||||
return `${getToolName("patch")} ${getRelativePath(filePath)}`
|
||||
},
|
||||
renderBody({ toolState, toolName, renderDiff, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const diffPayload = extractDiffPayload(toolName(), state)
|
||||
if (diffPayload) {
|
||||
return renderDiff(diffPayload)
|
||||
}
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const diffText = typeof metadata.diff === "string" ? metadata.diff : null
|
||||
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
|
||||
const content = ensureMarkdownContent(diffText || fallback, "diff", true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
42
packages/ui/src/components/tool-call/renderers/read.tsx
Normal file
42
packages/ui/src/components/tool-call/renderers/read.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
|
||||
|
||||
export const readRenderer: ToolRenderer = {
|
||||
tools: ["read"],
|
||||
getAction: () => "Reading file...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||
const offset = typeof input.offset === "number" ? input.offset : undefined
|
||||
const limit = typeof input.limit === "number" ? input.limit : undefined
|
||||
const relativePath = filePath ? getRelativePath(filePath) : ""
|
||||
const detailParts: string[] = []
|
||||
|
||||
if (typeof offset === "number") {
|
||||
detailParts.push(`Offset: ${offset}`)
|
||||
}
|
||||
|
||||
if (typeof limit === "number") {
|
||||
detailParts.push(`Limit: ${limit}`)
|
||||
}
|
||||
|
||||
const baseTitle = relativePath ? `${getToolName("read")} ${relativePath}` : getToolName("read")
|
||||
if (!detailParts.length) {
|
||||
return baseTitle
|
||||
}
|
||||
|
||||
return `${baseTitle} · ${detailParts.join(" · ")}`
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
const { metadata, input } = readToolStatePayload(state)
|
||||
const preview = typeof metadata.preview === "string" ? metadata.preview : null
|
||||
const language = inferLanguageFromPath(typeof input.filePath === "string" ? input.filePath : undefined)
|
||||
const content = ensureMarkdownContent(preview, language, true)
|
||||
if (!content) return null
|
||||
return renderMarkdown({ content, disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
155
packages/ui/src/components/tool-call/renderers/task.tsx
Normal file
155
packages/ui/src/components/tool-call/renderers/task.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { getTodoTitle } from "./todo"
|
||||
import { resolveTitleForTool } from "../tool-title"
|
||||
|
||||
interface TaskSummaryItem {
|
||||
id: string
|
||||
tool: string
|
||||
input: Record<string, any>
|
||||
metadata: Record<string, any>
|
||||
state?: ToolState
|
||||
status?: ToolState["status"]
|
||||
title?: string
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
||||
return status
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function summarizeStatusIcon(status?: ToolState["status"]) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "⏸"
|
||||
case "running":
|
||||
return "⏳"
|
||||
case "completed":
|
||||
return "✓"
|
||||
case "error":
|
||||
return "✗"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeStatusLabel(status?: ToolState["status"]) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "Pending"
|
||||
case "running":
|
||||
return "Running"
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "error":
|
||||
return "Error"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
function describeTaskTitle(input: Record<string, any>) {
|
||||
const description = typeof input.description === "string" ? input.description : undefined
|
||||
const subagent = typeof input.subagent_type === "string" ? input.subagent_type : undefined
|
||||
const base = getToolName("task")
|
||||
if (description && subagent) {
|
||||
return `${base}[${subagent}] ${description}`
|
||||
}
|
||||
if (description) {
|
||||
return `${base} ${description}`
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function describeToolTitle(item: TaskSummaryItem): string {
|
||||
if (item.title && item.title.length > 0) {
|
||||
return item.title
|
||||
}
|
||||
|
||||
if (item.tool === "task") {
|
||||
return describeTaskTitle({ ...item.metadata, ...item.input })
|
||||
}
|
||||
|
||||
if (item.state) {
|
||||
return resolveTitleForTool({ toolName: item.tool, state: item.state })
|
||||
}
|
||||
|
||||
return getDefaultToolAction(item.tool)
|
||||
}
|
||||
|
||||
export const taskRenderer: ToolRenderer = {
|
||||
tools: ["task"],
|
||||
getAction: () => "Delegating...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
return describeTaskTitle(input)
|
||||
},
|
||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) {
|
||||
const items = createMemo(() => {
|
||||
// Track the reactive change points so we only recompute when the part/message changes
|
||||
messageVersion?.()
|
||||
partVersion?.()
|
||||
|
||||
const state = toolState()
|
||||
if (!state) return []
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
|
||||
|
||||
return summary.map((entry, index) => {
|
||||
const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown"
|
||||
const stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined
|
||||
const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {}
|
||||
const fallbackInput = typeof entry?.input === "object" && entry.input ? entry.input : {}
|
||||
const id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}`
|
||||
const statusValue = normalizeStatus((entry?.status as string | undefined) ?? stateValue?.status)
|
||||
const title = typeof entry?.title === "string" ? entry.title : undefined
|
||||
return { id, tool, input: fallbackInput, metadata: metadataFromEntry, state: stateValue, status: statusValue, title }
|
||||
})
|
||||
})
|
||||
|
||||
if (items().length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={(element) => scrollHelpers?.registerContainer(element)}
|
||||
onScroll={scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusLabel = summarizeStatusLabel(status)
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
134
packages/ui/src/components/tool-call/renderers/todo.tsx
Normal file
134
packages/ui/src/components/tool-call/renderers/todo.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { readToolStatePayload } from "../utils"
|
||||
|
||||
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
|
||||
|
||||
export interface TodoViewItem {
|
||||
id: string
|
||||
content: string
|
||||
status: TodoViewStatus
|
||||
}
|
||||
|
||||
function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus {
|
||||
if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus
|
||||
return "pending"
|
||||
}
|
||||
|
||||
function extractTodosFromState(state?: ToolState): TodoViewItem[] {
|
||||
if (!state) return []
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : []
|
||||
const items: TodoViewItem[] = []
|
||||
|
||||
for (let index = 0; index < todos.length; index++) {
|
||||
const todo = todos[index]
|
||||
const content = typeof todo?.content === "string" ? todo.content.trim() : ""
|
||||
if (!content) continue
|
||||
const status = normalizeTodoStatus((todo as any).status)
|
||||
const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : `${index}-${content}`
|
||||
items.push({ id, content, status })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function summarizeTodos(todos: TodoViewItem[]) {
|
||||
return todos.reduce(
|
||||
(acc, todo) => {
|
||||
acc.total += 1
|
||||
acc[todo.status] = (acc[todo.status] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{ total: 0, pending: 0, in_progress: 0, completed: 0, cancelled: 0 } as Record<TodoViewStatus | "total", number>,
|
||||
)
|
||||
}
|
||||
|
||||
function getTodoStatusLabel(status: TodoViewStatus): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "in_progress":
|
||||
return "In progress"
|
||||
case "cancelled":
|
||||
return "Cancelled"
|
||||
default:
|
||||
return "Pending"
|
||||
}
|
||||
}
|
||||
|
||||
interface TodoListViewProps {
|
||||
state?: ToolState
|
||||
emptyLabel?: string
|
||||
showStatusLabel?: boolean
|
||||
}
|
||||
|
||||
export function TodoListView(props: TodoListViewProps) {
|
||||
const todos = extractTodosFromState(props.state)
|
||||
const counts = summarizeTodos(todos)
|
||||
|
||||
if (counts.total === 0) {
|
||||
return <div class="tool-call-todo-empty">{props.emptyLabel ?? "No plan items yet."}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-todo-region">
|
||||
<div class="tool-call-todos" role="list">
|
||||
<For each={todos}>
|
||||
{(todo) => {
|
||||
const label = getTodoStatusLabel(todo.status)
|
||||
return (
|
||||
<div
|
||||
class="tool-call-todo-item"
|
||||
classList={{
|
||||
"tool-call-todo-item-completed": todo.status === "completed",
|
||||
"tool-call-todo-item-cancelled": todo.status === "cancelled",
|
||||
"tool-call-todo-item-active": todo.status === "in_progress",
|
||||
}}
|
||||
role="listitem"
|
||||
>
|
||||
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
|
||||
<div class="tool-call-todo-body">
|
||||
<div class="tool-call-todo-heading">
|
||||
<span class="tool-call-todo-text">{todo.content}</span>
|
||||
<Show when={props.showStatusLabel !== false}>
|
||||
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function getTodoTitle(state?: ToolState): string {
|
||||
if (!state) return "Plan"
|
||||
|
||||
const todos = extractTodosFromState(state)
|
||||
if (state.status !== "completed" || todos.length === 0) return "Plan"
|
||||
|
||||
const counts = summarizeTodos(todos)
|
||||
if (counts.pending === counts.total) return "Creating plan"
|
||||
if (counts.completed === counts.total) return "Completing plan"
|
||||
return "Updating plan"
|
||||
}
|
||||
|
||||
export const todoRenderer: ToolRenderer = {
|
||||
tools: ["todowrite", "todoread"],
|
||||
getAction: () => "Planning...",
|
||||
getTitle({ toolState }) {
|
||||
return getTodoTitle(toolState())
|
||||
},
|
||||
renderBody({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
|
||||
return <TodoListView state={state} />
|
||||
},
|
||||
}
|
||||
33
packages/ui/src/components/tool-call/renderers/webfetch.tsx
Normal file
33
packages/ui/src/components/tool-call/renderers/webfetch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils"
|
||||
|
||||
export const webfetchRenderer: ToolRenderer = {
|
||||
tools: ["webfetch"],
|
||||
getAction: () => "Fetching from the web...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
if (typeof input.url === "string" && input.url.length > 0) {
|
||||
return `${getToolName("webfetch")} ${input.url}`
|
||||
}
|
||||
return getToolName("webfetch")
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const result = formatUnknown(
|
||||
state.status === "completed"
|
||||
? state.output
|
||||
: metadata.output,
|
||||
)
|
||||
if (!result) return null
|
||||
|
||||
const content = ensureMarkdownContent(result.text, result.language, true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
25
packages/ui/src/components/tool-call/renderers/write.tsx
Normal file
25
packages/ui/src/components/tool-call/renderers/write.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
|
||||
|
||||
export const writeRenderer: ToolRenderer = {
|
||||
tools: ["write"],
|
||||
getAction: () => "Preparing write...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||
if (!filePath) return getToolName("write")
|
||||
return `${getToolName("write")} ${getRelativePath(filePath)}`
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
const { metadata, input } = readToolStatePayload(state)
|
||||
const contentValue = typeof input.content === "string" ? input.content : metadata.content
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : undefined
|
||||
const content = ensureMarkdownContent(contentValue ?? null, inferLanguageFromPath(filePath), true)
|
||||
if (!content) return null
|
||||
return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
86
packages/ui/src/components/tool-call/tool-title.ts
Normal file
86
packages/ui/src/components/tool-call/tool-title.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
||||
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
||||
import { defaultRenderer } from "./renderers/default"
|
||||
import { bashRenderer } from "./renderers/bash"
|
||||
import { readRenderer } from "./renderers/read"
|
||||
import { writeRenderer } from "./renderers/write"
|
||||
import { editRenderer } from "./renderers/edit"
|
||||
import { patchRenderer } from "./renderers/patch"
|
||||
import { webfetchRenderer } from "./renderers/webfetch"
|
||||
import { todoRenderer } from "./renderers/todo"
|
||||
import { invalidRenderer } from "./renderers/invalid"
|
||||
|
||||
const TITLE_RENDERERS: Record<string, ToolRenderer> = {
|
||||
bash: bashRenderer,
|
||||
read: readRenderer,
|
||||
write: writeRenderer,
|
||||
edit: editRenderer,
|
||||
patch: patchRenderer,
|
||||
webfetch: webfetchRenderer,
|
||||
todowrite: todoRenderer,
|
||||
todoread: todoRenderer,
|
||||
invalid: invalidRenderer,
|
||||
}
|
||||
|
||||
interface TitleSnapshot {
|
||||
toolName: string
|
||||
state?: ToolState
|
||||
}
|
||||
|
||||
function lookupRenderer(toolName: string): ToolRenderer {
|
||||
return TITLE_RENDERERS[toolName] ?? defaultRenderer
|
||||
}
|
||||
|
||||
function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart {
|
||||
return {
|
||||
id: "",
|
||||
type: "tool",
|
||||
tool: snapshot.toolName,
|
||||
state: snapshot.state,
|
||||
} as ToolCallPart
|
||||
}
|
||||
|
||||
function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
||||
const toolStateAccessor = () => snapshot.state
|
||||
const toolNameAccessor = () => snapshot.toolName
|
||||
const toolCallAccessor = () => createStaticToolPart(snapshot)
|
||||
const messageVersionAccessor = () => undefined
|
||||
const partVersionAccessor = () => undefined
|
||||
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
|
||||
const renderDiff: ToolRendererContext["renderDiff"] = () => null
|
||||
|
||||
return {
|
||||
toolCall: toolCallAccessor,
|
||||
toolState: toolStateAccessor,
|
||||
toolName: toolNameAccessor,
|
||||
messageVersion: messageVersionAccessor,
|
||||
partVersion: partVersionAccessor,
|
||||
renderMarkdown,
|
||||
renderDiff,
|
||||
scrollHelpers: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTitleForTool(snapshot: TitleSnapshot): string {
|
||||
const renderer = lookupRenderer(snapshot.toolName)
|
||||
const context = createStaticContext(snapshot)
|
||||
const state = snapshot.state
|
||||
const defaultAction = renderer.getAction?.(context) ?? getDefaultToolAction(snapshot.toolName)
|
||||
|
||||
if (!state || state.status === "pending") {
|
||||
return defaultAction
|
||||
}
|
||||
|
||||
const stateTitle = typeof (state as { title?: string }).title === "string" ? (state as { title?: string }).title : undefined
|
||||
if (stateTitle && stateTitle.length > 0) {
|
||||
return stateTitle
|
||||
}
|
||||
|
||||
const customTitle = renderer.getTitle?.(context)
|
||||
if (customTitle) {
|
||||
return customTitle
|
||||
}
|
||||
|
||||
return getToolName(snapshot.toolName)
|
||||
}
|
||||
48
packages/ui/src/components/tool-call/types.ts
Normal file
48
packages/ui/src/components/tool-call/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
|
||||
export type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
export interface DiffPayload {
|
||||
diffText: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
export interface MarkdownRenderOptions {
|
||||
content: string
|
||||
size?: "default" | "large"
|
||||
disableHighlight?: boolean
|
||||
}
|
||||
|
||||
export interface DiffRenderOptions {
|
||||
variant?: string
|
||||
disableScrollTracking?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface ToolScrollHelpers {
|
||||
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
||||
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
||||
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
||||
}
|
||||
|
||||
export interface ToolRendererContext {
|
||||
toolCall: Accessor<ToolCallPart>
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
toolName: Accessor<string>
|
||||
messageVersion?: Accessor<number | undefined>
|
||||
partVersion?: Accessor<number | undefined>
|
||||
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
||||
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
}
|
||||
|
||||
export interface ToolRenderer {
|
||||
tools: string[]
|
||||
getTitle?(context: ToolRendererContext): string | undefined
|
||||
getAction?(context: ToolRendererContext): string | undefined
|
||||
renderBody(context: ToolRendererContext): JSXElement | null
|
||||
}
|
||||
|
||||
export type ToolRendererMap = Record<string, ToolRenderer>
|
||||
224
packages/ui/src/components/tool-call/utils.ts
Normal file
224
packages/ui/src/components/tool-call/utils.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { isRenderableDiffText } from "../../lib/diff-utils"
|
||||
import { getLanguageFromPath } from "../../lib/markdown"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { DiffPayload } from "./types"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
export type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||
export type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||
export type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||
|
||||
export const diffCapableTools = new Set(["edit", "patch"])
|
||||
|
||||
export function isToolStateRunning(state: ToolState): state is ToolStateRunning {
|
||||
return state.status === "running"
|
||||
}
|
||||
|
||||
export function isToolStateCompleted(state: ToolState): state is ToolStateCompleted {
|
||||
return state.status === "completed"
|
||||
}
|
||||
|
||||
export function isToolStateError(state: ToolState): state is ToolStateError {
|
||||
return state.status === "error"
|
||||
}
|
||||
|
||||
export function getToolIcon(tool: string): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return "⚡"
|
||||
case "edit":
|
||||
return "✏️"
|
||||
case "read":
|
||||
return "📖"
|
||||
case "write":
|
||||
return "📝"
|
||||
case "glob":
|
||||
return "🔍"
|
||||
case "grep":
|
||||
return "🔎"
|
||||
case "webfetch":
|
||||
return "🌐"
|
||||
case "task":
|
||||
return "🎯"
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "📋"
|
||||
case "list":
|
||||
return "📁"
|
||||
case "patch":
|
||||
return "🔧"
|
||||
default:
|
||||
return "🔧"
|
||||
}
|
||||
}
|
||||
|
||||
export function getToolName(tool: string): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return "Shell"
|
||||
case "webfetch":
|
||||
return "Fetch"
|
||||
case "invalid":
|
||||
return "Invalid"
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Plan"
|
||||
default: {
|
||||
const normalized = tool.replace(/^opencode_/, "")
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getRelativePath(path: string): string {
|
||||
if (!path) return ""
|
||||
const parts = path.split("/")
|
||||
return parts.slice(-1)[0] || path
|
||||
}
|
||||
|
||||
export function ensureMarkdownContent(
|
||||
value: string | null,
|
||||
language?: string,
|
||||
forceFence = false,
|
||||
): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = value.replace(/\s+$/, "")
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startsWithFence = trimmed.trimStart().startsWith("```")
|
||||
if (startsWithFence && !forceFence) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const langSuffix = language ? language : ""
|
||||
if (language || forceFence) {
|
||||
return `\u0060\u0060\u0060${langSuffix}\n${trimmed}\n\u0060\u0060\u0060`
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function formatUnknown(value: unknown): { text: string; language?: string } | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return { text: value }
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return { text: String(value) }
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((item) => {
|
||||
const formatted = formatUnknown(item)
|
||||
return formatted?.text ?? ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { text: parts.join("\n") }
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return { text: JSON.stringify(value, null, 2), language: "json" }
|
||||
} catch (error) {
|
||||
log.error("Failed to stringify tool call output", error)
|
||||
return { text: String(value) }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function inferLanguageFromPath(path?: string): string | undefined {
|
||||
return getLanguageFromPath(path || "")
|
||||
}
|
||||
|
||||
export function extractDiffPayload(toolName: string, state?: ToolState): DiffPayload | null {
|
||||
if (!state) return null
|
||||
if (!diffCapableTools.has(toolName)) return null
|
||||
|
||||
const { metadata, input, output } = readToolStatePayload(state)
|
||||
const candidates = [metadata.diff, output, metadata.output]
|
||||
let diffText: string | null = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && isRenderableDiffText(candidate)) {
|
||||
diffText = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!diffText) {
|
||||
return null
|
||||
}
|
||||
|
||||
const filePath =
|
||||
(typeof input.filePath === "string" ? input.filePath : undefined) ||
|
||||
(typeof metadata.filePath === "string" ? metadata.filePath : undefined) ||
|
||||
(typeof input.path === "string" ? input.path : undefined)
|
||||
|
||||
return { diffText, filePath }
|
||||
}
|
||||
|
||||
export function readToolStatePayload(state?: ToolState): {
|
||||
input: Record<string, any>
|
||||
metadata: Record<string, any>
|
||||
output: unknown
|
||||
} {
|
||||
if (!state) {
|
||||
return { input: {}, metadata: {}, output: undefined }
|
||||
}
|
||||
|
||||
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||
return {
|
||||
input: supportsMetadata ? ((state.input || {}) as Record<string, any>) : {},
|
||||
metadata: supportsMetadata ? ((state.metadata || {}) as Record<string, any>) : {},
|
||||
output: isToolStateCompleted(state) ? state.output : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultToolAction(toolName: string) {
|
||||
switch (toolName) {
|
||||
case "task":
|
||||
return "Delegating..."
|
||||
case "bash":
|
||||
return "Writing command..."
|
||||
case "edit":
|
||||
return "Preparing edit..."
|
||||
case "webfetch":
|
||||
return "Fetching from the web..."
|
||||
case "glob":
|
||||
return "Finding files..."
|
||||
case "grep":
|
||||
return "Searching content..."
|
||||
case "list":
|
||||
return "Listing directory..."
|
||||
case "read":
|
||||
return "Reading file..."
|
||||
case "write":
|
||||
return "Preparing write..."
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning..."
|
||||
case "patch":
|
||||
return "Preparing patch..."
|
||||
default:
|
||||
return "Working..."
|
||||
}
|
||||
}
|
||||
482
packages/ui/src/components/unified-picker.tsx
Normal file
482
packages/ui/src/components/unified-picker.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||
import type { Agent } from "../types/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
const SEARCH_RESULT_LIMIT = 100
|
||||
const SEARCH_DEBOUNCE_MS = 200
|
||||
|
||||
type LoadingState = "idle" | "listing" | "search"
|
||||
|
||||
interface FileItem {
|
||||
path: string
|
||||
relativePath: string
|
||||
added?: number
|
||||
removed?: number
|
||||
isGitFile: boolean
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
function formatDisplayPath(basePath: string, isDirectory: boolean) {
|
||||
if (!isDirectory) {
|
||||
return basePath
|
||||
}
|
||||
const trimmed = basePath.replace(/\/+$/, "")
|
||||
return trimmed.length > 0 ? `${trimmed}/` : "./"
|
||||
}
|
||||
|
||||
function isRootPath(value: string) {
|
||||
return value === "." || value === "./" || value === "/"
|
||||
}
|
||||
|
||||
function normalizeRelativePath(basePath: string, isDirectory: boolean) {
|
||||
if (isRootPath(basePath)) {
|
||||
return "."
|
||||
}
|
||||
const withoutPrefix = basePath.replace(/^\.\/+/, "")
|
||||
if (isDirectory) {
|
||||
const trimmed = withoutPrefix.replace(/\/+$/, "")
|
||||
return trimmed || "."
|
||||
}
|
||||
return withoutPrefix
|
||||
}
|
||||
|
||||
function normalizeQuery(rawQuery: string) {
|
||||
const trimmed = rawQuery.trim()
|
||||
if (!trimmed) {
|
||||
return ""
|
||||
}
|
||||
if (trimmed === "." || trimmed === "./") {
|
||||
return ""
|
||||
}
|
||||
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
function mapEntriesToFileItems(entries: { path: string; type: "file" | "directory" }[]): FileItem[] {
|
||||
return entries.map((entry) => {
|
||||
const isDirectory = entry.type === "directory"
|
||||
return {
|
||||
path: formatDisplayPath(entry.path, isDirectory),
|
||||
relativePath: normalizeRelativePath(entry.path, isDirectory),
|
||||
isDirectory,
|
||||
isGitFile: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem }
|
||||
|
||||
interface UnifiedPickerProps {
|
||||
open: boolean
|
||||
onSelect: (item: PickerItem) => void
|
||||
onClose: () => void
|
||||
agents: Agent[]
|
||||
instanceClient: OpencodeClient | null
|
||||
searchQuery: string
|
||||
textareaRef?: HTMLTextAreaElement
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [loadingState, setLoadingState] = createSignal<LoadingState>("idle")
|
||||
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
||||
const [isInitialized, setIsInitialized] = createSignal(false)
|
||||
const [cachedWorkspaceId, setCachedWorkspaceId] = createSignal<string | null>(null)
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let lastWorkspaceId: string | null = null
|
||||
let lastQuery = ""
|
||||
let inflightWorkspaceId: string | null = null
|
||||
let inflightSnapshotPromise: Promise<FileItem[]> | null = null
|
||||
let activeRequestId = 0
|
||||
let queryDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function resetScrollPosition() {
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef) {
|
||||
scrollContainerRef.scrollTop = 0
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function applyFileResults(nextFiles: FileItem[]) {
|
||||
setFiles(nextFiles)
|
||||
setSelectedIndex(0)
|
||||
resetScrollPosition()
|
||||
}
|
||||
|
||||
async function fetchWorkspaceSnapshot(workspaceId: string): Promise<FileItem[]> {
|
||||
if (inflightWorkspaceId === workspaceId && inflightSnapshotPromise) {
|
||||
return inflightSnapshotPromise
|
||||
}
|
||||
|
||||
inflightWorkspaceId = workspaceId
|
||||
inflightSnapshotPromise = serverApi
|
||||
.listWorkspaceFiles(workspaceId)
|
||||
.then((entries) => mapEntriesToFileItems(entries))
|
||||
.then((snapshot) => {
|
||||
setAllFiles(snapshot)
|
||||
setCachedWorkspaceId(workspaceId)
|
||||
return snapshot
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error(`[UnifiedPicker] Failed to load workspace files:`, error)
|
||||
setAllFiles([])
|
||||
setCachedWorkspaceId(null)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflightWorkspaceId === workspaceId) {
|
||||
inflightWorkspaceId = null
|
||||
inflightSnapshotPromise = null
|
||||
}
|
||||
})
|
||||
|
||||
return inflightSnapshotPromise
|
||||
}
|
||||
|
||||
async function ensureWorkspaceSnapshot(workspaceId: string) {
|
||||
if (cachedWorkspaceId() === workspaceId && allFiles().length > 0) {
|
||||
return allFiles()
|
||||
}
|
||||
|
||||
return fetchWorkspaceSnapshot(workspaceId)
|
||||
}
|
||||
|
||||
async function loadFilesForQuery(rawQuery: string, workspaceId: string) {
|
||||
const normalizedQuery = normalizeQuery(rawQuery)
|
||||
const requestId = ++activeRequestId
|
||||
const hasCachedSnapshot =
|
||||
!normalizedQuery && cachedWorkspaceId() === workspaceId && allFiles().length > 0
|
||||
const mode: LoadingState = normalizedQuery ? "search" : hasCachedSnapshot ? "idle" : "listing"
|
||||
if (mode !== "idle") {
|
||||
setLoadingState(mode)
|
||||
} else {
|
||||
setLoadingState("idle")
|
||||
}
|
||||
|
||||
try {
|
||||
if (!normalizedQuery) {
|
||||
const snapshot = await ensureWorkspaceSnapshot(workspaceId)
|
||||
if (!shouldApplyResults(requestId, workspaceId)) {
|
||||
return
|
||||
}
|
||||
applyFileResults(snapshot)
|
||||
return
|
||||
}
|
||||
|
||||
const results = await serverApi.searchWorkspaceFiles(workspaceId, normalizedQuery, {
|
||||
limit: SEARCH_RESULT_LIMIT,
|
||||
})
|
||||
if (!shouldApplyResults(requestId, workspaceId)) {
|
||||
return
|
||||
}
|
||||
applyFileResults(mapEntriesToFileItems(results))
|
||||
} catch (error) {
|
||||
if (workspaceId === props.workspaceId) {
|
||||
log.error(`[UnifiedPicker] Failed to fetch files:`, error)
|
||||
if (shouldApplyResults(requestId, workspaceId)) {
|
||||
applyFileResults([])
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (shouldFinalizeRequest(requestId, workspaceId)) {
|
||||
setLoadingState("idle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearQueryDebounce() {
|
||||
if (queryDebounceTimer) {
|
||||
clearTimeout(queryDebounceTimer)
|
||||
queryDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLoadFilesForQuery(rawQuery: string, workspaceId: string, immediate = false) {
|
||||
clearQueryDebounce()
|
||||
const normalizedQuery = normalizeQuery(rawQuery)
|
||||
const shouldDebounce = !immediate && normalizedQuery.length > 0
|
||||
if (shouldDebounce) {
|
||||
queryDebounceTimer = setTimeout(() => {
|
||||
queryDebounceTimer = null
|
||||
void loadFilesForQuery(rawQuery, workspaceId)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return
|
||||
}
|
||||
void loadFilesForQuery(rawQuery, workspaceId)
|
||||
}
|
||||
|
||||
function shouldApplyResults(requestId: number, workspaceId: string) {
|
||||
return props.open && workspaceId === props.workspaceId && requestId === activeRequestId
|
||||
}
|
||||
|
||||
|
||||
function shouldFinalizeRequest(requestId: number, workspaceId: string) {
|
||||
return workspaceId === props.workspaceId && requestId === activeRequestId
|
||||
}
|
||||
|
||||
function resetPickerState() {
|
||||
clearQueryDebounce()
|
||||
setFiles([])
|
||||
setAllFiles([])
|
||||
setCachedWorkspaceId(null)
|
||||
setIsInitialized(false)
|
||||
setSelectedIndex(0)
|
||||
setLoadingState("idle")
|
||||
lastWorkspaceId = null
|
||||
lastQuery = ""
|
||||
activeRequestId = 0
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
clearQueryDebounce()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) {
|
||||
resetPickerState()
|
||||
return
|
||||
}
|
||||
|
||||
const workspaceChanged = lastWorkspaceId !== props.workspaceId
|
||||
const queryChanged = lastQuery !== props.searchQuery
|
||||
|
||||
if (!isInitialized() || workspaceChanged || queryChanged) {
|
||||
setIsInitialized(true)
|
||||
lastWorkspaceId = props.workspaceId
|
||||
lastQuery = props.searchQuery
|
||||
const shouldSkipDebounce = workspaceChanged || normalizeQuery(props.searchQuery).length === 0
|
||||
scheduleLoadFilesForQuery(props.searchQuery, props.workspaceId, shouldSkipDebounce)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
|
||||
const query = props.searchQuery.toLowerCase()
|
||||
const filtered = query
|
||||
? props.agents.filter(
|
||||
(agent) =>
|
||||
agent.name.toLowerCase().includes(query) ||
|
||||
(agent.description && agent.description.toLowerCase().includes(query)),
|
||||
)
|
||||
: props.agents
|
||||
|
||||
setFilteredAgents(filtered)
|
||||
})
|
||||
|
||||
const allItems = (): PickerItem[] => {
|
||||
const items: PickerItem[] = []
|
||||
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
|
||||
files().forEach((file) => items.push({ type: "file", file }))
|
||||
return items
|
||||
}
|
||||
|
||||
function scrollToSelected() {
|
||||
setTimeout(() => {
|
||||
const selectedElement = containerRef?.querySelector('[data-picker-selected="true"]')
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function handleSelect(item: PickerItem) {
|
||||
props.onSelect(item)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (!props.open) return
|
||||
|
||||
const items = allItems()
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1))
|
||||
scrollToSelected()
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
scrollToSelected()
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const selected = items[selectedIndex()]
|
||||
if (selected) {
|
||||
handleSelect(selected)
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const agentCount = () => filteredAgents().length
|
||||
const fileCount = () => files().length
|
||||
const isLoading = () => loadingState() !== "idle"
|
||||
const loadingMessage = () => {
|
||||
if (loadingState() === "search") {
|
||||
return "Searching..."
|
||||
}
|
||||
if (loadingState() === "listing") {
|
||||
return "Loading workspace..."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="dropdown-surface bottom-full left-0 mb-1 max-w-md"
|
||||
>
|
||||
<div class="dropdown-header">
|
||||
<div class="dropdown-header-title">
|
||||
Select Agent or File
|
||||
<Show when={isLoading()}>
|
||||
<span class="ml-2">{loadingMessage()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} class="dropdown-content max-h-60">
|
||||
<Show when={agentCount() === 0 && fileCount() === 0}>
|
||||
<div class="dropdown-empty">No results found</div>
|
||||
</Show>
|
||||
|
||||
<Show when={agentCount() > 0}>
|
||||
<div class="dropdown-section-header">
|
||||
AGENTS
|
||||
</div>
|
||||
<For each={filteredAgents()}>
|
||||
{(agent) => {
|
||||
const itemIndex = allItems().findIndex(
|
||||
(item) => item.type === "agent" && item.agent.name === agent.name,
|
||||
)
|
||||
return (
|
||||
<div
|
||||
class={`dropdown-item ${
|
||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||
}`}
|
||||
data-picker-selected={itemIndex === selectedIndex()}
|
||||
onClick={() => handleSelect({ type: "agent", agent })}
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
class="dropdown-icon-accent h-4 w-4 mt-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{agent.name}</span>
|
||||
<Show when={agent.mode === "subagent"}>
|
||||
<span class="dropdown-badge">
|
||||
subagent
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={agent.description}>
|
||||
<div class="mt-0.5 text-xs" style="color: var(--text-muted)">
|
||||
{agent.description && agent.description.length > 80
|
||||
? agent.description.slice(0, 80) + "..."
|
||||
: agent.description}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={fileCount() > 0}>
|
||||
<div class="dropdown-section-header">
|
||||
FILES
|
||||
</div>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const itemIndex = allItems().findIndex(
|
||||
(item) => item.type === "file" && item.file.relativePath === file.relativePath,
|
||||
)
|
||||
const isFolder = file.isDirectory
|
||||
return (
|
||||
<div
|
||||
class={`dropdown-item py-1.5 ${
|
||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||
}`}
|
||||
data-picker-selected={itemIndex === selectedIndex()}
|
||||
onClick={() => handleSelect({ type: "file", file })}
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Show
|
||||
when={isFolder}
|
||||
fallback={
|
||||
<svg class="dropdown-icon h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg class="dropdown-icon-accent h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
<span class="truncate">{file.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-footer">
|
||||
<div>
|
||||
<span class="font-medium">↑↓</span> navigate • <span class="font-medium">Enter</span> select •{" "}
|
||||
<span class="font-medium">Esc</span> close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default UnifiedPicker
|
||||
343
packages/ui/src/components/virtual-item.tsx
Normal file
343
packages/ui/src/components/virtual-item.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
const sizeCache = new Map<string, number>()
|
||||
const DEFAULT_MARGIN_PX = 600
|
||||
const MIN_PLACEHOLDER_HEIGHT = 32
|
||||
const VISIBILITY_BUFFER_PX = 48
|
||||
|
||||
type ObserverRoot = Element | Document | null
|
||||
|
||||
type IntersectionCallback = (entry: IntersectionObserverEntry) => void
|
||||
|
||||
interface SharedObserver {
|
||||
observer: IntersectionObserver
|
||||
listeners: Map<Element, Set<IntersectionCallback>>
|
||||
}
|
||||
|
||||
const NULL_ROOT_KEY = "__null__"
|
||||
const rootIds = new WeakMap<Element | Document, number>()
|
||||
let sharedRootId = 0
|
||||
const sharedObservers = new Map<string, SharedObserver>()
|
||||
|
||||
function getRootKey(root: ObserverRoot, margin: number): string {
|
||||
if (!root) {
|
||||
return `${NULL_ROOT_KEY}:${margin}`
|
||||
}
|
||||
let id = rootIds.get(root)
|
||||
if (id === undefined) {
|
||||
id = ++sharedRootId
|
||||
rootIds.set(root, id)
|
||||
}
|
||||
return `${id}:${margin}`
|
||||
}
|
||||
|
||||
function createSharedObserver(root: ObserverRoot, margin: number): SharedObserver {
|
||||
const listeners = new Map<Element, Set<IntersectionCallback>>()
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const callbacks = listeners.get(entry.target as Element)
|
||||
if (!callbacks) return
|
||||
callbacks.forEach((fn) => fn(entry))
|
||||
})
|
||||
},
|
||||
{
|
||||
root: root ?? undefined,
|
||||
rootMargin: `${margin}px 0px ${margin}px 0px`,
|
||||
},
|
||||
)
|
||||
return { observer, listeners }
|
||||
}
|
||||
|
||||
function shouldRenderEntry(entry: IntersectionObserverEntry) {
|
||||
const rootBounds = entry.rootBounds
|
||||
if (!rootBounds) {
|
||||
return entry.isIntersecting
|
||||
}
|
||||
const distanceAbove = rootBounds.top - entry.boundingClientRect.bottom
|
||||
const distanceBelow = entry.boundingClientRect.top - rootBounds.bottom
|
||||
if (distanceAbove > VISIBILITY_BUFFER_PX || distanceBelow > VISIBILITY_BUFFER_PX) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function subscribeToSharedObserver(
|
||||
target: Element,
|
||||
root: ObserverRoot,
|
||||
margin: number,
|
||||
callback: IntersectionCallback,
|
||||
): () => void {
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
callback({ isIntersecting: true } as IntersectionObserverEntry)
|
||||
return () => {}
|
||||
}
|
||||
const key = getRootKey(root, margin)
|
||||
let shared = sharedObservers.get(key)
|
||||
if (!shared) {
|
||||
shared = createSharedObserver(root, margin)
|
||||
sharedObservers.set(key, shared)
|
||||
}
|
||||
let targetCallbacks = shared.listeners.get(target)
|
||||
if (!targetCallbacks) {
|
||||
targetCallbacks = new Set()
|
||||
shared.listeners.set(target, targetCallbacks)
|
||||
shared.observer.observe(target)
|
||||
}
|
||||
targetCallbacks.add(callback)
|
||||
return () => {
|
||||
const current = shared?.listeners.get(target)
|
||||
if (current) {
|
||||
current.delete(callback)
|
||||
if (current.size === 0) {
|
||||
shared?.listeners.delete(target)
|
||||
shared?.observer.unobserve(target)
|
||||
}
|
||||
}
|
||||
if (shared && shared.listeners.size === 0) {
|
||||
shared.observer.disconnect()
|
||||
sharedObservers.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface VirtualItemProps {
|
||||
cacheKey: string
|
||||
children: JSX.Element
|
||||
scrollContainer?: Accessor<HTMLElement | undefined | null>
|
||||
threshold?: number
|
||||
minPlaceholderHeight?: number
|
||||
class?: string
|
||||
contentClass?: string
|
||||
placeholderClass?: string
|
||||
virtualizationEnabled?: Accessor<boolean>
|
||||
forceVisible?: Accessor<boolean>
|
||||
suspendMeasurements?: Accessor<boolean>
|
||||
onMeasured?: () => void
|
||||
id?: string
|
||||
}
|
||||
|
||||
export default function VirtualItem(props: VirtualItemProps) {
|
||||
const resolved = resolveChildren(() => props.children)
|
||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
||||
const [isIntersecting, setIsIntersecting] = createSignal(true)
|
||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0)
|
||||
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
|
||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||
let pendingVisibility: boolean | null = null
|
||||
let visibilityFrame: number | null = null
|
||||
const flushVisibility = () => {
|
||||
if (visibilityFrame !== null) {
|
||||
cancelAnimationFrame(visibilityFrame)
|
||||
visibilityFrame = null
|
||||
}
|
||||
if (pendingVisibility !== null) {
|
||||
setIsIntersecting(pendingVisibility)
|
||||
pendingVisibility = null
|
||||
}
|
||||
}
|
||||
const queueVisibility = (nextValue: boolean) => {
|
||||
pendingVisibility = nextValue
|
||||
if (visibilityFrame !== null) return
|
||||
visibilityFrame = requestAnimationFrame(() => {
|
||||
visibilityFrame = null
|
||||
if (pendingVisibility !== null) {
|
||||
setIsIntersecting(pendingVisibility)
|
||||
pendingVisibility = null
|
||||
}
|
||||
})
|
||||
}
|
||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||
const shouldHideContent = createMemo(() => {
|
||||
if (props.forceVisible?.()) return false
|
||||
if (!virtualizationEnabled()) return false
|
||||
return !isIntersecting()
|
||||
})
|
||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||
|
||||
let wrapperRef: HTMLDivElement | undefined
|
||||
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let intersectionCleanup: (() => void) | undefined
|
||||
|
||||
function cleanupResizeObserver() {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupIntersectionObserver() {
|
||||
if (intersectionCleanup) {
|
||||
intersectionCleanup()
|
||||
intersectionCleanup = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function persistMeasurement(nextHeight: number) {
|
||||
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
||||
return
|
||||
}
|
||||
const normalized = nextHeight
|
||||
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
|
||||
const shouldKeepPrevious = previous > 0 && (normalized === 0 || (normalized > 0 && normalized < previous))
|
||||
if (shouldKeepPrevious) {
|
||||
if (!hasReportedMeasurement) {
|
||||
hasReportedMeasurement = true
|
||||
props.onMeasured?.()
|
||||
}
|
||||
setHasMeasured(true)
|
||||
sizeCache.set(props.cacheKey, previous)
|
||||
setMeasuredHeight(previous)
|
||||
return
|
||||
}
|
||||
if (normalized > 0) {
|
||||
sizeCache.set(props.cacheKey, normalized)
|
||||
setHasMeasured(true)
|
||||
if (!hasReportedMeasurement) {
|
||||
hasReportedMeasurement = true
|
||||
props.onMeasured?.()
|
||||
}
|
||||
}
|
||||
setMeasuredHeight(normalized)
|
||||
}
|
||||
|
||||
function updateMeasuredHeight() {
|
||||
if (!contentRef || measurementsSuspended()) return
|
||||
const next = contentRef.offsetHeight
|
||||
if (next === measuredHeight()) return
|
||||
persistMeasurement(next)
|
||||
}
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (!contentRef || measurementsSuspended()) return
|
||||
cleanupResizeObserver()
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
updateMeasuredHeight()
|
||||
return
|
||||
}
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (measurementsSuspended()) return
|
||||
updateMeasuredHeight()
|
||||
})
|
||||
resizeObserver.observe(contentRef)
|
||||
}
|
||||
|
||||
|
||||
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
|
||||
cleanupIntersectionObserver()
|
||||
if (!wrapperRef) {
|
||||
setIsIntersecting(true)
|
||||
return
|
||||
}
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setIsIntersecting(true)
|
||||
return
|
||||
}
|
||||
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
||||
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
|
||||
const nextVisible = shouldRenderEntry(entry)
|
||||
queueVisibility(nextVisible)
|
||||
})
|
||||
}
|
||||
|
||||
function setWrapperRef(element: HTMLDivElement | null) {
|
||||
wrapperRef = element ?? undefined
|
||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
||||
refreshIntersectionObserver(root ?? null)
|
||||
}
|
||||
|
||||
function setContentRef(element: HTMLDivElement | null) {
|
||||
contentRef = element ?? undefined
|
||||
if (contentRef) {
|
||||
queueMicrotask(() => {
|
||||
if (shouldHideContent() || measurementsSuspended()) return
|
||||
updateMeasuredHeight()
|
||||
setupResizeObserver()
|
||||
})
|
||||
} else {
|
||||
cleanupResizeObserver()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (shouldHideContent() || measurementsSuspended()) {
|
||||
cleanupResizeObserver()
|
||||
} else if (contentRef) {
|
||||
queueMicrotask(() => {
|
||||
updateMeasuredHeight()
|
||||
setupResizeObserver()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
const key = props.cacheKey
|
||||
|
||||
const cached = sizeCache.get(key)
|
||||
if (cached !== undefined) {
|
||||
setMeasuredHeight(cached)
|
||||
setHasMeasured(true)
|
||||
} else {
|
||||
setMeasuredHeight(0)
|
||||
setHasMeasured(false)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
measurementsSuspended()
|
||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
||||
refreshIntersectionObserver(root ?? null)
|
||||
})
|
||||
|
||||
const placeholderHeight = createMemo(() => {
|
||||
|
||||
const seenHeight = measuredHeight()
|
||||
if (seenHeight > 0) {
|
||||
return seenHeight
|
||||
}
|
||||
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cleanupResizeObserver()
|
||||
cleanupIntersectionObserver()
|
||||
flushVisibility()
|
||||
})
|
||||
|
||||
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
|
||||
const contentClass = () => {
|
||||
const classes = ["virtual-item-content", props.contentClass]
|
||||
if (shouldHideContent()) {
|
||||
classes.push("virtual-item-content-hidden")
|
||||
}
|
||||
return classes.filter(Boolean).join(" ")
|
||||
}
|
||||
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
|
||||
const lazyContent = createMemo<JSX.Element | null>(() => {
|
||||
if (shouldHideContent()) return null
|
||||
return resolved()
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}>
|
||||
<div
|
||||
class={placeholderClass()}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<div ref={setContentRef} class={contentClass()}>
|
||||
{lazyContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
packages/ui/src/images/CodeNomad-Icon.png
Normal file
BIN
packages/ui/src/images/CodeNomad-Icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
packages/ui/src/images/NomadArch-Icon.png
Normal file
BIN
packages/ui/src/images/NomadArch-Icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 471 KiB |
38
packages/ui/src/index.css
Normal file
38
packages/ui/src/index.css
Normal file
@@ -0,0 +1,38 @@
|
||||
@import './styles/tokens.css';
|
||||
@import './styles/utilities.css';
|
||||
@import './styles/controls.css';
|
||||
@import './styles/messaging.css';
|
||||
@import './styles/panels.css';
|
||||
@import './styles/markdown.css';
|
||||
@import './styles/tabs.css';
|
||||
@import './styles/antigravity.css';
|
||||
@import './styles/responsive.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-normal);
|
||||
font-weight: var(--font-weight-regular);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
390
packages/ui/src/lib/__tests__/compaction-schema.test.ts
Normal file
390
packages/ui/src/lib/__tests__/compaction-schema.test.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import {
|
||||
validateStructuredSummary,
|
||||
validateCompactionEvent,
|
||||
validateCompactionResult,
|
||||
sanitizeStructuredSummary,
|
||||
type StructuredSummary,
|
||||
type CompactionEvent,
|
||||
type CompactionResult,
|
||||
} from "../compaction-schema.js"
|
||||
|
||||
describe("compaction schema", () => {
|
||||
describe("validateStructuredSummary", () => {
|
||||
it("validates tierA summary", () => {
|
||||
const summary: StructuredSummary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short",
|
||||
what_was_done: ["Created API endpoint", "Added error handling"],
|
||||
files: [{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" }],
|
||||
current_state: "API endpoint implemented with error handling",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.data.summary_type, "tierA_short")
|
||||
})
|
||||
|
||||
it("validates tierB summary", () => {
|
||||
const summary: StructuredSummary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierB_detailed",
|
||||
what_was_done: ["Created API endpoint", "Added error handling", "Wrote unit tests"],
|
||||
files: [
|
||||
{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" },
|
||||
{ path: "src/api.test.ts", notes: "Test file", decision_id: "decision-2" },
|
||||
],
|
||||
current_state: "API endpoint implemented with error handling and full test coverage",
|
||||
key_decisions: [
|
||||
{
|
||||
id: "decision-1",
|
||||
decision: "Use Fastify for performance",
|
||||
rationale: "Fastify provides better performance than Express",
|
||||
actor: "agent",
|
||||
},
|
||||
],
|
||||
next_steps: ["Add authentication", "Implement rate limiting"],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: ["api", "fastify"],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1500,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.data.summary_type, "tierB_detailed")
|
||||
assert.ok(result.data.key_decisions)
|
||||
assert.equal(result.data.key_decisions.length, 1)
|
||||
})
|
||||
|
||||
it("rejects invalid timestamp", () => {
|
||||
const summary = {
|
||||
timestamp: "invalid-date",
|
||||
summary_type: "tierA_short" as const,
|
||||
what_was_done: ["Created API endpoint"],
|
||||
files: [],
|
||||
current_state: "API endpoint implemented",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(!result.success)
|
||||
assert.ok(result.errors.length > 0)
|
||||
})
|
||||
|
||||
it("rejects empty what_was_done array", () => {
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short" as const,
|
||||
what_was_done: [],
|
||||
files: [],
|
||||
current_state: "API endpoint implemented",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(!result.success)
|
||||
assert.ok(result.errors.some((e) => e.includes("what_was_done")))
|
||||
})
|
||||
|
||||
it("rejects empty current_state", () => {
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short" as const,
|
||||
what_was_done: ["Created API endpoint"],
|
||||
files: [],
|
||||
current_state: "",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(!result.success)
|
||||
assert.ok(result.errors.some((e) => e.includes("current_state")))
|
||||
})
|
||||
|
||||
it("rejects invalid actor in key_decisions", () => {
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short" as const,
|
||||
what_was_done: ["Created API endpoint"],
|
||||
files: [],
|
||||
current_state: "API endpoint implemented",
|
||||
key_decisions: [
|
||||
{
|
||||
id: "decision-1",
|
||||
decision: "Use Fastify",
|
||||
rationale: "Performance",
|
||||
actor: "invalid" as any,
|
||||
},
|
||||
],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(!result.success)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateCompactionEvent", () => {
|
||||
it("validates user-triggered compaction", () => {
|
||||
const event: CompactionEvent = {
|
||||
event_id: "comp_1234567890",
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: "user",
|
||||
trigger_reason: "manual",
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
model_used: "claude-3.5-sonnet",
|
||||
cost_estimate: 0.05,
|
||||
}
|
||||
const result = validateCompactionEvent(event)
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.data.actor, "user")
|
||||
})
|
||||
|
||||
it("validates auto-triggered compaction", () => {
|
||||
const event: CompactionEvent = {
|
||||
event_id: "auto_1234567890",
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: "auto",
|
||||
trigger_reason: "overflow",
|
||||
token_before: 15000,
|
||||
token_after: 5000,
|
||||
model_used: "claude-3.5-sonnet",
|
||||
cost_estimate: 0.07,
|
||||
}
|
||||
const result = validateCompactionEvent(event)
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.data.actor, "auto")
|
||||
assert.equal(result.data.trigger_reason, "overflow")
|
||||
})
|
||||
|
||||
it("rejects negative token values", () => {
|
||||
const event = {
|
||||
event_id: "comp_1234567890",
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: "user" as const,
|
||||
trigger_reason: "manual" as const,
|
||||
token_before: -1000,
|
||||
token_after: 3000,
|
||||
model_used: "claude-3.5-sonnet",
|
||||
cost_estimate: 0.05,
|
||||
}
|
||||
const result = validateCompactionEvent(event)
|
||||
assert.ok(!result.success)
|
||||
})
|
||||
|
||||
it("rejects empty event_id", () => {
|
||||
const event = {
|
||||
event_id: "",
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: "user" as const,
|
||||
trigger_reason: "manual" as const,
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
model_used: "claude-3.5-sonnet",
|
||||
cost_estimate: 0.05,
|
||||
}
|
||||
const result = validateCompactionEvent(event)
|
||||
assert.ok(!result.success)
|
||||
})
|
||||
|
||||
it("rejects invalid actor", () => {
|
||||
const event = {
|
||||
event_id: "comp_1234567890",
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: "invalid" as any,
|
||||
trigger_reason: "manual" as const,
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
model_used: "claude-3.5-sonnet",
|
||||
cost_estimate: 0.05,
|
||||
}
|
||||
const result = validateCompactionEvent(event)
|
||||
assert.ok(!result.success)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateCompactionResult", () => {
|
||||
it("validates successful compaction", () => {
|
||||
const result: CompactionResult = {
|
||||
success: true,
|
||||
mode: "compact",
|
||||
human_summary: "Compacted 100 messages",
|
||||
detailed_summary: {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short",
|
||||
what_was_done: ["Compacted 100 messages"],
|
||||
files: [],
|
||||
current_state: "Session compacted",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
},
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
token_reduction_pct: 70,
|
||||
}
|
||||
const validation = validateCompactionResult(result)
|
||||
assert.ok(validation.success)
|
||||
})
|
||||
|
||||
it("validates failed compaction", () => {
|
||||
const result: CompactionResult = {
|
||||
success: false,
|
||||
mode: "compact",
|
||||
human_summary: "Compaction failed",
|
||||
token_before: 10000,
|
||||
token_after: 10000,
|
||||
token_reduction_pct: 0,
|
||||
}
|
||||
const validation = validateCompactionResult(result)
|
||||
assert.ok(validation.success)
|
||||
assert.equal(validation.data.success, false)
|
||||
})
|
||||
|
||||
it("rejects invalid token reduction percentage", () => {
|
||||
const result = {
|
||||
success: true,
|
||||
mode: "compact" as const,
|
||||
human_summary: "Compacted 100 messages",
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
token_reduction_pct: 150,
|
||||
}
|
||||
const validation = validateCompactionResult(result)
|
||||
assert.ok(!validation.success)
|
||||
})
|
||||
|
||||
it("rejects negative token reduction percentage", () => {
|
||||
const result = {
|
||||
success: true,
|
||||
mode: "compact" as const,
|
||||
human_summary: "Compacted 100 messages",
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
token_reduction_pct: -10,
|
||||
}
|
||||
const validation = validateCompactionResult(result)
|
||||
assert.ok(!validation.success)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sanitizeStructuredSummary", () => {
|
||||
it("sanitizes summary by removing extra fields", () => {
|
||||
const dirtySummary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short" as const,
|
||||
what_was_done: ["Created API endpoint"],
|
||||
files: [],
|
||||
current_state: "API endpoint implemented",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
extraField: "should be removed",
|
||||
anotherExtra: { nested: "data" },
|
||||
}
|
||||
const clean = sanitizeStructuredSummary(dirtySummary)
|
||||
assert.ok(clean)
|
||||
assert.ok(!("extraField" in clean))
|
||||
assert.ok(!("anotherExtra" in clean))
|
||||
assert.equal(clean?.summary_type, "tierA_short")
|
||||
})
|
||||
|
||||
it("preserves all valid fields", () => {
|
||||
const summary: StructuredSummary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short",
|
||||
what_was_done: ["Created API endpoint"],
|
||||
files: [{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" }],
|
||||
current_state: "API endpoint implemented",
|
||||
key_decisions: [],
|
||||
next_steps: ["Add tests"],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: ["api"],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const clean = sanitizeStructuredSummary(summary)
|
||||
assert.ok(clean)
|
||||
assert.equal(clean?.what_was_done.length, 1)
|
||||
assert.ok(clean?.files)
|
||||
assert.equal(clean.files.length, 1)
|
||||
assert.ok(clean?.next_steps)
|
||||
assert.equal(clean.next_steps.length, 1)
|
||||
assert.ok(clean?.tags)
|
||||
assert.equal(clean.tags.length, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
158
packages/ui/src/lib/__tests__/secrets-detector.test.ts
Normal file
158
packages/ui/src/lib/__tests__/secrets-detector.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { redactSecrets, hasSecrets, redactObject } from "../secrets-detector.js"
|
||||
|
||||
describe("secrets detector", () => {
|
||||
describe("redactSecrets", () => {
|
||||
it("redacts API keys", () => {
|
||||
const content = "My API key is sk-1234567890abcdef"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(!result.clean.includes("sk-1234567890abcdef"))
|
||||
})
|
||||
|
||||
it("redacts AWS access keys", () => {
|
||||
const content = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(!result.clean.includes("AKIAIOSFODNN7EXAMPLE"))
|
||||
})
|
||||
|
||||
it("redacts bearer tokens", () => {
|
||||
const content = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(!result.clean.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"))
|
||||
})
|
||||
|
||||
it("redacts GitHub tokens", () => {
|
||||
const content = "github_pat_11AAAAAAAAAAAAAAAAAAAAAA"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(!result.clean.includes("github_pat_11AAAAAAAAAAAAAAAAAAAAAA"))
|
||||
})
|
||||
|
||||
it("redacts npm tokens", () => {
|
||||
const content = "npm_1234567890abcdef1234567890abcdef1234"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(!result.clean.includes("npm_1234567890abcdef1234567890abcdef1234"))
|
||||
})
|
||||
|
||||
it("preserves non-sensitive content", () => {
|
||||
const content = "This is a normal message without any secrets"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.equal(result.clean, content)
|
||||
assert.equal(result.redactions.length, 0)
|
||||
})
|
||||
|
||||
it("handles empty content", () => {
|
||||
const content = ""
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.equal(result.clean, "")
|
||||
assert.equal(result.redactions.length, 0)
|
||||
})
|
||||
|
||||
it("provides redaction reasons", () => {
|
||||
const content = "API key: sk-1234567890abcdef"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(result.redactions[0].reason.length > 0)
|
||||
})
|
||||
|
||||
it("tracks redaction paths", () => {
|
||||
const content = "sk-1234567890abcdef"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.equal(typeof result.redactions[0].path, "string")
|
||||
assert.ok(result.redactions[0].path.length > 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasSecrets", () => {
|
||||
it("detects API keys", () => {
|
||||
const content = "sk-1234567890abcdef"
|
||||
assert.ok(hasSecrets(content))
|
||||
})
|
||||
|
||||
it("detects bearer tokens", () => {
|
||||
const content = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
assert.ok(hasSecrets(content))
|
||||
})
|
||||
|
||||
it("returns false for normal content", () => {
|
||||
const content = "This is a normal message"
|
||||
assert.ok(!hasSecrets(content))
|
||||
})
|
||||
|
||||
it("returns false for empty content", () => {
|
||||
const content = ""
|
||||
assert.ok(!hasSecrets(content))
|
||||
})
|
||||
})
|
||||
|
||||
describe("redactObject", () => {
|
||||
it("redacts secrets in nested objects", () => {
|
||||
const obj = {
|
||||
apiKey: "sk-1234567890abcdef",
|
||||
nested: {
|
||||
token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
},
|
||||
}
|
||||
const result = redactObject(obj, "test")
|
||||
assert.ok(!result.apiKey.includes("sk-1234567890abcdef"))
|
||||
assert.ok(!result.nested.token.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"))
|
||||
})
|
||||
|
||||
it("redacts secrets in arrays", () => {
|
||||
const obj = {
|
||||
messages: [
|
||||
{ content: "Use sk-1234567890abcdef" },
|
||||
{ content: "Normal message" },
|
||||
],
|
||||
}
|
||||
const result = redactObject(obj, "test")
|
||||
assert.ok(!result.messages[0].content.includes("sk-1234567890abcdef"))
|
||||
assert.equal(result.messages[1].content, "Normal message")
|
||||
})
|
||||
|
||||
it("preserves non-sensitive fields", () => {
|
||||
const obj = {
|
||||
name: "John Doe",
|
||||
age: 30,
|
||||
message: "Hello world",
|
||||
}
|
||||
const result = redactObject(obj, "test")
|
||||
assert.equal(result.name, "John Doe")
|
||||
assert.equal(result.age, 30)
|
||||
assert.equal(result.message, "Hello world")
|
||||
})
|
||||
|
||||
it("handles null and undefined values", () => {
|
||||
const obj = {
|
||||
value: null,
|
||||
undefined: undefined,
|
||||
message: "sk-1234567890abcdef",
|
||||
}
|
||||
const result = redactObject(obj, "test")
|
||||
assert.equal(result.value, null)
|
||||
assert.equal(result.undefined, undefined)
|
||||
assert.ok(!result.message.includes("sk-1234567890abcdef"))
|
||||
})
|
||||
|
||||
it("preserves object structure", () => {
|
||||
const obj = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
secret: "sk-1234567890abcdef",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = redactObject(obj, "test")
|
||||
assert.ok(result.level1.level2.level3.secret)
|
||||
assert.ok(!result.level1.level2.level3.secret.includes("sk-1234567890abcdef"))
|
||||
})
|
||||
})
|
||||
})
|
||||
294
packages/ui/src/lib/api-client.ts
Normal file
294
packages/ui/src/lib/api-client.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import type {
|
||||
AppConfig,
|
||||
BinaryCreateRequest,
|
||||
BinaryListResponse,
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
FileSystemEntry,
|
||||
FileSystemListResponse,
|
||||
InstanceData,
|
||||
ServerMeta,
|
||||
SkillCatalogResponse,
|
||||
SkillDetail,
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceDescriptor,
|
||||
WorkspaceFileResponse,
|
||||
WorkspaceFileSearchResponse,
|
||||
|
||||
WorkspaceLogEntry,
|
||||
WorkspaceEventPayload,
|
||||
WorkspaceEventType,
|
||||
WorkspaceGitStatus,
|
||||
WorkspaceExportRequest,
|
||||
WorkspaceExportResponse,
|
||||
WorkspaceImportRequest,
|
||||
WorkspaceImportResponse,
|
||||
WorkspaceMcpConfigRequest,
|
||||
WorkspaceMcpConfigResponse,
|
||||
PortAvailabilityResponse,
|
||||
} from "../../../server/src/api-types"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
|
||||
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
||||
const DEFAULT_BASE = typeof window !== "undefined"
|
||||
? (window.__CODENOMAD_API_BASE__ ??
|
||||
(window.location?.protocol === "file:" ? FALLBACK_API_BASE : (RUNTIME_BASE === "null" || !RUNTIME_BASE || RUNTIME_BASE.startsWith("file:") ? FALLBACK_API_BASE : RUNTIME_BASE)))
|
||||
: FALLBACK_API_BASE
|
||||
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
||||
|
||||
function getApiOrigin(base: string): string {
|
||||
try {
|
||||
if (base.startsWith("http://") || base.startsWith("https://")) {
|
||||
return new URL(base).origin
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return FALLBACK_API_BASE
|
||||
}
|
||||
|
||||
const API_BASE_ORIGIN = getApiOrigin(API_BASE)
|
||||
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
||||
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
|
||||
|
||||
export const CODENOMAD_API_BASE = API_BASE
|
||||
|
||||
function buildEventsUrl(base: string | undefined, path: string): string {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path
|
||||
}
|
||||
|
||||
let effectiveBase = base;
|
||||
if (typeof window !== "undefined" && window.location.protocol === "file:") {
|
||||
if (!effectiveBase || effectiveBase.startsWith("/") || effectiveBase.startsWith("file:")) {
|
||||
effectiveBase = FALLBACK_API_BASE;
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveBase) {
|
||||
const origin = getApiOrigin(effectiveBase)
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||
return `${origin}${normalized}`
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
const httpLogger = getLogger("api")
|
||||
const sseLogger = getLogger("sse")
|
||||
|
||||
function logHttp(message: string, context?: Record<string, unknown>) {
|
||||
if (context) {
|
||||
httpLogger.info(message, context)
|
||||
return
|
||||
}
|
||||
httpLogger.info(message)
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = API_BASE_ORIGIN ? new URL(path, API_BASE_ORIGIN).toString() : path
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
}
|
||||
|
||||
const method = (init?.method ?? "GET").toUpperCase()
|
||||
const startedAt = Date.now()
|
||||
logHttp(`${method} ${path}`)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...init, headers })
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
const duration = Date.now() - startedAt
|
||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: duration })
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await response.json()) as T
|
||||
} catch (error) {
|
||||
logHttp(`${method} ${path} failed`, { durationMs: Date.now() - startedAt, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const serverApi = {
|
||||
getApiBase(): string {
|
||||
return API_BASE_ORIGIN
|
||||
},
|
||||
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
||||
return request<WorkspaceDescriptor[]>("/api/workspaces")
|
||||
},
|
||||
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
|
||||
return request<WorkspaceDescriptor>("/api/workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
fetchServerMeta(): Promise<ServerMeta> {
|
||||
return request<ServerMeta>("/api/meta")
|
||||
},
|
||||
deleteWorkspace(id: string): Promise<void> {
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
listWorkspaceFiles(id: string, relativePath = "."): Promise<FileSystemEntry[]> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
|
||||
},
|
||||
searchWorkspaceFiles(
|
||||
id: string,
|
||||
query: string,
|
||||
opts?: { limit?: number; type?: "file" | "directory" | "all" },
|
||||
): Promise<WorkspaceFileSearchResponse> {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
const params = new URLSearchParams({ q: trimmed })
|
||||
if (opts?.limit) {
|
||||
params.set("limit", String(opts.limit))
|
||||
}
|
||||
if (opts?.type) {
|
||||
params.set("type", opts.type)
|
||||
}
|
||||
return request<WorkspaceFileSearchResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/search?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
readWorkspaceFile(id: string, relativePath: string): Promise<WorkspaceFileResponse> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
return request<WorkspaceFileResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
fetchWorkspaceGitStatus(id: string): Promise<WorkspaceGitStatus> {
|
||||
return request<WorkspaceGitStatus>(`/api/workspaces/${encodeURIComponent(id)}/git/status`)
|
||||
},
|
||||
exportWorkspace(id: string, payload: WorkspaceExportRequest): Promise<WorkspaceExportResponse> {
|
||||
return request<WorkspaceExportResponse>(`/api/workspaces/${encodeURIComponent(id)}/export`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
importWorkspace(payload: WorkspaceImportRequest): Promise<WorkspaceImportResponse> {
|
||||
return request<WorkspaceImportResponse>("/api/workspaces/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
fetchWorkspaceMcpConfig(id: string): Promise<WorkspaceMcpConfigResponse> {
|
||||
return request<WorkspaceMcpConfigResponse>(`/api/workspaces/${encodeURIComponent(id)}/mcp-config`)
|
||||
},
|
||||
updateWorkspaceMcpConfig(id: string, config: WorkspaceMcpConfigRequest["config"]): Promise<WorkspaceMcpConfigResponse> {
|
||||
return request<WorkspaceMcpConfigResponse>(`/api/workspaces/${encodeURIComponent(id)}/mcp-config`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ config }),
|
||||
})
|
||||
},
|
||||
fetchWorkspaceMcpStatus(id: string): Promise<{
|
||||
servers: Record<string, { connected: boolean }>
|
||||
toolCount: number
|
||||
tools: Array<{ name: string; server: string; description: string }>
|
||||
}> {
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}/mcp-status`)
|
||||
},
|
||||
connectWorkspaceMcps(id: string): Promise<{
|
||||
success: boolean
|
||||
servers: Record<string, { connected: boolean }>
|
||||
toolCount: number
|
||||
}> {
|
||||
return request(`/api/workspaces/${encodeURIComponent(id)}/mcp-connect`, { method: "POST" })
|
||||
},
|
||||
|
||||
fetchConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app")
|
||||
},
|
||||
updateConfig(payload: AppConfig): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
listBinaries(): Promise<BinaryListResponse> {
|
||||
return request<BinaryListResponse>("/api/config/binaries")
|
||||
},
|
||||
createBinary(payload: BinaryCreateRequest) {
|
||||
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
|
||||
updateBinary(id: string, updates: BinaryUpdateRequest) {
|
||||
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
},
|
||||
|
||||
deleteBinary(id: string): Promise<void> {
|
||||
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
validateBinary(path: string): Promise<BinaryValidationResult> {
|
||||
return request<BinaryValidationResult>("/api/config/binaries/validate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
})
|
||||
},
|
||||
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (path && path !== ".") {
|
||||
params.set("path", path)
|
||||
}
|
||||
if (options?.includeFiles !== undefined) {
|
||||
params.set("includeFiles", String(options.includeFiles))
|
||||
}
|
||||
const query = params.toString()
|
||||
return request<FileSystemListResponse>(query ? `/api/filesystem?${query}` : "/api/filesystem")
|
||||
},
|
||||
readInstanceData(id: string): Promise<InstanceData> {
|
||||
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
|
||||
},
|
||||
writeInstanceData(id: string, data: InstanceData): Promise<void> {
|
||||
return request(`/api/storage/instances/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
deleteInstanceData(id: string): Promise<void> {
|
||||
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
||||
const source = new EventSource(EVENTS_URL)
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
onEvent(payload)
|
||||
} catch (error) {
|
||||
sseLogger.error("Failed to parse event", error)
|
||||
}
|
||||
}
|
||||
source.onerror = () => {
|
||||
sseLogger.warn("EventSource error, closing stream")
|
||||
onError?.()
|
||||
}
|
||||
return source
|
||||
},
|
||||
fetchSkillsCatalog(): Promise<SkillCatalogResponse> {
|
||||
return request<SkillCatalogResponse>("/api/skills/catalog")
|
||||
},
|
||||
fetchSkillDetail(id: string): Promise<SkillDetail> {
|
||||
const params = new URLSearchParams({ id })
|
||||
return request<SkillDetail>(`/api/skills/detail?${params.toString()}`)
|
||||
},
|
||||
fetchAvailablePort(): Promise<PortAvailabilityResponse> {
|
||||
return request<PortAvailabilityResponse>("/api/ports/available")
|
||||
},
|
||||
}
|
||||
|
||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }
|
||||
61
packages/ui/src/lib/command-utils.ts
Normal file
61
packages/ui/src/lib/command-utils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Command } from "./commands"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
export function commandRequiresArguments(template?: string): boolean {
|
||||
if (!template) return false
|
||||
return /\$(?:\d+|ARGUMENTS)/.test(template)
|
||||
}
|
||||
|
||||
export function promptForCommandArguments(command: SDKCommand): string | null {
|
||||
if (!commandRequiresArguments(command.template)) {
|
||||
return ""
|
||||
}
|
||||
const input = window.prompt(`Arguments for /${command.name}`, "")
|
||||
if (input === null) {
|
||||
return null
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function formatCommandLabel(name: string): string {
|
||||
if (!name) return ""
|
||||
return name.charAt(0).toUpperCase() + name.slice(1)
|
||||
}
|
||||
|
||||
export function buildCustomCommandEntries(instanceId: string, commands: SDKCommand[]): Command[] {
|
||||
return commands.map((cmd) => ({
|
||||
id: `custom:${instanceId}:${cmd.name}`,
|
||||
label: formatCommandLabel(cmd.name),
|
||||
description: cmd.description ?? "Custom command",
|
||||
category: "Custom Commands",
|
||||
keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])],
|
||||
action: async () => {
|
||||
const sessionId = activeSessionId().get(instanceId)
|
||||
if (!sessionId || sessionId === "info") {
|
||||
showAlertDialog("Select a session before running a custom command.", {
|
||||
title: "Session required",
|
||||
variant: "warning",
|
||||
})
|
||||
return
|
||||
}
|
||||
const args = promptForCommandArguments(cmd)
|
||||
if (args === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
|
||||
} catch (error) {
|
||||
log.error("Failed to run custom command", error)
|
||||
showAlertDialog("Failed to run custom command. Check the console for details.", {
|
||||
title: "Command failed",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
68
packages/ui/src/lib/commands.ts
Normal file
68
packages/ui/src/lib/commands.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface KeyboardShortcut {
|
||||
key: string
|
||||
meta?: boolean
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
alt?: boolean
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: string
|
||||
label: string | (() => string)
|
||||
description: string
|
||||
keywords?: string[]
|
||||
shortcut?: KeyboardShortcut
|
||||
action: () => void | Promise<void>
|
||||
category?: string
|
||||
}
|
||||
|
||||
export function createCommandRegistry() {
|
||||
const commands = new Map<string, Command>()
|
||||
|
||||
function register(command: Command) {
|
||||
commands.set(command.id, command)
|
||||
}
|
||||
|
||||
function unregister(id: string) {
|
||||
commands.delete(id)
|
||||
}
|
||||
|
||||
function get(id: string) {
|
||||
return commands.get(id)
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return Array.from(commands.values())
|
||||
}
|
||||
|
||||
function execute(id: string) {
|
||||
const command = commands.get(id)
|
||||
if (command) {
|
||||
return command.action()
|
||||
}
|
||||
}
|
||||
|
||||
function search(query: string) {
|
||||
if (!query) return getAll()
|
||||
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return getAll().filter((cmd) => {
|
||||
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
|
||||
const labelMatch = label.toLowerCase().includes(lowerQuery)
|
||||
const descMatch = cmd.description.toLowerCase().includes(lowerQuery)
|
||||
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(lowerQuery))
|
||||
return labelMatch || descMatch || keywordMatch
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
unregister,
|
||||
get,
|
||||
getAll,
|
||||
execute,
|
||||
search,
|
||||
}
|
||||
}
|
||||
|
||||
export type CommandRegistry = ReturnType<typeof createCommandRegistry>
|
||||
168
packages/ui/src/lib/compaction-schema.ts
Normal file
168
packages/ui/src/lib/compaction-schema.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { z } from "zod"
|
||||
import { getLogger } from "./logger.js"
|
||||
|
||||
const log = getLogger("compaction-schema")
|
||||
|
||||
export const SecretRedactionSchema = z.object({
|
||||
path: z.string(),
|
||||
reason: z.string(),
|
||||
})
|
||||
|
||||
export const ProvenanceSchema = z.object({
|
||||
model: z.string().min(1, "Model name is required"),
|
||||
token_count: z.number().int().nonnegative(),
|
||||
redactions: z.array(SecretRedactionSchema),
|
||||
})
|
||||
|
||||
export const KeyDecisionSchema = z.object({
|
||||
id: z.string().min(1, "Decision ID is required"),
|
||||
decision: z.string().min(1, "Decision is required"),
|
||||
rationale: z.string().min(1, "Rationale is required"),
|
||||
actor: z.enum(["agent", "user"], { errorMap: () => ({ message: "Actor must be 'agent' or 'user'" }) }),
|
||||
})
|
||||
|
||||
export const ArtifactSchema = z.object({
|
||||
type: z.string().min(1, "Artifact type is required"),
|
||||
uri: z.string().min(1, "Artifact URI is required"),
|
||||
notes: z.string(),
|
||||
})
|
||||
|
||||
export const FileReferenceSchema = z.object({
|
||||
path: z.string().min(1, "File path is required"),
|
||||
notes: z.string(),
|
||||
decision_id: z.string().min(1, "Decision ID is required"),
|
||||
})
|
||||
|
||||
export const StructuredSummarySchema = z.object({
|
||||
timestamp: z.string().datetime(),
|
||||
summary_type: z.enum(["tierA_short", "tierB_detailed"]),
|
||||
what_was_done: z.array(z.string()).min(1, "At least one 'what_was_done' entry is required"),
|
||||
files: z.array(FileReferenceSchema).optional(),
|
||||
current_state: z.string().min(1, "Current state is required"),
|
||||
key_decisions: z.array(KeyDecisionSchema).optional(),
|
||||
next_steps: z.array(z.string()).optional(),
|
||||
blockers: z.array(z.string()).optional(),
|
||||
artifacts: z.array(ArtifactSchema).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
provenance: ProvenanceSchema,
|
||||
aggressive: z.boolean(),
|
||||
})
|
||||
|
||||
export const CompactionEventSchema = z.object({
|
||||
event_id: z.string().min(1, "Event ID is required"),
|
||||
timestamp: z.string().datetime(),
|
||||
actor: z.enum(["user", "auto"], { errorMap: () => ({ message: "Actor must be 'user' or 'auto'" }) }),
|
||||
trigger_reason: z.enum(["overflow", "scheduled", "manual"]),
|
||||
token_before: z.number().int().nonnegative(),
|
||||
token_after: z.number().int().nonnegative(),
|
||||
model_used: z.string().min(1, "Model name is required"),
|
||||
cost_estimate: z.number().nonnegative(),
|
||||
snapshot_id: z.string().optional(),
|
||||
})
|
||||
|
||||
export const CompactionConfigSchema = z.object({
|
||||
autoCompactEnabled: z.boolean(),
|
||||
autoCompactThreshold: z.number().int().min(1).max(100),
|
||||
compactPreserveWindow: z.number().int().positive(),
|
||||
pruneReclaimThreshold: z.number().int().positive(),
|
||||
userPreference: z.enum(["auto", "ask", "never"]),
|
||||
undoRetentionWindow: z.number().int().positive(),
|
||||
recentMessagesToKeep: z.number().int().positive().optional(),
|
||||
systemMessagesToKeep: z.number().int().positive().optional(),
|
||||
incrementalChunkSize: z.number().int().positive().optional(),
|
||||
// ADK-style sliding window settings
|
||||
compactionInterval: z.number().int().positive().optional(),
|
||||
overlapSize: z.number().int().nonnegative().optional(),
|
||||
enableAiSummarization: z.boolean().optional(),
|
||||
summaryMaxTokens: z.number().int().positive().optional(),
|
||||
preserveFileOperations: z.boolean().optional(),
|
||||
preserveDecisions: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const CompactionResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
mode: z.enum(["prune", "compact"]),
|
||||
human_summary: z.string().min(1, "Human summary is required"),
|
||||
detailed_summary: StructuredSummarySchema.optional(),
|
||||
token_before: z.number().int().nonnegative(),
|
||||
token_after: z.number().int().nonnegative(),
|
||||
token_reduction_pct: z.number().int().min(0).max(100),
|
||||
compaction_event: CompactionEventSchema.optional(),
|
||||
preview: z.string().optional(),
|
||||
})
|
||||
|
||||
export type SecretRedaction = z.infer<typeof SecretRedactionSchema>
|
||||
export type Provenance = z.infer<typeof ProvenanceSchema>
|
||||
export type KeyDecision = z.infer<typeof KeyDecisionSchema>
|
||||
export type Artifact = z.infer<typeof ArtifactSchema>
|
||||
export type FileReference = z.infer<typeof FileReferenceSchema>
|
||||
export type StructuredSummary = z.infer<typeof StructuredSummarySchema>
|
||||
export type CompactionEvent = z.infer<typeof CompactionEventSchema>
|
||||
export type CompactionConfig = z.infer<typeof CompactionConfigSchema>
|
||||
export type CompactionResult = z.infer<typeof CompactionResultSchema>
|
||||
|
||||
export function validateStructuredSummary(data: unknown): { success: true; data: StructuredSummary } | { success: false; errors: string[] } {
|
||||
const result = StructuredSummarySchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||
return { success: false, errors }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
export function validateCompactionEvent(data: unknown): { success: true; data: CompactionEvent } | { success: false; errors: string[] } {
|
||||
const result = CompactionEventSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||
return { success: false, errors }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
export function validateCompactionResult(data: unknown): { success: true; data: CompactionResult } | { success: false; errors: string[] } {
|
||||
const result = CompactionResultSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||
return { success: false, errors }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
export function validateCompactionConfig(data: unknown): { success: true; data: CompactionConfig } | { success: false; errors: string[] } {
|
||||
const result = CompactionConfigSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||
return { success: false, errors }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
export function sanitizeStructuredSummary(input: unknown): StructuredSummary | null {
|
||||
const result = validateStructuredSummary(input)
|
||||
if (!result.success) {
|
||||
log.warn("Invalid structured summary, using fallback", { errors: result.errors })
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
export function createDefaultStructuredSummary(aggressive: boolean = false): StructuredSummary {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short",
|
||||
what_was_done: ["Session compaction completed"],
|
||||
files: [],
|
||||
current_state: "Session context has been compacted",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "system",
|
||||
token_count: 0,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive,
|
||||
}
|
||||
}
|
||||
83
packages/ui/src/lib/contexts/instance-metadata-context.tsx
Normal file
83
packages/ui/src/lib/contexts/instance-metadata-context.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Component, JSX, createContext, createEffect, createMemo, createSignal, useContext, type Accessor } from "solid-js"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { getInstanceMetadata } from "../../stores/instance-metadata"
|
||||
import { loadInstanceMetadata, hasMetadataLoaded } from "../hooks/use-instance-metadata"
|
||||
|
||||
interface InstanceMetadataContextValue {
|
||||
isLoading: Accessor<boolean>
|
||||
instance: Accessor<Instance>
|
||||
metadata: Accessor<Instance["metadata"] | undefined>
|
||||
refreshMetadata: () => Promise<void>
|
||||
}
|
||||
|
||||
const InstanceMetadataContext = createContext<InstanceMetadataContextValue | null>(null)
|
||||
|
||||
interface InstanceMetadataProviderProps {
|
||||
instance: Instance
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const InstanceMetadataProvider: Component<InstanceMetadataProviderProps> = (props) => {
|
||||
const resolvedInstance = createMemo(() => instances().get(props.instance.id) ?? props.instance)
|
||||
const [isLoading, setIsLoading] = createSignal(true)
|
||||
|
||||
const ensureMetadata = async (force = false) => {
|
||||
const current = resolvedInstance()
|
||||
if (!current) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const cachedMetadata = getInstanceMetadata(current.id) ?? current.metadata
|
||||
if (!force && hasMetadataLoaded(cachedMetadata)) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
await loadInstanceMetadata(current, { force })
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const current = resolvedInstance()
|
||||
if (!current) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tracked = getInstanceMetadata(current.id) ?? current.metadata
|
||||
if (!tracked || !hasMetadataLoaded(tracked)) {
|
||||
void ensureMetadata()
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
||||
const contextValue: InstanceMetadataContextValue = {
|
||||
isLoading,
|
||||
instance: resolvedInstance,
|
||||
metadata: () => getInstanceMetadata(resolvedInstance().id) ?? resolvedInstance().metadata,
|
||||
refreshMetadata: () => ensureMetadata(true),
|
||||
}
|
||||
|
||||
return (
|
||||
<InstanceMetadataContext.Provider value={contextValue}>
|
||||
{props.children}
|
||||
</InstanceMetadataContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useInstanceMetadataContext(): InstanceMetadataContextValue {
|
||||
const ctx = useContext(InstanceMetadataContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useInstanceMetadataContext must be used within InstanceMetadataProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useOptionalInstanceMetadataContext(): InstanceMetadataContextValue | null {
|
||||
return useContext(InstanceMetadataContext)
|
||||
}
|
||||
50
packages/ui/src/lib/diff-utils.ts
Normal file
50
packages/ui/src/lib/diff-utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
const HUNK_PATTERN = /(^|\n)@@/m
|
||||
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
|
||||
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
|
||||
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
|
||||
|
||||
function stripCodeFence(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.startsWith("```")) return trimmed
|
||||
const lines = trimmed.split("\n")
|
||||
if (lines.length < 2) return ""
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (!lastLine.startsWith("```")) return trimmed
|
||||
return lines.slice(1, -1).join("\n")
|
||||
}
|
||||
|
||||
export function normalizeDiffText(raw: string): string {
|
||||
if (!raw) return ""
|
||||
const withoutFence = stripCodeFence(raw.replace(/\r\n/g, "\n"))
|
||||
const lines = withoutFence.split("\n").map((line) => line.replace(/\s+$/u, ""))
|
||||
|
||||
let pendingFilePath: string | null = null
|
||||
const cleanedLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue
|
||||
if (BEGIN_PATCH_PATTERN.test(line)) {
|
||||
continue
|
||||
}
|
||||
const updateMatch = line.match(UPDATE_FILE_PATTERN)
|
||||
if (updateMatch) {
|
||||
pendingFilePath = updateMatch[1]?.trim() || null
|
||||
continue
|
||||
}
|
||||
cleanedLines.push(line)
|
||||
}
|
||||
|
||||
if (pendingFilePath && !FILE_MARKER_PATTERN.test(cleanedLines.join("\n"))) {
|
||||
cleanedLines.unshift(`+++ b/${pendingFilePath}`)
|
||||
cleanedLines.unshift(`--- a/${pendingFilePath}`)
|
||||
}
|
||||
|
||||
return cleanedLines.join("\n").trim()
|
||||
}
|
||||
|
||||
export function isRenderableDiffText(raw?: string | null): raw is string {
|
||||
if (!raw) return false
|
||||
const normalized = normalizeDiffText(raw)
|
||||
if (!normalized) return false
|
||||
return HUNK_PATTERN.test(normalized)
|
||||
}
|
||||
12
packages/ui/src/lib/formatters.ts
Normal file
12
packages/ui/src/lib/formatters.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function formatTokenTotal(value: number): string {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(0)}K`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
126
packages/ui/src/lib/global-cache.ts
Normal file
126
packages/ui/src/lib/global-cache.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
export interface CacheEntryBaseParams {
|
||||
instanceId?: string
|
||||
sessionId?: string
|
||||
scope: string
|
||||
}
|
||||
|
||||
export interface CacheEntryParams extends CacheEntryBaseParams {
|
||||
key: string
|
||||
}
|
||||
|
||||
type CacheValueMap = Map<string, unknown>
|
||||
type CacheScopeMap = Map<string, CacheValueMap>
|
||||
type CacheSessionMap = Map<string, CacheScopeMap>
|
||||
|
||||
const GLOBAL_KEY = "GLOBAL"
|
||||
const cacheStore = new Map<string, CacheSessionMap>()
|
||||
|
||||
function resolveKey(value?: string) {
|
||||
return value && value.length > 0 ? value : GLOBAL_KEY
|
||||
}
|
||||
|
||||
function getScopeValueMap(params: CacheEntryParams, create: boolean): CacheValueMap | undefined {
|
||||
const instanceKey = resolveKey(params.instanceId)
|
||||
const sessionKey = resolveKey(params.sessionId)
|
||||
|
||||
let sessionMap = cacheStore.get(instanceKey)
|
||||
if (!sessionMap) {
|
||||
if (!create) return undefined
|
||||
sessionMap = new Map()
|
||||
cacheStore.set(instanceKey, sessionMap)
|
||||
}
|
||||
|
||||
let scopeMap = sessionMap.get(sessionKey)
|
||||
if (!scopeMap) {
|
||||
if (!create) return undefined
|
||||
scopeMap = new Map()
|
||||
sessionMap.set(sessionKey, scopeMap)
|
||||
}
|
||||
|
||||
let valueMap = scopeMap.get(params.scope)
|
||||
if (!valueMap) {
|
||||
if (!create) return undefined
|
||||
valueMap = new Map()
|
||||
scopeMap.set(params.scope, valueMap)
|
||||
}
|
||||
|
||||
return valueMap
|
||||
}
|
||||
|
||||
function cleanupHierarchy(instanceKey: string, sessionKey: string, scopeKey?: string) {
|
||||
const sessionMap = cacheStore.get(instanceKey)
|
||||
if (!sessionMap) {
|
||||
return
|
||||
}
|
||||
|
||||
const scopeMap = sessionMap.get(sessionKey)
|
||||
if (!scopeMap) {
|
||||
if (sessionMap.size === 0) {
|
||||
cacheStore.delete(instanceKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (scopeKey) {
|
||||
const valueMap = scopeMap.get(scopeKey)
|
||||
if (valueMap && valueMap.size === 0) {
|
||||
scopeMap.delete(scopeKey)
|
||||
}
|
||||
}
|
||||
|
||||
if (scopeMap.size === 0) {
|
||||
sessionMap.delete(sessionKey)
|
||||
}
|
||||
|
||||
if (sessionMap.size === 0) {
|
||||
cacheStore.delete(instanceKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined): void {
|
||||
const instanceKey = resolveKey(params.instanceId)
|
||||
const sessionKey = resolveKey(params.sessionId)
|
||||
|
||||
if (value === undefined) {
|
||||
const existingMap = getScopeValueMap(params, false)
|
||||
existingMap?.delete(params.key)
|
||||
cleanupHierarchy(instanceKey, sessionKey, params.scope)
|
||||
return
|
||||
}
|
||||
|
||||
const scopeEntries = getScopeValueMap(params, true)
|
||||
scopeEntries?.set(params.key, value)
|
||||
}
|
||||
|
||||
export function getCacheEntry<T>(params: CacheEntryParams): T | undefined {
|
||||
const scopeEntries = getScopeValueMap(params, false)
|
||||
return scopeEntries?.get(params.key) as T | undefined
|
||||
}
|
||||
|
||||
export function clearCacheScope(params: CacheEntryBaseParams): void {
|
||||
const instanceKey = resolveKey(params.instanceId)
|
||||
const sessionKey = resolveKey(params.sessionId)
|
||||
const sessionMap = cacheStore.get(instanceKey)
|
||||
if (!sessionMap) return
|
||||
const scopeMap = sessionMap.get(sessionKey)
|
||||
if (!scopeMap) return
|
||||
scopeMap.delete(params.scope)
|
||||
cleanupHierarchy(instanceKey, sessionKey)
|
||||
}
|
||||
|
||||
export function clearCacheForSession(instanceId?: string, sessionId?: string): void {
|
||||
const instanceKey = resolveKey(instanceId)
|
||||
const sessionKey = resolveKey(sessionId)
|
||||
const sessionMap = cacheStore.get(instanceKey)
|
||||
if (!sessionMap) return
|
||||
sessionMap.delete(sessionKey)
|
||||
if (sessionMap.size === 0) {
|
||||
cacheStore.delete(instanceKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCacheForInstance(instanceId?: string): void {
|
||||
const instanceKey = resolveKey(instanceId)
|
||||
cacheStore.delete(instanceKey)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user