fix: restore complete source code and fix launchers

- Copy complete source code packages from original CodeNomad project
- Add root package.json with npm workspace configuration
- Include electron-app, server, ui, tauri-app, and opencode-config packages
- Fix Launch-Windows.bat and Launch-Dev-Windows.bat to work with correct npm scripts
- Fix Launch-Unix.sh to work with correct npm scripts
- Launchers now correctly call npm run dev:electron which launches Electron app
This commit is contained in:
Gemini AI
2025-12-23 12:57:55 +04:00
Unverified
parent f365d64c97
commit b448d11991
249 changed files with 48776 additions and 0 deletions

3
packages/ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.vite/

54
packages/ui/README.md Normal file
View 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.

37
packages/ui/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"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"
},
"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",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
}
}

View 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: {},
},
}

466
packages/ui/src/App.tsx Normal file
View File

@@ -0,0 +1,466 @@
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 { 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,
} 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 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)
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?.()
})
}
}
})
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={!hasInstances()}
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)
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

View File

@@ -0,0 +1,60 @@
import { Component } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import OpenCodeBinarySelector from "./opencode-binary-selector"
import EnvironmentVariablesEditor from "./environment-variables-editor"
interface AdvancedSettingsModalProps {
open: boolean
onClose: () => void
selectedBinary: string
onBinaryChange: (binary: string) => void
isLoading?: boolean
}
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
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-5xl 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="flex-1 overflow-y-auto 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>
<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

View File

@@ -0,0 +1,124 @@
import { Select } from "@kobalte/core/select"
import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import { getLogger } from "../lib/logger"
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 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">
<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>
</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>
</div>
)
}

View 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

View 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

View 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>
)
}

View 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

View 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>
)
}

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,426 @@
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"
const codeNomadLogo = new URL("../images/CodeNomad-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 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)
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)
}
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"}
>
<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={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<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}
/>
</>
)
}
export default FolderSelectionView

View 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

View 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

View 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>
)
}

View File

@@ -0,0 +1,162 @@
import { Component, For, Show, createMemo } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
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) : []
})
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" />
<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>
</div>
)
}
export default InstanceInfo

View 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

View 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

View 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

View 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">Well 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

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View File

@@ -0,0 +1,165 @@
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
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
}
export function Markdown(props: MarkdownProps) {
const [html, setHtml] = createSignal("")
let containerRef: HTMLDivElement | undefined
let latestRequestedText = ""
const notifyRendered = () => {
Promise.resolve().then(() => props.onRendered?.())
}
createEffect(async () => {
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
const localCache = part.renderCache
if (localCache && localCache.text === text && localCache.theme === themeKey) {
setHtml(localCache.html)
notifyRendered()
return
}
const globalCache = markdownRenderCache.get(cacheKey)
if (globalCache && globalCache.text === text) {
setHtml(globalCache.html)
part.renderCache = globalCache
notifyRendered()
return
}
if (!highlightEnabled) {
part.renderCache = undefined
try {
const rendered = await renderMarkdown(text, { suppressHighlight: true })
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) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
}
}
return
}
try {
const rendered = await renderMarkdown(text)
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) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
notifyRendered()
}
}
})
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)
}
}
}
}
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()} />
}

View 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" }} />
</>
)
}

View File

@@ -0,0 +1,722 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
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) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
const block = createMemo<MessageDisplayBlock | null>(() => {
const current = record()
if (!current) return null
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 : ""
const cacheSignature = [
current.id,
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))
let cached = sessionCache.messageItems.get(segmentKey)
if (!cached) {
cached = {
type: "content",
key: segmentKey,
record: current,
parts: pendingParts.slice(),
messageInfo: info,
isQueued,
showAgentMeta: shouldShowAgentMeta,
}
sessionCache.messageItems.set(segmentKey, cached)
} else {
cached.record = current
cached.parts = pendingParts.slice()
cached.messageInfo = info
cached.isQueued = isQueued
cached.showAgentMeta = shouldShowAgentMeta
}
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()} keyed>
{(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>
)
}

View File

@@ -0,0 +1,352 @@
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 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 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 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
}
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(" • ")
}
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>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
</header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div>
</Show>
<Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,129 @@
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) && 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}
/>
</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>
)
}

View 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

View File

@@ -0,0 +1,858 @@
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"
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 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}>
<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>
)
}

View 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

View File

@@ -0,0 +1,139 @@
import { Combobox } from "@kobalte/core/combobox"
import { createEffect, createMemo, createSignal } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
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
}
export default function ModelSelector(props: ModelSelectorProps) {
const instanceProviders = () => providers().get(props.instanceId) || []
const [isOpen, setIsOpen] = createSignal(false)
let triggerRef!: HTMLButtonElement
let searchInputRef!: HTMLInputElement
createEffect(() => {
if (instanceProviders().length === 0) {
fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error))
}
})
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
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">
{itemProps.item.rawValue.name}
</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">
Model: {currentModelValue()?.name ?? "None"}
</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>
)
}

View File

@@ -0,0 +1,356 @@
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } 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")
interface BinaryOption {
path: string
version?: string
lastUsed?: number
isDefault?: 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"))
const binaryOptions = createMemo<BinaryOption[]>(() => [{ path: "opencode", isDefault: true }, ...customBinaries()])
const currentSelectionPath = () => props.selectedBinary || "opencode"
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 }> {
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 === "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>
</div>
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
<For each={binaryOptions()}>
{(binary) => {
const isDefault = binary.isDefault
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 }}
>
<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">
<Check
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
/>
<span class="text-sm font-medium truncate text-primary">{getDisplayName(binary.path)}</span>
</div>
<Show when={!isDefault}>
<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()}>
<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 && 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>
</div>
</div>
</button>
<Show when={!isDefault}>
<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 default OpenCodeBinarySelector

File diff suppressed because it is too large Load Diff

View 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 CodeNomad remotely</h2>
<p class="remote-subtitle">Use the addresses below to open CodeNomad 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>
)
}

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,922 @@
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"])
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 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 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>
)
}

View 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" })
},
}

View 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" })
},
}

View 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" })
},
}

View 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 }

View 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)
},
}

View 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" })
},
}

View 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" })
},
}

View 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>
)
},
}

View 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} />
},
}

View 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" })
},
}

View 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" })
},
}

View 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)
}

View 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>

View 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..."
}
}

View 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

View 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>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

64
packages/ui/src/index.css Normal file
View File

@@ -0,0 +1,64 @@
@import './styles/tokens.css';
@import './styles/utilities.css';
@import './styles/controls.css';
@import './styles/messaging.css';
@import './styles/panels.css';
@import './styles/markdown.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
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);
}
#root {
width: 100vw;
height: 100vh;
background-color: var(--surface-base);
}

View File

@@ -0,0 +1,208 @@
import type {
AppConfig,
BinaryCreateRequest,
BinaryListResponse,
BinaryUpdateRequest,
BinaryValidationResult,
FileSystemEntry,
FileSystemListResponse,
InstanceData,
ServerMeta,
WorkspaceCreateRequest,
WorkspaceDescriptor,
WorkspaceFileResponse,
WorkspaceFileSearchResponse,
WorkspaceLogEntry,
WorkspaceEventPayload,
WorkspaceEventType,
} 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__ ?? RUNTIME_BASE ?? FALLBACK_API_BASE : FALLBACK_API_BASE
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
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
}
if (base) {
const normalized = path.startsWith("/") ? path : `/${path}`
return `${base}${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 ? new URL(path, API_BASE).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 = {
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()}`,
)
},
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
},
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View 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",
})
}
},
}))
}

View 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>

View 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)
}

View 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)
}

View 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()
}

View 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)
}

View File

@@ -0,0 +1,134 @@
import { onMount, onCleanup, type Accessor } from "solid-js"
import { setupTabKeyboardShortcuts } from "../keyboard"
import { registerNavigationShortcuts } from "../shortcuts/navigation"
import { registerInputShortcuts } from "../shortcuts/input"
import { registerAgentShortcuts } from "../shortcuts/agent"
import { registerEscapeShortcut, setEscapeStateChangeHandler } from "../shortcuts/escape"
import { keyboardRegistry } from "../keyboard-registry"
import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
import type { Instance } from "../../types/instance"
import { getLogger } from "../logger"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
const log = getLogger("actions")
interface UseAppLifecycleOptions {
setEscapeInDebounce: (value: boolean) => void
handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
showFolderSelection: Accessor<boolean>
setShowFolderSelection: (value: boolean) => void
getActiveInstance: () => Instance | null
getActiveSessionIdForInstance: () => string | null
}
export function useAppLifecycle(options: UseAppLifecycleOptions) {
onMount(() => {
setEscapeStateChangeHandler(options.setEscapeInDebounce)
setupTabKeyboardShortcuts(
options.handleNewInstanceRequest,
options.handleCloseInstance,
options.handleNewSession,
options.handleCloseSession,
() => {
const instance = options.getActiveInstance()
if (instance) {
showCommandPalette(instance.id)
}
},
)
registerNavigationShortcuts()
registerInputShortcuts(
() => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) textarea.value = ""
},
() => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
textarea?.focus()
},
)
registerAgentShortcuts(
() => {
const instance = options.getActiveInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-model-selector" })
},
() => {
const instance = options.getActiveInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
},
)
registerEscapeShortcut(
() => {
if (options.showFolderSelection()) return true
const instance = options.getActiveInstance()
if (!instance) return false
const sessionId = options.getActiveSessionIdForInstance()
if (!sessionId || sessionId === "info") return false
const sessions = getSessions(instance.id)
const session = sessions.find((s) => s.id === sessionId)
if (!session) return false
return isSessionBusy(instance.id, sessionId)
},
async () => {
if (options.showFolderSelection()) {
options.setShowFolderSelection(false)
return
}
const instance = options.getActiveInstance()
const sessionId = options.getActiveSessionIdForInstance()
if (!instance || !sessionId || sessionId === "info") return
try {
await abortSession(instance.id, sessionId)
log.info("Session aborted successfully", { instanceId: instance.id, sessionId })
} catch (error) {
log.error("Failed to abort session", error)
}
},
() => {
const active = document.activeElement as HTMLElement
active?.blur()
},
() => hideCommandPalette(),
)
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
const isInCombobox = target.closest('[role="combobox"]') !== null
const isInListbox = target.closest('[role="listbox"]') !== null
const isInAgentSelect = target.closest('[role="button"][data-agent-selector]') !== null
if (isInCombobox || isInListbox || isInAgentSelect) {
return
}
const shortcut = keyboardRegistry.findMatch(e)
if (shortcut) {
e.preventDefault()
shortcut.handler()
}
}
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
}

View File

@@ -0,0 +1,534 @@
import { createSignal, onMount } from "solid-js"
import type { Accessor } from "solid-js"
import type { Preferences, ExpansionPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import type { ClientPart, MessageInfo } from "../../types/message"
import {
activeParentSessionId,
activeSessionId as activeSessionMap,
getSessionFamily,
getSessions,
setActiveSession,
} from "../../stores/sessions"
import { setSessionCompactionState } from "../../stores/session-compaction"
import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { cleanupBlankSessions } from "../../stores/session-state"
import { getLogger } from "../logger"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
const log = getLogger("actions")
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
toggleShowTimelineTools: () => void
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
getActiveInstance: () => Instance | null
getActiveSessionIdForInstance: () => string | null
}
function extractUserTextFromRecord(record?: MessageRecord): string | null {
if (!record) return null
const parts = record.partIds
.map((partId) => record.parts[partId]?.data)
.filter((part): part is ClientPart => Boolean(part))
const textParts = parts.filter((part): part is ClientPart & { type: "text"; text: string } => part.type === "text" && typeof (part as any).text === "string")
if (textParts.length === 0) {
return null
}
return textParts.map((part) => (part as any).text as string).join("\n")
}
export function useCommands(options: UseCommandsOptions) {
const commandRegistry = createCommandRegistry()
const [commands, setCommands] = createSignal<Command[]>([])
function refreshCommands() {
setCommands(commandRegistry.getAll())
}
function registerCommands() {
const activeInstance = options.getActiveInstance
const activeSessionIdForInstance = options.getActiveSessionIdForInstance
commandRegistry.register({
id: "new-instance",
label: "New Instance",
description: "Open folder picker to create new instance",
category: "Instance",
keywords: ["folder", "project", "workspace"],
shortcut: { key: "N", meta: true },
action: options.handleNewInstanceRequest,
})
commandRegistry.register({
id: "close-instance",
label: "Close Instance",
description: "Stop current instance's server",
category: "Instance",
keywords: ["stop", "quit", "close"],
shortcut: { key: "W", meta: true },
action: async () => {
const instance = activeInstance()
if (!instance) return
await options.handleCloseInstance(instance.id)
},
})
commandRegistry.register({
id: "instance-next",
label: "Next Instance",
description: "Cycle to next instance tab",
category: "Instance",
keywords: ["switch", "navigate"],
shortcut: { key: "]", meta: true },
action: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveInstanceId(ids[next])
},
})
commandRegistry.register({
id: "instance-prev",
label: "Previous Instance",
description: "Cycle to previous instance tab",
category: "Instance",
keywords: ["switch", "navigate"],
shortcut: { key: "[", meta: true },
action: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveInstanceId(ids[prev])
},
})
commandRegistry.register({
id: "new-session",
label: "New Session",
description: "Create a new parent session",
category: "Session",
keywords: ["create", "start"],
shortcut: { key: "N", meta: true, shift: true },
action: async () => {
const instance = activeInstance()
if (!instance) return
await options.handleNewSession(instance.id)
},
})
commandRegistry.register({
id: "close-session",
label: "Close Session",
description: "Close current parent session",
category: "Session",
keywords: ["close", "stop"],
shortcut: { key: "W", meta: true, shift: true },
action: async () => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
if (!instance || !sessionId || sessionId === "info") return
await options.handleCloseSession(instance.id, sessionId)
},
})
commandRegistry.register({
id: "cleanup-blank-sessions",
label: "Scrub Sessions",
description: "Remove empty sessions, subagent sessions that have completed their primary task, and extraneous forked sessions.",
category: "Session",
keywords: ["cleanup", "blank", "empty", "sessions", "remove", "delete", "scrub"],
action: async () => {
const instance = activeInstance()
if (!instance) return
cleanupBlankSessions(instance.id, undefined, true)
},
})
commandRegistry.register({
id: "switch-to-info",
label: "Instance Info",
description: "Open the instance overview for logs and status",
category: "Instance",
keywords: ["info", "logs", "console", "output"],
shortcut: { key: "L", meta: true, shift: true },
action: () => {
const instance = activeInstance()
if (instance) setActiveSession(instance.id, "info")
},
})
commandRegistry.register({
id: "session-next",
label: "Next Session",
description: "Cycle to next session tab",
category: "Session",
keywords: ["switch", "navigate"],
shortcut: { key: "]", meta: true, shift: true },
action: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const familySessions = getSessionFamily(instanceId, parentId)
const ids = familySessions.map((s) => s.id).concat(["info"])
if (ids.length <= 1) return
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
const next = (current + 1) % ids.length
if (ids[next]) {
setActiveSession(instanceId, ids[next])
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
}
},
})
commandRegistry.register({
id: "session-prev",
label: "Previous Session",
description: "Cycle to previous session tab",
category: "Session",
keywords: ["switch", "navigate"],
shortcut: { key: "[", meta: true, shift: true },
action: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const familySessions = getSessionFamily(instanceId, parentId)
const ids = familySessions.map((s) => s.id).concat(["info"])
if (ids.length <= 1) return
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) {
setActiveSession(instanceId, ids[prev])
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
}
},
})
commandRegistry.register({
id: "compact",
label: "Compact Session",
description: "Summarize and compact the current session",
category: "Session",
keywords: ["/compact", "summarize", "compress"],
action: async () => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
if (!instance || !instance.client || !sessionId || sessionId === "info") return
const sessions = getSessions(instance.id)
const session = sessions.find((s) => s.id === sessionId)
if (!session) return
try {
setSessionCompactionState(instance.id, sessionId, true)
await instance.client.session.summarize({
path: { id: sessionId },
body: {
providerID: session.model.providerId,
modelID: session.model.modelId,
},
})
} catch (error) {
setSessionCompactionState(instance.id, sessionId, false)
log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : "Failed to compact session"
showAlertDialog(`Compact failed: ${message}`, {
title: "Compact failed",
variant: "error",
})
}
},
})
commandRegistry.register({
id: "undo",
label: "Undo Last Message",
description: "Revert the last message",
category: "Session",
keywords: ["/undo", "revert", "undo"],
action: async () => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
if (!instance || !instance.client || !sessionId || sessionId === "info") return
const sessions = getSessions(instance.id)
const session = sessions.find((s) => s.id === sessionId)
if (!session) return
const store = messageStoreBus.getOrCreate(instance.id)
const messageIds = store.getSessionMessageIds(sessionId)
const infoMap = new Map<string, MessageInfo>()
messageIds.forEach((id) => {
const info = store.getMessageInfo(id)
if (info) infoMap.set(id, info)
})
const revertState = store.getSessionRevert(sessionId) ?? session.revert
let after = 0
if (revertState?.messageID) {
const revertInfo = infoMap.get(revertState.messageID) ?? store.getMessageInfo(revertState.messageID)
after = revertInfo?.time?.created || 0
}
let messageID = ""
let restoredText: string | null = null
for (let i = messageIds.length - 1; i >= 0; i--) {
const id = messageIds[i]
const record = store.getMessage(id)
const info = infoMap.get(id) ?? store.getMessageInfo(id)
if (record?.role === "user" && info?.time?.created) {
if (after > 0 && info.time.created >= after) {
continue
}
messageID = id
restoredText = extractUserTextFromRecord(record)
break
}
}
if (!messageID) {
showAlertDialog("Nothing to undo", {
title: "No actions to undo",
variant: "info",
})
return
}
try {
await instance.client.session.revert({
path: { id: sessionId },
body: { messageID },
})
if (!restoredText) {
const fallbackRecord = store.getMessage(messageID)
restoredText = extractUserTextFromRecord(fallbackRecord)
}
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 message", {
title: "Undo failed",
variant: "error",
})
}
},
})
commandRegistry.register({
id: "open-model-selector",
label: "Open Model Selector",
description: "Choose a different model",
category: "Agent & Model",
keywords: ["model", "llm", "ai"],
shortcut: { key: "M", meta: true, shift: true },
action: () => {
const instance = activeInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-model-selector" })
},
})
commandRegistry.register({
id: "open-agent-selector",
label: "Open Agent Selector",
description: "Choose a different agent",
category: "Agent & Model",
keywords: ["agent", "mode"],
shortcut: { key: "A", meta: true, shift: true },
action: () => {
const instance = activeInstance()
if (!instance) return
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
},
})
commandRegistry.register({
id: "clear-input",
label: "Clear Input",
description: "Clear the prompt textarea",
category: "Input & Focus",
keywords: ["clear", "reset"],
shortcut: { key: "K", meta: true },
action: () => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) textarea.value = ""
},
})
commandRegistry.register({
id: "thinking",
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`,
description: "Show/hide AI thinking process",
category: "System",
keywords: ["/thinking", "thinking", "reasoning", "toggle", "show", "hide"],
action: options.toggleShowThinkingBlocks,
})
commandRegistry.register({
id: "timeline-tools",
label: () => `${options.preferences().showTimelineTools ? "Hide" : "Show"} Timeline Tool Calls`,
description: "Toggle tool call entries in the message timeline",
category: "System",
keywords: ["timeline", "tool", "toggle"],
action: options.toggleShowTimelineTools,
})
commandRegistry.register({
id: "thinking-default-visibility",
label: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
return `Thinking Blocks Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
},
description: "Toggle whether thinking blocks start expanded",
category: "System",
keywords: ["/thinking", "thinking", "reasoning", "expand", "collapse", "default"],
action: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setThinkingBlocksExpansion(next)
},
})
commandRegistry.register({
id: "diff-view-split",
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`,
description: "Display tool-call diffs side-by-side",
category: "System",
keywords: ["diff", "split", "view"],
action: () => options.setDiffViewMode("split"),
})
commandRegistry.register({
id: "diff-view-unified",
label: () => `${(options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`,
description: "Display tool-call diffs inline",
category: "System",
keywords: ["diff", "unified", "view"],
action: () => options.setDiffViewMode("unified"),
})
commandRegistry.register({
id: "tool-output-default-visibility",
label: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
return `Tool Outputs Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
},
description: "Toggle default expansion for tool outputs",
category: "System",
keywords: ["tool", "output", "expand", "collapse"],
action: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setToolOutputExpansion(next)
},
})
commandRegistry.register({
id: "diagnostics-default-visibility",
label: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
return `Diagnostics Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
},
description: "Toggle default expansion for diagnostics output",
category: "System",
keywords: ["diagnostics", "expand", "collapse"],
action: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setDiagnosticsExpansion(next)
},
})
commandRegistry.register({
id: "token-usage-visibility",
label: () => {
const visible = options.preferences().showUsageMetrics ?? true
return `Token Usage Display · ${visible ? "Visible" : "Hidden"}`
},
description: "Show or hide token and cost stats for assistant messages",
category: "System",
keywords: ["token", "usage", "cost", "stats"],
action: options.toggleUsageMetrics,
})
commandRegistry.register({
id: "auto-cleanup-blank-sessions",
label: () => {
const enabled = options.preferences().autoCleanupBlankSessions
return `Auto-Cleanup Blank Sessions · ${enabled ? "Enabled" : "Disabled"}`
},
description: "Automatically clean up blank sessions when creating new ones",
category: "System",
keywords: ["auto", "cleanup", "blank", "sessions", "toggle"],
action: options.toggleAutoCleanupBlankSessions,
})
commandRegistry.register({
id: "help",
label: "Show Help",
description: "Display keyboard shortcuts and help",
category: "System",
keywords: ["/help", "shortcuts", "help"],
action: () => {
log.info("Show help modal (not implemented)")
},
})
}
function executeCommand(command: Command) {
try {
const result = command.action?.()
if (result instanceof Promise) {
void result.catch((error) => {
log.error("Command execution failed", error)
})
}
} catch (error) {
log.error("Command execution failed", error)
}
}
onMount(() => {
registerCommands()
refreshCommands()
})
return {
commands,
commandRegistry,
refreshCommands,
executeCommand,
}
}

View File

@@ -0,0 +1,86 @@
import { type Accessor, createMemo } from "solid-js"
import {
type CacheEntryParams,
getCacheEntry,
setCacheEntry,
clearCacheScope,
clearCacheForSession,
clearCacheForInstance,
} from "../global-cache"
/**
* `useGlobalCache` exposes a tiny typed facade over the shared cache helpers.
* Callers can pass raw values or accessors for the cache keys; empty identifiers
* automatically fall back to the global buckets.
*/
export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle {
const resolvedEntry = createMemo<CacheEntryParams>(() => {
const instanceId = normalizeId(resolveValue(params.instanceId))
const sessionId = normalizeId(resolveValue(params.sessionId))
const scope = resolveValue(params.scope)
const key = resolveValue(params.key)
return { instanceId, sessionId, scope, key }
})
const scopeParams = createMemo(() => {
const entry = resolvedEntry()
return { instanceId: entry.instanceId, sessionId: entry.sessionId, scope: entry.scope }
})
const sessionParams = createMemo(() => {
const entry = resolvedEntry()
return { instanceId: entry.instanceId, sessionId: entry.sessionId }
})
return {
get<T>() {
return getCacheEntry<T>(resolvedEntry())
},
set<T>(value: T | undefined) {
setCacheEntry(resolvedEntry(), value)
},
clearScope() {
clearCacheScope(scopeParams())
},
clearSession() {
const params = sessionParams()
clearCacheForSession(params.instanceId, params.sessionId)
},
clearInstance() {
const params = sessionParams()
clearCacheForInstance(params.instanceId)
},
params() {
return resolvedEntry()
},
}
}
function normalizeId(value?: string): string | undefined {
return value && value.length > 0 ? value : undefined
}
function resolveValue<T>(value: MaybeAccessor<T> | undefined): T {
if (typeof value === "function") {
return (value as Accessor<T>)()
}
return value as T
}
type MaybeAccessor<T> = T | Accessor<T>
interface UseGlobalCacheParams {
instanceId?: MaybeAccessor<string | undefined>
sessionId?: MaybeAccessor<string | undefined>
scope: MaybeAccessor<string>
key: MaybeAccessor<string>
}
interface GlobalCacheHandle {
get<T>(): T | undefined
set<T>(value: T | undefined): void
clearScope(): void
clearSession(): void
clearInstance(): void
params(): CacheEntryParams
}

View File

@@ -0,0 +1,71 @@
import type { Instance, RawMcpStatus } from "../../types/instance"
import { fetchLspStatus } from "../../stores/instances"
import { getLogger } from "../../lib/logger"
import { getInstanceMetadata, mergeInstanceMetadata } from "../../stores/instance-metadata"
const log = getLogger("session")
const pendingMetadataRequests = new Set<string>()
function hasMetadataLoaded(metadata?: Instance["metadata"]): boolean {
if (!metadata) return false
return "project" in metadata && "mcpStatus" in metadata && "lspStatus" in metadata
}
export async function loadInstanceMetadata(instance: Instance, options?: { force?: boolean }): Promise<void> {
const client = instance.client
if (!client) {
log.warn("[metadata] Skipping fetch; client missing", { instanceId: instance.id })
return
}
const currentMetadata = getInstanceMetadata(instance.id) ?? instance.metadata
if (!options?.force && hasMetadataLoaded(currentMetadata)) {
return
}
if (pendingMetadataRequests.has(instance.id)) {
return
}
pendingMetadataRequests.add(instance.id)
try {
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
client.project.current(),
client.mcp.status(),
fetchLspStatus(instance.id),
])
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
const updates: Instance["metadata"] = { ...(currentMetadata ?? {}) }
if (projectResult.status === "fulfilled") {
updates.project = project ?? null
}
if (mcpResult.status === "fulfilled") {
updates.mcpStatus = mcpStatus ?? {}
}
if (lspResult.status === "fulfilled") {
updates.lspStatus = lspStatus ?? []
}
if (!updates?.version && instance.binaryVersion) {
updates.version = instance.binaryVersion
}
mergeInstanceMetadata(instance.id, updates)
} catch (error) {
log.error("Failed to load instance metadata", error)
} finally {
pendingMetadataRequests.delete(instance.id)
}
}
export { hasMetadataLoaded }

View File

@@ -0,0 +1,102 @@
import { type Accessor, createMemo } from "solid-js"
import { messageStoreBus } from "../../stores/message-v2/bus"
import type { ScrollSnapshot } from "../../stores/message-v2/types"
interface UseScrollCacheParams {
instanceId: MaybeAccessor<string>
sessionId: MaybeAccessor<string>
scope: MaybeAccessor<string>
}
interface PersistScrollOptions {
atBottomOffset?: number
}
interface RestoreScrollOptions {
behavior?: ScrollBehavior
fallback?: () => void
onApplied?: (snapshot: ScrollSnapshot | undefined) => void
}
interface ScrollCacheHandle {
persist: (element: HTMLElement | null | undefined, options?: PersistScrollOptions) => ScrollSnapshot | undefined
restore: (element: HTMLElement | null | undefined, options?: RestoreScrollOptions) => void
}
const DEFAULT_BOTTOM_OFFSET = 48
/**
* Wraps the message-store scroll snapshot helpers so components can
* persist/restore scroll positions without duplicating requestAnimationFrame
* boilerplate.
*/
export function useScrollCache(params: UseScrollCacheParams): ScrollCacheHandle {
const resolved = createMemo(() => ({
instanceId: resolveValue(params.instanceId),
sessionId: resolveValue(params.sessionId),
scope: resolveValue(params.scope),
}))
const store = createMemo(() => {
const { instanceId } = resolved()
return messageStoreBus.getOrCreate(instanceId)
})
function persist(element: HTMLElement | null | undefined, options?: PersistScrollOptions) {
if (!element) {
return undefined
}
const target = resolved()
if (!target.sessionId) {
return undefined
}
const snapshot: Omit<ScrollSnapshot, "updatedAt"> = {
scrollTop: element.scrollTop,
atBottom: isNearBottom(element, options?.atBottomOffset ?? DEFAULT_BOTTOM_OFFSET),
}
store().setScrollSnapshot(target.sessionId, target.scope, snapshot)
return { ...snapshot, updatedAt: Date.now() }
}
function restore(element: HTMLElement | null | undefined, options?: RestoreScrollOptions) {
const target = resolved()
if (!element || !target.sessionId) {
options?.fallback?.()
options?.onApplied?.(undefined)
return
}
const snapshot = store().getScrollSnapshot(target.sessionId, target.scope)
requestAnimationFrame(() => {
if (!element) {
options?.onApplied?.(snapshot)
return
}
if (!snapshot) {
options?.fallback?.()
options?.onApplied?.(undefined)
return
}
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
const nextTop = snapshot.atBottom ? maxScrollTop : Math.min(snapshot.scrollTop, maxScrollTop)
const behavior = options?.behavior ?? "auto"
element.scrollTo({ top: nextTop, behavior })
options?.onApplied?.(snapshot)
})
}
return { persist, restore }
}
function isNearBottom(element: HTMLElement, offset: number) {
const { scrollTop, scrollHeight, clientHeight } = element
return scrollHeight - (scrollTop + clientHeight) <= offset
}
function resolveValue<T>(value: MaybeAccessor<T>): T {
if (typeof value === "function") {
return (value as Accessor<T>)()
}
return value
}
type MaybeAccessor<T> = T | Accessor<T>

View File

@@ -0,0 +1,73 @@
export interface KeyboardShortcut {
id: string
key: string
modifiers: {
ctrl?: boolean
meta?: boolean
shift?: boolean
alt?: boolean
}
handler: () => void
description: string
context?: "global" | "input" | "messages"
condition?: () => boolean
}
class KeyboardRegistry {
private shortcuts = new Map<string, KeyboardShortcut>()
register(shortcut: KeyboardShortcut) {
this.shortcuts.set(shortcut.id, shortcut)
}
unregister(id: string) {
this.shortcuts.delete(id)
}
get(id: string) {
return this.shortcuts.get(id)
}
findMatch(event: KeyboardEvent): KeyboardShortcut | null {
for (const shortcut of this.shortcuts.values()) {
if (this.matches(event, shortcut)) {
if (shortcut.context === "input" && !this.isInputFocused()) continue
if (shortcut.context === "messages" && this.isInputFocused()) continue
if (shortcut.condition && !shortcut.condition()) continue
return shortcut
}
}
return null
}
private matches(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean {
const shortcutKey = shortcut.key.toLowerCase()
const eventKey = event.key ? event.key.toLowerCase() : ""
const eventCode = event.code ? event.code.toLowerCase() : ""
const keyMatch = eventKey === shortcutKey || eventCode === shortcutKey
const ctrlMatch = event.ctrlKey === (shortcut.modifiers.ctrl ?? false)
const metaMatch = event.metaKey === (shortcut.modifiers.meta ?? false)
const shiftMatch = event.shiftKey === (shortcut.modifiers.shift ?? false)
const altMatch = event.altKey === (shortcut.modifiers.alt ?? false)
return keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch
}
private isInputFocused(): boolean {
const active = document.activeElement
return (
active?.tagName === "TEXTAREA" ||
active?.tagName === "INPUT" ||
(active?.hasAttribute("contenteditable") ?? false)
)
}
getByContext(context: string): KeyboardShortcut[] {
return Array.from(this.shortcuts.values()).filter((s) => !s.context || s.context === context)
}
}
export const keyboardRegistry = new KeyboardRegistry()

View File

@@ -0,0 +1,30 @@
import type { KeyboardShortcut } from "./keyboard-registry"
export const isMac = () => navigator.platform.toLowerCase().includes("mac")
export const modKey = (event?: KeyboardEvent) => {
if (!event) return isMac() ? "metaKey" : "ctrlKey"
return isMac() ? event.metaKey : event.ctrlKey
}
export const modKeyPressed = (event: KeyboardEvent) => {
return isMac() ? event.metaKey : event.ctrlKey
}
export const formatShortcut = (shortcut: KeyboardShortcut): string => {
const parts: string[] = []
if (shortcut.modifiers.ctrl || shortcut.modifiers.meta) {
parts.push(isMac() ? "Cmd" : "Ctrl")
}
if (shortcut.modifiers.shift) {
parts.push("Shift")
}
if (shortcut.modifiers.alt) {
parts.push(isMac() ? "Option" : "Alt")
}
parts.push(shortcut.key.toUpperCase())
return parts.join("+")
}

View File

@@ -0,0 +1,87 @@
import { instances, activeInstanceId, setActiveInstanceId } from "../stores/instances"
import { activeSessionId, setActiveSession, getSessions, activeParentSessionId } from "../stores/sessions"
import { keyboardRegistry } from "./keyboard-registry"
import { isMac } from "./keyboard-utils"
export function setupTabKeyboardShortcuts(
handleNewInstance: () => void,
handleCloseInstance: (instanceId: string) => void,
handleNewSession: (instanceId: string) => void,
handleCloseSession: (instanceId: string, sessionId: string) => void,
handleCommandPalette: () => void,
) {
keyboardRegistry.register({
id: "session-new",
key: "n",
modifiers: {
shift: true,
meta: isMac(),
ctrl: !isMac(),
},
handler: () => {
const instanceId = activeInstanceId()
if (instanceId) void handleNewSession(instanceId)
},
description: "New Session",
context: "global",
})
window.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "p") {
e.preventDefault()
handleCommandPalette()
return
}
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key >= "1" && e.key <= "9") {
e.preventDefault()
const index = parseInt(e.key) - 1
const instanceIds = Array.from(instances().keys())
if (instanceIds[index]) {
setActiveInstanceId(instanceIds[index])
}
}
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key >= "1" && e.key <= "9") {
e.preventDefault()
const instanceId = activeInstanceId()
if (!instanceId) return
const index = parseInt(e.key) - 1
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const sessions = getSessions(instanceId)
const sessionFamily = sessions.filter((s) => s.id === parentId || s.parentId === parentId)
const allTabs = sessionFamily.map((s) => s.id).concat(["logs"])
if (allTabs[index]) {
setActiveSession(instanceId, allTabs[index])
}
}
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "n") {
e.preventDefault()
handleNewInstance()
}
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "w") {
e.preventDefault()
const instanceId = activeInstanceId()
if (instanceId) {
handleCloseInstance(instanceId)
}
}
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "w") {
e.preventDefault()
const instanceId = activeInstanceId()
if (!instanceId) return
const sessionId = activeSessionId().get(instanceId)
if (sessionId && sessionId !== "logs") {
handleCloseSession(instanceId, sessionId)
}
}
})
}

View File

@@ -0,0 +1,151 @@
import debug from "debug"
export type LoggerNamespace = "sse" | "api" | "session" | "actions" | "solo" | "multix-chat"
interface Logger {
log: (...args: unknown[]) => void
info: (...args: unknown[]) => void
warn: (...args: unknown[]) => void
error: (...args: unknown[]) => void
}
export interface NamespaceState {
name: LoggerNamespace
enabled: boolean
}
export interface LoggerControls {
listLoggerNamespaces: () => NamespaceState[]
enableLogger: (namespace: LoggerNamespace) => void
disableLogger: (namespace: LoggerNamespace) => void
enableAllLoggers: () => void
disableAllLoggers: () => void
}
const KNOWN_NAMESPACES: LoggerNamespace[] = ["sse", "api", "session", "actions", "solo", "multix-chat"]
const STORAGE_KEY = "opencode:logger:namespaces"
const namespaceLoggers = new Map<LoggerNamespace, Logger>()
const enabledNamespaces = new Set<LoggerNamespace>()
const rawConsole = typeof globalThis !== "undefined" ? globalThis.console : undefined
function applyEnabledNamespaces(): void {
if (enabledNamespaces.size === 0) {
debug.disable()
} else {
debug.enable(Array.from(enabledNamespaces).join(","))
}
}
function persistEnabledNamespaces(): void {
if (typeof window === "undefined" || !window?.localStorage) return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(enabledNamespaces)))
} catch (error) {
rawConsole?.warn?.("Failed to persist logger namespaces", error)
}
}
function hydrateNamespacesFromStorage(): void {
if (typeof window === "undefined" || !window?.localStorage) return
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
if (!stored) return
const parsed: unknown = JSON.parse(stored)
if (!Array.isArray(parsed)) return
for (const name of parsed) {
if (KNOWN_NAMESPACES.includes(name as LoggerNamespace)) {
enabledNamespaces.add(name as LoggerNamespace)
}
}
} catch (error) {
rawConsole?.warn?.("Failed to hydrate logger namespaces", error)
}
}
hydrateNamespacesFromStorage()
applyEnabledNamespaces()
function buildLogger(namespace: LoggerNamespace): Logger {
const base = debug(namespace)
const baseLogger: (...args: any[]) => void = base
const formatAndLog = (level: string, args: any[]) => {
baseLogger(level, ...args)
}
return {
log: (...args: any[]) => baseLogger(...args),
info: (...args: any[]) => baseLogger(...args),
warn: (...args: any[]) => formatAndLog("[warn]", args),
error: (...args: any[]) => formatAndLog("[error]", args),
}
}
function getLogger(namespace: LoggerNamespace): Logger {
if (!KNOWN_NAMESPACES.includes(namespace)) {
throw new Error(`Unknown logger namespace: ${namespace}`)
}
if (!namespaceLoggers.has(namespace)) {
namespaceLoggers.set(namespace, buildLogger(namespace))
}
return namespaceLoggers.get(namespace)!
}
function listLoggerNamespaces(): NamespaceState[] {
return KNOWN_NAMESPACES.map((name) => ({ name, enabled: enabledNamespaces.has(name) }))
}
function enableLogger(namespace: LoggerNamespace): void {
if (!KNOWN_NAMESPACES.includes(namespace)) {
throw new Error(`Unknown logger namespace: ${namespace}`)
}
if (enabledNamespaces.has(namespace)) return
enabledNamespaces.add(namespace)
persistEnabledNamespaces()
applyEnabledNamespaces()
}
function disableLogger(namespace: LoggerNamespace): void {
if (!KNOWN_NAMESPACES.includes(namespace)) {
throw new Error(`Unknown logger namespace: ${namespace}`)
}
if (!enabledNamespaces.has(namespace)) return
enabledNamespaces.delete(namespace)
persistEnabledNamespaces()
applyEnabledNamespaces()
}
function disableAllLoggers(): void {
enabledNamespaces.clear()
persistEnabledNamespaces()
applyEnabledNamespaces()
}
function enableAllLoggers(): void {
KNOWN_NAMESPACES.forEach((namespace) => enabledNamespaces.add(namespace))
persistEnabledNamespaces()
applyEnabledNamespaces()
}
const loggerControls: LoggerControls = {
listLoggerNamespaces,
enableLogger,
disableLogger,
enableAllLoggers,
disableAllLoggers,
}
function exposeLoggerControls(): void {
if (typeof window === "undefined") return
window.codenomadLogger = loggerControls
}
exposeLoggerControls()
export {
getLogger,
listLoggerNamespaces,
enableLogger,
disableLogger,
enableAllLoggers,
disableAllLoggers,
}

View File

@@ -0,0 +1,375 @@
import { marked } from "marked"
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
import { getLogger } from "./logger"
const log = getLogger("actions")
let highlighter: Highlighter | null = null
let highlighterPromise: Promise<Highlighter> | null = null
let currentTheme: "light" | "dark" = "light"
let isInitialized = false
let highlightSuppressed = false
let rendererSetup = false
const extensionToLanguage: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
sh: "bash",
bash: "bash",
json: "json",
html: "html",
css: "css",
md: "markdown",
yaml: "yaml",
yml: "yaml",
sql: "sql",
rs: "rust",
go: "go",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
h: "cpp",
c: "c",
java: "java",
cs: "csharp",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
}
export function getLanguageFromPath(path?: string | null): string | undefined {
if (!path) return undefined
const ext = path.split(".").pop()?.toLowerCase()
return ext ? extensionToLanguage[ext] : undefined
}
// Track loaded languages and queue for on-demand loading
const loadedLanguages = new Set<string>()
const queuedLanguages = new Set<string>()
const languageLoadQueue: Array<() => Promise<void>> = []
let isQueueRunning = false
// Pub/sub mechanism for language loading notifications
const languageListeners: Array<() => void> = []
export function onLanguagesLoaded(callback: () => void): () => void {
languageListeners.push(callback)
// Return cleanup function
return () => {
const index = languageListeners.indexOf(callback)
if (index > -1) {
languageListeners.splice(index, 1)
}
}
}
function triggerLanguageListeners() {
for (const listener of languageListeners) {
try {
listener()
} catch (error) {
log.error("Error in language listener", error)
}
}
}
async function getOrCreateHighlighter() {
if (highlighter) {
return highlighter
}
if (highlighterPromise) {
return highlighterPromise
}
// Create highlighter with no preloaded languages
highlighterPromise = createHighlighter({
themes: ["github-light", "github-dark"],
langs: [],
})
highlighter = await highlighterPromise
highlighterPromise = null
return highlighter
}
function normalizeLanguageToken(token: string): string {
return token.trim().toLowerCase()
}
function resolveLanguage(token: string): { canonical: string | null; raw: string } {
const normalized = normalizeLanguageToken(token)
// Check if it's a direct key match
if (normalized in bundledLanguages) {
return { canonical: normalized, raw: normalized }
}
// Check aliases
for (const [key, lang] of Object.entries(bundledLanguages)) {
const aliases = (lang as { aliases?: string[] }).aliases
if (aliases?.includes(normalized)) {
return { canonical: key, raw: normalized }
}
}
return { canonical: null, raw: normalized }
}
async function ensureLanguages(content: string) {
if (highlightSuppressed) {
return
}
// Parse code fences to extract language tokens
// Updated regex to capture optional language tokens and handle trailing annotations
const codeBlockRegex = /```[ \t]*([A-Za-z0-9_.+#-]+)?[^`]*?```/g
const foundLanguages = new Set<string>()
let match
while ((match = codeBlockRegex.exec(content)) !== null) {
const langToken = match[1]
if (langToken && langToken.trim()) {
foundLanguages.add(langToken.trim())
}
}
// Queue language loading tasks
for (const token of foundLanguages) {
const { canonical, raw } = resolveLanguage(token)
const langKey = canonical || raw
// Skip "text" and aliases since Shiki handles plain text already
if (langKey === "text" || raw === "text") {
continue
}
// Skip if already loaded or queued
if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) {
continue
}
queuedLanguages.add(langKey)
// Queue the language loading task
languageLoadQueue.push(async () => {
try {
const h = await getOrCreateHighlighter()
await h.loadLanguage(langKey as never)
loadedLanguages.add(langKey)
triggerLanguageListeners()
} catch {
// Quietly ignore errors
} finally {
queuedLanguages.delete(langKey)
}
})
}
// Trigger queue runner if not already running
if (languageLoadQueue.length > 0 && !isQueueRunning) {
runLanguageLoadQueue()
}
}
export function decodeHtmlEntities(content: string): string {
if (!content.includes("&")) {
return content
}
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
const namedEntities: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
}
let result = content
let previous = ""
while (result.includes("&") && result !== previous) {
previous = result
result = result.replace(entityPattern, (match, entity) => {
if (!entity) {
return match
}
if (entity[0] === "#") {
const isHex = entity[1]?.toLowerCase() === "x"
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
if (!Number.isNaN(value)) {
try {
return String.fromCodePoint(value)
} catch {
return match
}
}
return match
}
const decoded = namedEntities[entity.toLowerCase()]
return decoded !== undefined ? decoded : match
})
}
return result
}
async function runLanguageLoadQueue() {
if (isQueueRunning || languageLoadQueue.length === 0) {
return
}
isQueueRunning = true
while (languageLoadQueue.length > 0) {
const task = languageLoadQueue.shift()
if (task) {
await task()
}
}
isQueueRunning = false
}
function setupRenderer(isDark: boolean) {
if (!highlighter || rendererSetup) return
currentTheme = isDark ? "dark" : "light"
marked.setOptions({
breaks: true,
gfm: true,
})
const renderer = new marked.Renderer()
renderer.code = (code: string, lang: string | undefined) => {
const decodedCode = decodeHtmlEntities(code)
const encodedCode = encodeURIComponent(decodedCode)
// Use "text" as default when no language is specified
const resolvedLang = lang && lang.trim() ? lang.trim() : "text"
const escapedLang = escapeHtml(resolvedLang)
const header = `
<div class="code-block-header">
<span class="code-block-language">${escapedLang}</span>
<button class="code-block-copy" data-code="${encodedCode}">
<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">Copy</span>
</button>
</div>
`.trim()
if (highlightSuppressed) {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`
}
// Skip highlighting for "text" language or when highlighter is not available
if (resolvedLang === "text" || !highlighter) {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code>${escapeHtml(decodedCode)}</code></pre></div>`
}
// Resolve language and check if it's loaded
const { canonical, raw } = resolveLanguage(resolvedLang)
const langKey = canonical || raw
// Skip highlighting for "text" aliases
if (langKey === "text" || raw === "text") {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`
}
// Use highlighting if language is loaded, otherwise fall back to plain code
if (loadedLanguages.has(langKey)) {
try {
const html = highlighter!.codeToHtml(decodedCode, {
lang: langKey,
theme: currentTheme === "dark" ? "github-dark" : "github-light",
})
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>`
} catch {
// Fall through to plain code if highlighting fails
}
}
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`
}
renderer.link = (href: string, title: string | null | undefined, text: string) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
}
renderer.codespan = (code: string) => {
const decoded = decodeHtmlEntities(code)
return `<code class="inline-code">${escapeHtml(decoded)}</code>`
}
marked.use({ renderer })
rendererSetup = true
}
export async function initMarkdown(isDark: boolean) {
await getOrCreateHighlighter()
setupRenderer(isDark)
isInitialized = true
}
export function isMarkdownReady(): boolean {
return isInitialized && highlighter !== null
}
export async function renderMarkdown(
content: string,
options?: {
suppressHighlight?: boolean
},
): Promise<string> {
if (!isInitialized) {
await initMarkdown(currentTheme === "dark")
}
const suppressHighlight = options?.suppressHighlight ?? false
const decoded = decodeHtmlEntities(content)
if (!suppressHighlight) {
// Queue language loading but don't wait for it to complete
await ensureLanguages(decoded)
}
const previousSuppressed = highlightSuppressed
highlightSuppressed = suppressHighlight
try {
// Proceed to parse immediately - highlighting will be available on next render
return marked.parse(decoded) as Promise<string>
} finally {
highlightSuppressed = previousSuppressed
}
}
export async function getSharedHighlighter(): Promise<Highlighter> {
return getOrCreateHighlighter()
}
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<"']/g, (m) => map[m])
}

View File

@@ -0,0 +1,31 @@
import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger"
const log = getLogger("actions")
export async function restartCli(): Promise<boolean> {
try {
if (runtimeEnv.host === "electron") {
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
if (api?.restartCli) {
await api.restartCli()
return true
}
return false
}
if (runtimeEnv.host === "tauri") {
const tauri = (window as typeof window & { __TAURI__?: { invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> } }).__TAURI__
if (tauri?.invoke) {
await tauri.invoke("cli_restart")
return true
}
return false
}
} catch (error) {
log.error("Failed to restart CLI", error)
return false
}
return false
}

View File

@@ -0,0 +1,42 @@
import type { NativeDialogOptions } from "../native-functions"
import { getLogger } from "../../logger"
const log = getLogger("actions")
interface ElectronDialogResult {
canceled?: boolean
paths?: string[]
path?: string | null
}
interface ElectronAPI {
openDialog?: (options: NativeDialogOptions) => Promise<ElectronDialogResult>
}
function coerceFirstPath(result?: ElectronDialogResult | null): string | null {
if (!result || result.canceled) {
return null
}
const paths = Array.isArray(result.paths) ? result.paths : result.path ? [result.path] : []
if (paths.length === 0) {
return null
}
return paths[0] ?? null
}
export async function openElectronNativeDialog(options: NativeDialogOptions): Promise<string | null> {
if (typeof window === "undefined") {
return null
}
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
if (!api?.openDialog) {
return null
}
try {
const result = await api.openDialog(options)
return coerceFirstPath(result)
} catch (error) {
log.error("[native] electron dialog failed", error)
return null
}
}

View File

@@ -0,0 +1,37 @@
import { runtimeEnv } from "../runtime-env"
import type { NativeDialogOptions } from "./types"
import { openElectronNativeDialog } from "./electron/functions"
import { openTauriNativeDialog } from "./tauri/functions"
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
switch (runtimeEnv.host) {
case "electron":
return openElectronNativeDialog
case "tauri":
return openTauriNativeDialog
default:
return null
}
}
export function supportsNativeDialogs(): boolean {
return resolveNativeHandler() !== null
}
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
const handler = resolveNativeHandler()
if (!handler) {
return null
}
return handler(options)
}
export async function openNativeFolderDialog(options?: Omit<NativeDialogOptions, "mode">): Promise<string | null> {
return openNativeDialog({ mode: "directory", ...(options ?? {}) })
}
export async function openNativeFileDialog(options?: Omit<NativeDialogOptions, "mode">): Promise<string | null> {
return openNativeDialog({ mode: "file", ...(options ?? {}) })
}

View File

@@ -0,0 +1,58 @@
import type { NativeDialogOptions } from "../native-functions"
import { getLogger } from "../../logger"
const log = getLogger("actions")
interface TauriDialogModule {
open?: (
options: {
title?: string
defaultPath?: string
filters?: { name?: string; extensions: string[] }[]
directory?: boolean
multiple?: boolean
},
) => Promise<string | string[] | null>
}
interface TauriBridge {
dialog?: TauriDialogModule
}
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
if (typeof window === "undefined") {
return null
}
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
const dialogApi = tauriBridge?.dialog
if (!dialogApi?.open) {
return null
}
try {
const response = await dialogApi.open({
title: options.title,
defaultPath: options.defaultPath,
directory: options.mode === "directory",
multiple: false,
filters: options.filters?.map((filter) => ({
name: filter.name,
extensions: filter.extensions,
})),
})
if (!response) {
return null
}
if (Array.isArray(response)) {
return response[0] ?? null
}
return response
} catch (error) {
log.error("[native] tauri dialog failed", error)
return null
}
}

View File

@@ -0,0 +1,13 @@
export type NativeDialogMode = "directory" | "file"
export interface NativeDialogFilter {
name?: string
extensions: string[]
}
export interface NativeDialogOptions {
mode: NativeDialogMode
title?: string
defaultPath?: string
filters?: NativeDialogFilter[]
}

View File

@@ -0,0 +1,99 @@
import toast from "solid-toast"
export type ToastVariant = "info" | "success" | "warning" | "error"
export type ToastHandle = {
id: string
dismiss: () => void
}
type ToastPosition = "top-left" | "top-right" | "top-center" | "bottom-left" | "bottom-right" | "bottom-center"
export type ToastPayload = {
title?: string
message: string
variant: ToastVariant
duration?: number
position?: ToastPosition
action?: {
label: string
href: string
}
}
const variantAccent: Record<
ToastVariant,
{
badge: string
container: string
headline: string
body: string
}
> = {
info: {
badge: "bg-sky-500/40",
container: "bg-slate-900/95 border-slate-700 text-slate-100",
headline: "text-slate-50",
body: "text-slate-200/80",
},
success: {
badge: "bg-emerald-500/40",
container: "bg-emerald-950/90 border-emerald-800 text-emerald-50",
headline: "text-emerald-50",
body: "text-emerald-100/80",
},
warning: {
badge: "bg-amber-500/40",
container: "bg-amber-950/90 border-amber-800 text-amber-50",
headline: "text-amber-50",
body: "text-amber-100/80",
},
error: {
badge: "bg-rose-500/40",
container: "bg-rose-950/90 border-rose-800 text-rose-50",
headline: "text-rose-50",
body: "text-rose-100/80",
},
}
export function showToastNotification(payload: ToastPayload): ToastHandle {
const accent = variantAccent[payload.variant]
const duration = payload.duration ?? 10000
const id = toast.custom(
() => (
<div class={`pointer-events-auto w-[320px] max-w-[360px] rounded-lg border px-4 py-3 shadow-xl ${accent.container}`}>
<div class="flex items-start gap-3">
<span class={`mt-1 inline-block h-2.5 w-2.5 rounded-full ${accent.badge}`} />
<div class="flex-1 text-sm leading-snug">
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>}
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p>
{payload.action && (
<a
class="mt-3 inline-flex items-center text-xs font-semibold uppercase tracking-wide text-sky-300 hover:text-sky-200"
href={payload.action.href}
target="_blank"
rel="noreferrer noopener"
>
{payload.action.label}
</a>
)}
</div>
</div>
</div>
),
{
duration,
position: payload.position ?? "top-right",
ariaProps: {
role: "status",
"aria-live": "polite",
},
},
)
return {
id,
dismiss: () => toast.dismiss(id),
}
}

View File

@@ -0,0 +1,36 @@
import type { Attachment } from "../types/attachment"
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
if (!prompt || !prompt.includes("[pasted #")) {
return prompt
}
if (!attachments || attachments.length === 0) {
return prompt
}
const lookup = new Map<string, string>()
for (const attachment of attachments) {
const source = attachment?.source
if (!source || source.type !== "text") continue
const display = attachment?.display
const value = source.value
if (typeof display !== "string" || typeof value !== "string") continue
const match = display.match(/pasted #(\d+)/)
if (!match) continue
const placeholder = `[pasted #${match[1]}]`
if (!lookup.has(placeholder)) {
lookup.set(placeholder, value)
}
}
if (lookup.size === 0) {
return prompt
}
return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
const replacement = lookup.get(fullMatch)
return typeof replacement === "string" ? replacement : fullMatch
})
}

View File

@@ -0,0 +1,89 @@
import { getLogger } from "./logger"
export type HostRuntime = "electron" | "tauri" | "web"
export type PlatformKind = "desktop" | "mobile"
export interface RuntimeEnvironment {
host: HostRuntime
platform: PlatformKind
}
declare global {
interface Window {
electronAPI?: unknown
__TAURI__?: {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
dialog?: {
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
save?: (options: Record<string, unknown>) => Promise<string | null>
}
}
}
}
function detectHost(): HostRuntime {
if (typeof window === "undefined") {
return "web"
}
const win = window as Window & { electronAPI?: unknown }
if (typeof win.electronAPI !== "undefined") {
return "electron"
}
if (typeof win.__TAURI__ !== "undefined") {
return "tauri"
}
if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) {
return "tauri"
}
return "web"
}
function detectPlatform(): PlatformKind {
if (typeof navigator === "undefined") {
return "desktop"
}
const uaData = (navigator as any).userAgentData
if (uaData?.mobile) {
return "mobile"
}
const ua = navigator.userAgent.toLowerCase()
if (/android|iphone|ipad|ipod|blackberry|mini|windows phone|mobile|silk/.test(ua)) {
return "mobile"
}
return "desktop"
}
const log = getLogger("actions")
let cachedEnv: RuntimeEnvironment | null = null
export function detectRuntimeEnvironment(): RuntimeEnvironment {
if (cachedEnv) {
return cachedEnv
}
cachedEnv = {
host: detectHost(),
platform: detectPlatform(),
}
if (typeof window !== "undefined") {
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`)
}
return cachedEnv
}
export const runtimeEnv = detectRuntimeEnvironment()
export const isElectronHost = () => runtimeEnv.host === "electron"
export const isTauriHost = () => runtimeEnv.host === "tauri"
export const isWebHost = () => runtimeEnv.host === "web"
export const isMobilePlatform = () => runtimeEnv.platform === "mobile"

View File

@@ -0,0 +1,47 @@
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client"
import { CODENOMAD_API_BASE } from "./api-client"
class SDKManager {
private clients = new Map<string, OpencodeClient>()
createClient(instanceId: string, proxyPath: string): OpencodeClient {
if (this.clients.has(instanceId)) {
return this.clients.get(instanceId)!
}
const baseUrl = buildInstanceBaseUrl(proxyPath)
const client = createOpencodeClient({ baseUrl })
this.clients.set(instanceId, client)
return client
}
getClient(instanceId: string): OpencodeClient | null {
return this.clients.get(instanceId) ?? null
}
destroyClient(instanceId: string): void {
this.clients.delete(instanceId)
}
destroyAll(): void {
this.clients.clear()
}
}
function buildInstanceBaseUrl(proxyPath: string): string {
const normalized = normalizeProxyPath(proxyPath)
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
return `${base}${normalized}/`
}
function normalizeProxyPath(proxyPath: string): string {
const withLeading = proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}`
return withLeading.replace(/\/+/g, "/").replace(/\/+$/, "")
}
function stripTrailingSlashes(input: string): string {
return input.replace(/\/+$/, "")
}
export const sdkManager = new SDKManager()

View File

@@ -0,0 +1,66 @@
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
import { serverApi } from "./api-client"
import { getLogger } from "./logger"
const RETRY_BASE_DELAY = 1000
const RETRY_MAX_DELAY = 10000
const log = getLogger("sse")
function logSse(message: string, context?: Record<string, unknown>) {
if (context) {
log.info(message, context)
return
}
log.info(message)
}
class ServerEvents {
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
private source: EventSource | null = null
private retryDelay = RETRY_BASE_DELAY
constructor() {
this.connect()
}
private connect() {
if (this.source) {
this.source.close()
}
logSse("Connecting to backend events stream")
this.source = serverApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
this.source.onopen = () => {
logSse("Events stream connected")
this.retryDelay = RETRY_BASE_DELAY
}
}
private scheduleReconnect() {
if (this.source) {
this.source.close()
this.source = null
}
logSse("Events stream disconnected, scheduling reconnect", { delayMs: this.retryDelay })
setTimeout(() => {
this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY)
this.connect()
}, this.retryDelay)
}
private dispatch(event: WorkspaceEventPayload) {
logSse(`event ${event.type}`)
this.handlers.get("*")?.forEach((handler) => handler(event))
this.handlers.get(event.type)?.forEach((handler) => handler(event))
}
on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set())
}
const bucket = this.handlers.get(type)!
bucket.add(handler)
return () => bucket.delete(handler)
}
}
export const serverEvents = new ServerEvents()

View File

@@ -0,0 +1,20 @@
import type { ServerMeta } from "../../../server/src/api-types"
import { serverApi } from "./api-client"
let cachedMeta: ServerMeta | null = null
let pendingMeta: Promise<ServerMeta> | null = null
export async function getServerMeta(forceRefresh = false): Promise<ServerMeta> {
if (cachedMeta && !forceRefresh) {
return cachedMeta
}
if (pendingMeta) {
return pendingMeta
}
pendingMeta = serverApi.fetchServerMeta().then((meta) => {
cachedMeta = meta
pendingMeta = null
return meta
})
return pendingMeta
}

View File

@@ -0,0 +1,13 @@
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
export interface SessionSidebarRequestDetail {
instanceId: string
action: SessionSidebarRequestAction
}
export const SESSION_SIDEBAR_EVENT = "opencode:session-sidebar-request"
export function emitSessionSidebarRequest(detail: SessionSidebarRequestDetail) {
if (typeof window === "undefined") return
window.dispatchEvent(new CustomEvent<SessionSidebarRequestDetail>(SESSION_SIDEBAR_EVENT, { detail }))
}

View File

@@ -0,0 +1,23 @@
import { keyboardRegistry } from "../keyboard-registry"
export function registerAgentShortcuts(focusModelSelector: () => void, openAgentSelector: () => void) {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
keyboardRegistry.register({
id: "focus-model",
key: "M",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: focusModelSelector,
description: "focus model",
context: "global",
})
keyboardRegistry.register({
id: "open-agent-selector",
key: "A",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: openAgentSelector,
description: "open agent",
context: "global",
})
}

View File

@@ -0,0 +1,67 @@
import { keyboardRegistry } from "../keyboard-registry"
type EscapeKeyState = "idle" | "firstPress"
const ESCAPE_DEBOUNCE_TIMEOUT = 1000
let escapeKeyState: EscapeKeyState = "idle"
let escapeTimeoutId: number | null = null
let onEscapeStateChange: ((inDebounce: boolean) => void) | null = null
export function setEscapeStateChangeHandler(handler: (inDebounce: boolean) => void) {
onEscapeStateChange = handler
}
function resetEscapeState() {
escapeKeyState = "idle"
if (escapeTimeoutId !== null) {
clearTimeout(escapeTimeoutId)
escapeTimeoutId = null
}
if (onEscapeStateChange) {
onEscapeStateChange(false)
}
}
export function registerEscapeShortcut(
isSessionBusy: () => boolean,
abortSession: () => Promise<void>,
blurInput: () => void,
closeModal: () => void,
) {
keyboardRegistry.register({
id: "escape",
key: "Escape",
modifiers: {},
handler: () => {
const hasOpenModal = document.querySelector('[role="dialog"]') !== null
if (hasOpenModal) {
closeModal()
resetEscapeState()
return
}
if (isSessionBusy()) {
if (escapeKeyState === "idle") {
escapeKeyState = "firstPress"
if (onEscapeStateChange) {
onEscapeStateChange(true)
}
escapeTimeoutId = window.setTimeout(() => {
resetEscapeState()
}, ESCAPE_DEBOUNCE_TIMEOUT)
} else if (escapeKeyState === "firstPress") {
resetEscapeState()
abortSession()
}
return
}
resetEscapeState()
blurInput()
},
description: "cancel/close",
context: "global",
})
}

View File

@@ -0,0 +1,23 @@
import { keyboardRegistry } from "../keyboard-registry"
export function registerInputShortcuts(clearInput: () => void, focusInput: () => void) {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
keyboardRegistry.register({
id: "clear-input",
key: "k",
modifiers: { ctrl: !isMac(), meta: isMac() },
handler: clearInput,
description: "clear input",
context: "global",
})
keyboardRegistry.register({
id: "focus-input",
key: "p",
modifiers: { ctrl: !isMac(), meta: isMac() },
handler: focusInput,
description: "focus input",
context: "global",
})
}

View File

@@ -0,0 +1,118 @@
import { keyboardRegistry } from "../keyboard-registry"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import { getSessionFamily, activeSessionId, setActiveSession, activeParentSessionId } from "../../stores/sessions"
export function registerNavigationShortcuts() {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
const buildNavigationOrder = (instanceId: string): string[] => {
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return []
const familySessions = getSessionFamily(instanceId, parentId)
if (familySessions.length === 0) return []
const [parentSession, ...childSessions] = familySessions
if (!parentSession) return []
const sortedChildren = childSessions.slice().sort((a, b) => b.time.updated - a.time.updated)
return [parentSession.id, "info", ...sortedChildren.map((session) => session.id)]
}
keyboardRegistry.register({
id: "instance-prev",
key: "[",
modifiers: { ctrl: !isMac(), meta: isMac() },
handler: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveInstanceId(ids[prev])
},
description: "previous instance",
context: "global",
})
keyboardRegistry.register({
id: "instance-next",
key: "]",
modifiers: { ctrl: !isMac(), meta: isMac() },
handler: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveInstanceId(ids[next])
},
description: "next instance",
context: "global",
})
keyboardRegistry.register({
id: "session-prev",
key: "[",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const navigationIds = buildNavigationOrder(instanceId)
if (navigationIds.length === 0) return
const currentActiveId = activeSessionId().get(instanceId)
let currentIndex = navigationIds.indexOf(currentActiveId || "")
if (currentIndex === -1) {
currentIndex = navigationIds.length - 1
}
const targetIndex = currentIndex <= 0 ? navigationIds.length - 1 : currentIndex - 1
const targetSessionId = navigationIds[targetIndex]
setActiveSession(instanceId, targetSessionId)
},
description: "previous session",
context: "global",
})
keyboardRegistry.register({
id: "session-next",
key: "]",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const navigationIds = buildNavigationOrder(instanceId)
if (navigationIds.length === 0) return
const currentActiveId = activeSessionId().get(instanceId)
let currentIndex = navigationIds.indexOf(currentActiveId || "")
if (currentIndex === -1) {
currentIndex = 0
}
const targetIndex = (currentIndex + 1) % navigationIds.length
const targetSessionId = navigationIds[targetIndex]
setActiveSession(instanceId, targetSessionId)
},
description: "next session",
context: "global",
})
keyboardRegistry.register({
id: "switch-to-info",
key: "l",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: () => {
const instanceId = activeInstanceId()
if (instanceId) setActiveSession(instanceId, "info")
},
description: "info tab",
context: "global",
})
}

View File

@@ -0,0 +1,165 @@
import { createSignal } from "solid-js"
import {
MessageUpdateEvent,
MessageRemovedEvent,
MessagePartUpdatedEvent,
MessagePartRemovedEvent,
} from "../types/message"
import type {
EventLspUpdated,
EventPermissionReplied,
EventPermissionUpdated,
EventSessionCompacted,
EventSessionError,
EventSessionIdle,
EventSessionUpdated,
} from "@opencode-ai/sdk"
import { serverEvents } from "./server-events"
import type {
InstanceStreamEvent,
InstanceStreamStatus,
WorkspaceEventPayload,
} from "../../../server/src/api-types"
import { getLogger } from "./logger"
const log = getLogger("sse")
type InstanceEventPayload = Extract<WorkspaceEventPayload, { type: "instance.event" }>
type InstanceStatusPayload = Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>
interface TuiToastEvent {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
type SSEEvent =
| MessageUpdateEvent
| MessageRemovedEvent
| MessagePartUpdatedEvent
| MessagePartRemovedEvent
| EventSessionUpdated
| EventSessionCompacted
| EventSessionError
| EventSessionIdle
| EventPermissionUpdated
| EventPermissionReplied
| EventLspUpdated
| TuiToastEvent
| { type: string; properties?: Record<string, unknown> }
type ConnectionStatus = InstanceStreamStatus
const [connectionStatus, setConnectionStatus] = createSignal<Map<string, ConnectionStatus>>(new Map())
class SSEManager {
constructor() {
serverEvents.on("instance.eventStatus", (event) => {
const payload = event as InstanceStatusPayload
this.updateConnectionStatus(payload.instanceId, payload.status)
if (payload.status === "disconnected") {
if (payload.reason === "workspace stopped") {
return
}
const reason = payload.reason ?? "Instance disconnected"
void this.onConnectionLost?.(payload.instanceId, reason)
}
})
serverEvents.on("instance.event", (event) => {
const payload = event as InstanceEventPayload
this.updateConnectionStatus(payload.instanceId, "connected")
this.handleEvent(payload.instanceId, payload.event as SSEEvent)
})
}
seedStatus(instanceId: string, status: ConnectionStatus) {
this.updateConnectionStatus(instanceId, status)
}
private handleEvent(instanceId: string, event: SSEEvent | InstanceStreamEvent): void {
if (!event || typeof event !== "object" || typeof (event as { type?: unknown }).type !== "string") {
log.warn("Dropping malformed event", event)
return
}
log.info("Received event", { type: event.type, event })
switch (event.type) {
case "message.updated":
this.onMessageUpdate?.(instanceId, event as MessageUpdateEvent)
break
case "message.part.updated":
this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent)
break
case "message.removed":
this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent)
break
case "message.part.removed":
this.onMessagePartRemoved?.(instanceId, event as MessagePartRemovedEvent)
break
case "session.updated":
this.onSessionUpdate?.(instanceId, event as EventSessionUpdated)
break
case "session.compacted":
this.onSessionCompacted?.(instanceId, event as EventSessionCompacted)
break
case "session.error":
this.onSessionError?.(instanceId, event as EventSessionError)
break
case "tui.toast.show":
this.onTuiToast?.(instanceId, event as TuiToastEvent)
break
case "session.idle":
this.onSessionIdle?.(instanceId, event as EventSessionIdle)
break
case "permission.updated":
this.onPermissionUpdated?.(instanceId, event as EventPermissionUpdated)
break
case "permission.replied":
this.onPermissionReplied?.(instanceId, event as EventPermissionReplied)
break
case "lsp.updated":
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
break
default:
log.warn("Unknown SSE event type", { type: event.type })
}
}
private updateConnectionStatus(instanceId: string, status: ConnectionStatus): void {
setConnectionStatus((prev) => {
const next = new Map(prev)
next.set(instanceId, status)
return next
})
}
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void
onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void
onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void
onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void
onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void
onSessionError?: (instanceId: string, event: EventSessionError) => void
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
getStatus(instanceId: string): ConnectionStatus | null {
return connectionStatus().get(instanceId) ?? null
}
getStatuses() {
return connectionStatus()
}
}
export const sseManager = new SSEManager()

Some files were not shown because too many files have changed in this diff Show More