Split workspace into electron and ui packages
This commit is contained in:
345
packages/ui/src/App.tsx
Normal file
345
packages/ui/src/App.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { Component, Show, createMemo, createEffect, createSignal } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Toaster } from "solid-toast"
|
||||
import FolderSelectionView from "./components/folder-selection-view"
|
||||
import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell"
|
||||
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 {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
setHasInstances,
|
||||
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 App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
preferences,
|
||||
addRecentFolder,
|
||||
toggleShowThinkingBlocks,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch(console.error)
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => getActiveInstance())
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return null
|
||||
return activeSessionId().get(instance.id) || null
|
||||
})
|
||||
|
||||
const launchErrorPath = () => {
|
||||
const value = launchErrorBinary()
|
||||
if (!value) return "opencode"
|
||||
return value.trim() || "opencode"
|
||||
}
|
||||
|
||||
const isMissingBinaryError = (error: unknown): boolean => {
|
||||
if (!error) return false
|
||||
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
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 = () => setLaunchErrorBinary(null)
|
||||
|
||||
async function handleSelectFolder(folderPath?: string, binaryPath?: string) {
|
||||
setIsSelectingFolder(true)
|
||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||
try {
|
||||
let folder: string | null | undefined = folderPath
|
||||
|
||||
if (!folder) {
|
||||
folder = await window.electronAPI.selectFolder()
|
||||
if (!folder) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
addRecentFolder(folder)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folder, selectedBinary)
|
||||
setHasInstances(true)
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
|
||||
} catch (error) {
|
||||
clearLaunchError()
|
||||
if (isMissingBinaryError(error)) {
|
||||
setLaunchErrorBinary(selectedBinary)
|
||||
}
|
||||
console.error("Failed to create instance:", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLaunchErrorClose() {
|
||||
clearLaunchError()
|
||||
}
|
||||
|
||||
function handleLaunchErrorAdvanced() {
|
||||
clearLaunchError()
|
||||
setIsAdvancedSettingsOpen(true)
|
||||
}
|
||||
|
||||
function handleNewInstanceRequest() {
|
||||
if (hasInstances()) {
|
||||
setShowFolderSelection(true)
|
||||
} else {
|
||||
void handleSelectFolder()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnectedInstanceClose() {
|
||||
try {
|
||||
await acknowledgeDisconnectedInstance()
|
||||
} catch (error) {
|
||||
console.error("Failed to finalize disconnected instance:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseInstance(instanceId: string) {
|
||||
if (confirm("Stop OpenCode instance? This will stop the server.")) {
|
||||
await stopInstance(instanceId)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewSession(instanceId: string) {
|
||||
try {
|
||||
const session = await createSession(instanceId)
|
||||
setActiveParentSession(instanceId, session.id)
|
||||
} catch (error) {
|
||||
console.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) {
|
||||
console.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,
|
||||
toggleShowThinkingBlocks,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
getActiveInstance: activeInstance,
|
||||
getActiveSessionIdForInstance: activeSessionIdForInstance,
|
||||
})
|
||||
|
||||
useAppLifecycle({
|
||||
setEscapeInDebounce,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
getActiveInstance: activeInstance,
|
||||
getActiveSessionIdForInstance: activeSessionIdForInstance,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<InstanceDisconnectedModal
|
||||
open={Boolean(disconnectedInstance())}
|
||||
folder={disconnectedInstance()?.folder}
|
||||
reason={disconnectedInstance()?.reason}
|
||||
onClose={handleDisconnectedInstanceClose}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(launchErrorBinary())} 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">
|
||||
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom 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>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}>
|
||||
Open Advanced Settings
|
||||
</button>
|
||||
<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}
|
||||
/>
|
||||
|
||||
<Show when={activeInstance()} keyed>
|
||||
{(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}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<Toaster
|
||||
position="top-right"
|
||||
gutter={16}
|
||||
toastOptions={{
|
||||
duration: 8000,
|
||||
className: "bg-transparent border-none shadow-none p-0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
60
packages/ui/src/components/advanced-settings-modal.tsx
Normal file
60
packages/ui/src/components/advanced-settings-modal.tsx
Normal 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
|
||||
124
packages/ui/src/components/agent-selector.tsx
Normal file
124
packages/ui/src/components/agent-selector.tsx
Normal 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 Kbd from "./kbd"
|
||||
|
||||
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(console.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 z-50">
|
||||
<Select.Listbox class="selector-listbox" />
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
<span class="hint sidebar-selector-hint">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
packages/ui/src/components/attachment-chip.tsx
Normal file
27
packages/ui/src/components/attachment-chip.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Component } from "solid-js"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
|
||||
interface AttachmentChipProps {
|
||||
attachment: Attachment
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class="attachment-chip"
|
||||
title={props.attachment.source.type === "file" ? props.attachment.source.path : undefined}
|
||||
>
|
||||
<span class="font-mono">{props.attachment.display}</span>
|
||||
<button
|
||||
onClick={props.onRemove}
|
||||
class="attachment-remove"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentChip
|
||||
107
packages/ui/src/components/code-block-inline.tsx
Normal file
107
packages/ui/src/components/code-block-inline.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||
|
||||
const inlineLoadedLanguages = new Set<string>()
|
||||
|
||||
type LoadLanguageArg = Parameters<Highlighter["loadLanguage"]>[0]
|
||||
type CodeToHtmlOptions = Parameters<Highlighter["codeToHtml"]>[1]
|
||||
|
||||
interface CodeBlockInlineProps {
|
||||
code: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [html, setHtml] = createSignal("")
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [ready, setReady] = createSignal(false)
|
||||
let highlighter: Highlighter | null = null
|
||||
|
||||
onMount(async () => {
|
||||
highlighter = await getSharedHighlighter()
|
||||
setReady(true)
|
||||
await updateHighlight()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (ready()) {
|
||||
isDark()
|
||||
props.code
|
||||
props.language
|
||||
void updateHighlight()
|
||||
}
|
||||
})
|
||||
|
||||
const updateHighlight = async () => {
|
||||
if (!highlighter) return
|
||||
|
||||
if (!props.language) {
|
||||
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const language = props.language as LoadLanguageArg
|
||||
if (!inlineLoadedLanguages.has(props.language)) {
|
||||
await highlighter.loadLanguage(language)
|
||||
inlineLoadedLanguages.add(props.language)
|
||||
}
|
||||
|
||||
const highlighted = highlighter.codeToHtml(props.code, {
|
||||
lang: props.language as CodeToHtmlOptions["lang"],
|
||||
theme: isDark() ? "github-dark" : "github-light",
|
||||
})
|
||||
setHtml(highlighted)
|
||||
} catch {
|
||||
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
|
||||
}
|
||||
}
|
||||
|
||||
const copyCode = async () => {
|
||||
await navigator.clipboard.writeText(props.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={ready()}
|
||||
fallback={
|
||||
<pre class="tool-call-content">
|
||||
<code>{props.code}</code>
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
<div class="code-block-inline">
|
||||
<div class="code-block-header">
|
||||
<Show when={props.language}>
|
||||
<span class="code-block-language">{props.language}</span>
|
||||
</Show>
|
||||
<button onClick={copyCode} class="code-block-copy">
|
||||
<svg
|
||||
class="copy-icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div innerHTML={html()} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
287
packages/ui/src/components/command-palette.tsx
Normal file
287
packages/ui/src/components/command-palette.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import type { Command } from "../lib/commands"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
commands: Command[]
|
||||
onExecute: (command: Command) => void
|
||||
}
|
||||
|
||||
function buildShortcutString(shortcut: Command["shortcut"]): string {
|
||||
if (!shortcut) return ""
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (shortcut.meta || shortcut.ctrl) parts.push("cmd")
|
||||
if (shortcut.shift) parts.push("shift")
|
||||
if (shortcut.alt) parts.push("alt")
|
||||
parts.push(shortcut.key)
|
||||
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
|
||||
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
let listRef: HTMLDivElement | undefined
|
||||
|
||||
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
|
||||
|
||||
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
|
||||
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
|
||||
|
||||
const processedCommands = createMemo<ProcessedCommands>(() => {
|
||||
const source = props.commands ?? []
|
||||
const q = query().trim().toLowerCase()
|
||||
|
||||
const filtered = q
|
||||
? source.filter((cmd) => {
|
||||
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
|
||||
const labelMatch = label.toLowerCase().includes(q)
|
||||
const descMatch = cmd.description.toLowerCase().includes(q)
|
||||
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q))
|
||||
const categoryMatch = cmd.category?.toLowerCase().includes(q)
|
||||
return labelMatch || descMatch || keywordMatch || categoryMatch
|
||||
})
|
||||
: source
|
||||
|
||||
const groupsMap = new Map<string, Command[]>()
|
||||
for (const cmd of filtered) {
|
||||
const category = cmd.category || "Other"
|
||||
const list = groupsMap.get(category)
|
||||
if (list) {
|
||||
list.push(cmd)
|
||||
} else {
|
||||
groupsMap.set(category, [cmd])
|
||||
}
|
||||
}
|
||||
|
||||
const groups: CommandGroup[] = []
|
||||
const ordered: Command[] = []
|
||||
const processedCategories = new Set<string>()
|
||||
|
||||
const addGroup = (category: string) => {
|
||||
const cmds = groupsMap.get(category)
|
||||
if (!cmds || cmds.length === 0 || processedCategories.has(category)) return
|
||||
groups.push({ category, commands: cmds, startIndex: ordered.length })
|
||||
ordered.push(...cmds)
|
||||
processedCategories.add(category)
|
||||
}
|
||||
|
||||
for (const category of categoryOrder) {
|
||||
addGroup(category)
|
||||
}
|
||||
|
||||
for (const [category] of groupsMap) {
|
||||
addGroup(category)
|
||||
}
|
||||
|
||||
return { groups, ordered }
|
||||
})
|
||||
|
||||
const groupedCommandList = () => processedCommands().groups
|
||||
const orderedCommands = () => processedCommands().ordered
|
||||
const selectedIndex = createMemo(() => {
|
||||
const ordered = orderedCommands()
|
||||
if (ordered.length === 0) return -1
|
||||
const id = selectedCommandId()
|
||||
if (!id) return 0
|
||||
const index = ordered.findIndex((cmd) => cmd.id === id)
|
||||
return index >= 0 ? index : 0
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
setQuery("")
|
||||
setSelectedCommandId(null)
|
||||
setIsPointerSelecting(false)
|
||||
setTimeout(() => inputRef?.focus(), 100)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const ordered = orderedCommands()
|
||||
if (ordered.length === 0) {
|
||||
if (selectedCommandId() !== null) {
|
||||
setSelectedCommandId(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const currentId = selectedCommandId()
|
||||
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
||||
setSelectedCommandId(ordered[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
const index = selectedIndex()
|
||||
if (!listRef || index < 0) return
|
||||
|
||||
const selectedButton = listRef.querySelector(`[data-command-index="${index}"]`) as HTMLElement
|
||||
if (selectedButton) {
|
||||
selectedButton.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}
|
||||
})
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const ordered = orderedCommands()
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (ordered.length === 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsPointerSelecting(false)
|
||||
const current = selectedIndex()
|
||||
const nextIndex = Math.min((current < 0 ? 0 : current) + 1, ordered.length - 1)
|
||||
setSelectedCommandId(ordered[nextIndex]?.id ?? null)
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsPointerSelecting(false)
|
||||
const current = selectedIndex()
|
||||
const nextIndex = current <= 0 ? ordered.length - 1 : current - 1
|
||||
setSelectedCommandId(ordered[nextIndex]?.id ?? null)
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const index = selectedIndex()
|
||||
if (index < 0 || index >= ordered.length) return
|
||||
const command = ordered[index]
|
||||
if (!command) return
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommandClick(command: Command) {
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
setIsPointerSelecting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
||||
<Dialog.Content
|
||||
class="modal-surface w-full max-w-2xl max-h-[60vh]"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
|
||||
|
||||
<div class="modal-search-container">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 modal-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query()}
|
||||
onInput={(e) => {
|
||||
setQuery(e.currentTarget.value)
|
||||
setSelectedCommandId(null)
|
||||
}}
|
||||
placeholder="Type a command or search..."
|
||||
class="modal-search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
class="modal-list-container"
|
||||
data-pointer-mode={isPointerSelecting() ? "pointer" : "keyboard"}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Show
|
||||
when={orderedCommands().length > 0}
|
||||
fallback={<div class="modal-empty-state">No commands found for "{query()}"</div>}
|
||||
>
|
||||
<For each={groupedCommandList()}>
|
||||
{(group) => (
|
||||
<div class="py-2">
|
||||
<div class="modal-section-header">
|
||||
{group.category}
|
||||
</div>
|
||||
<For each={group.commands}>
|
||||
{(command, localIndex) => {
|
||||
const commandIndex = group.startIndex + localIndex()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-command-index={commandIndex}
|
||||
onClick={() => handleCommandClick(command)}
|
||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||
onPointerMove={(event) => {
|
||||
if (event.movementX === 0 && event.movementY === 0) return
|
||||
if (event.pointerType === "mouse" || event.pointerType === "pen" || event.pointerType === "touch") {
|
||||
if (!isPointerSelecting()) {
|
||||
setIsPointerSelecting(true)
|
||||
}
|
||||
setSelectedCommandId(command.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="modal-item-label">
|
||||
{typeof command.label === "function" ? command.label() : command.label}
|
||||
</div>
|
||||
<div class="modal-item-description">
|
||||
{command.description}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={command.shortcut}>
|
||||
<div class="mt-1">
|
||||
<Kbd shortcut={buildShortcutString(command.shortcut)} />
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandPalette
|
||||
103
packages/ui/src/components/diff-viewer.tsx
Normal file
103
packages/ui/src/components/diff-viewer.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createMemo, Show, onMount, createEffect } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setToolRenderCache } from "../lib/tool-render-cache"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
|
||||
interface ToolCallDiffViewerProps {
|
||||
diffText: string
|
||||
filePath?: string
|
||||
theme: "light" | "dark"
|
||||
mode: DiffViewMode
|
||||
onRendered?: () => void
|
||||
cachedHtml?: string
|
||||
cacheKey?: string
|
||||
}
|
||||
|
||||
type DiffData = {
|
||||
oldFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null }
|
||||
newFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null }
|
||||
hunks: string[]
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const captureAndCacheHtml = () => {
|
||||
if (diffContainerRef && props.cacheKey && !props.cachedHtml) {
|
||||
// Extract the rendered HTML from DiffView container
|
||||
const renderedHtml = diffContainerRef.innerHTML
|
||||
if (renderedHtml) {
|
||||
setToolRenderCache(props.cacheKey, {
|
||||
text: props.diffText,
|
||||
html: renderedHtml,
|
||||
theme: props.theme,
|
||||
mode: props.mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
props.onRendered?.()
|
||||
}
|
||||
|
||||
// Also capture HTML when diff data changes
|
||||
createEffect(() => {
|
||||
const data = diffData()
|
||||
if (data && !props.cachedHtml) {
|
||||
// Delay to allow DiffView to re-render with new data
|
||||
setTimeout(captureAndCacheHtml, 100)
|
||||
}
|
||||
})
|
||||
|
||||
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) => (
|
||||
<DiffView
|
||||
data={data()}
|
||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||
diffViewTheme={props.theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={false}
|
||||
diffViewFontSize={13}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div innerHTML={props.cachedHtml} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
packages/ui/src/components/empty-state.tsx
Normal file
51
packages/ui/src/components/empty-state.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Loader2 } from "lucide-solid"
|
||||
|
||||
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
interface EmptyStateProps {
|
||||
onSelectFolder: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const EmptyState: Component<EmptyStateProps> = (props) => {
|
||||
return (
|
||||
<div class="flex h-full w-full items-center justify-center bg-surface-secondary">
|
||||
<div class="max-w-[500px] px-8 py-12 text-center">
|
||||
<div class="mb-8 flex justify-center">
|
||||
<img src={codeNomadIcon} alt="CodeNomad logo" class="h-24 w-auto" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<h1 class="mb-3 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<p class="mb-8 text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
|
||||
|
||||
<button
|
||||
onClick={props.onSelectFolder}
|
||||
disabled={props.isLoading}
|
||||
class="mb-4 button-primary"
|
||||
>
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Selecting...
|
||||
</>
|
||||
) : (
|
||||
"Select Folder"
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p class="text-sm text-muted">
|
||||
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-1 text-sm text-muted">
|
||||
<p>Examples: ~/projects/my-app</p>
|
||||
<p>You can have multiple instances of the same folder</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
148
packages/ui/src/components/environment-variables-editor.tsx
Normal file
148
packages/ui/src/components/environment-variables-editor.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Component, createSignal, For, Show } from "solid-js"
|
||||
import { Plus, Trash2, Key, Globe } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
interface EnvironmentVariablesEditorProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||
const {
|
||||
preferences,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
updateEnvironmentVariables,
|
||||
} = useConfig()
|
||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
|
||||
const [newKey, setNewKey] = createSignal("")
|
||||
const [newValue, setNewValue] = createSignal("")
|
||||
|
||||
const entries = () => Object.entries(envVars())
|
||||
|
||||
function handleAddVariable() {
|
||||
const key = newKey().trim()
|
||||
const value = newValue().trim()
|
||||
|
||||
if (!key) return
|
||||
|
||||
addEnvironmentVariable(key, value)
|
||||
setEnvVars({ ...envVars(), [key]: value })
|
||||
setNewKey("")
|
||||
setNewValue("")
|
||||
}
|
||||
|
||||
function handleRemoveVariable(key: string) {
|
||||
removeEnvironmentVariable(key)
|
||||
const { [key]: removed, ...rest } = envVars()
|
||||
setEnvVars(rest)
|
||||
}
|
||||
|
||||
function handleUpdateVariable(key: string, value: string) {
|
||||
const updated = { ...envVars(), [key]: value }
|
||||
setEnvVars(updated)
|
||||
updateEnvironmentVariables(updated)
|
||||
}
|
||||
|
||||
function handleKeyPress(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleAddVariable()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Globe class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Environment Variables</span>
|
||||
<span class="text-xs text-muted">
|
||||
({entries().length} variable{entries().length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Existing variables */}
|
||||
<Show when={entries().length > 0}>
|
||||
<div class="space-y-2">
|
||||
<For each={entries()}>
|
||||
{([key, value]) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<Key class="w-3.5 h-3.5 icon-muted flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-secondary border border-base rounded text-muted cursor-not-allowed"
|
||||
placeholder="Variable name"
|
||||
title="Variable name (read-only)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
disabled={props.disabled}
|
||||
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Variable value"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveVariable(key)}
|
||||
disabled={props.disabled}
|
||||
class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Remove variable"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Add new variable */}
|
||||
<div class="flex items-center gap-2 pt-2 border-t border-base">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<Key class="w-3.5 h-3.5 icon-muted flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={newKey()}
|
||||
onInput={(e) => setNewKey(e.currentTarget.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Variable name"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newValue()}
|
||||
onInput={(e) => setNewValue(e.currentTarget.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Variable value"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddVariable}
|
||||
disabled={props.disabled || !newKey().trim()}
|
||||
class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Add variable"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={entries().length === 0}>
|
||||
<div class="text-xs text-muted text-center py-2">
|
||||
No environment variables configured. Add variables above to customize the OpenCode environment.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="text-xs text-muted mt-2">
|
||||
These variables will be available in the OpenCode environment when starting instances.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnvironmentVariablesEditor
|
||||
221
packages/ui/src/components/file-picker.tsx
Normal file
221
packages/ui/src/components/file-picker.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
|
||||
interface FileItem {
|
||||
path: string
|
||||
added?: number
|
||||
removed?: number
|
||||
isGitFile: boolean
|
||||
}
|
||||
|
||||
interface FilePickerProps {
|
||||
open: boolean
|
||||
onSelect: (path: string) => void
|
||||
onNavigate: (direction: "up" | "down") => void
|
||||
onClose: () => void
|
||||
instanceClient: OpencodeClient
|
||||
searchQuery: string
|
||||
textareaRef?: HTMLTextAreaElement
|
||||
workspaceFolder: string
|
||||
}
|
||||
|
||||
const FilePicker: Component<FilePickerProps> = (props) => {
|
||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
||||
const [isInitialized, setIsInitialized] = createSignal(false)
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
|
||||
async function fetchFiles(searchQuery: string) {
|
||||
console.log(`[FilePicker] Fetching files for query: "${searchQuery}"`)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
if (allFiles().length === 0) {
|
||||
console.log(`[FilePicker] Scanning workspace: ${props.workspaceFolder}`)
|
||||
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder)
|
||||
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({
|
||||
path,
|
||||
isGitFile: false,
|
||||
}))
|
||||
setAllFiles(scannedFiles)
|
||||
console.log(`[FilePicker] Found ${scannedFiles.length} files`)
|
||||
}
|
||||
|
||||
const filteredFiles = searchQuery.trim()
|
||||
? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: allFiles()
|
||||
|
||||
console.log(`[FilePicker] Showing ${filteredFiles.length} files`)
|
||||
setFiles(filteredFiles)
|
||||
setSelectedIndex(0)
|
||||
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef) {
|
||||
scrollContainerRef.scrollTop = 0
|
||||
}
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
console.error(`[FilePicker] Failed to fetch files:`, error)
|
||||
setFiles([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
let lastQuery = ""
|
||||
|
||||
createEffect(() => {
|
||||
console.log(
|
||||
`[FilePicker] Effect triggered - open: ${props.open}, query: "${props.searchQuery}", isInitialized: ${isInitialized()}`,
|
||||
)
|
||||
|
||||
if (props.open && !isInitialized()) {
|
||||
setIsInitialized(true)
|
||||
console.log("[FilePicker] First open - fetching files")
|
||||
fetchFiles(props.searchQuery)
|
||||
lastQuery = props.searchQuery
|
||||
return
|
||||
}
|
||||
|
||||
if (props.open && props.searchQuery !== lastQuery) {
|
||||
console.log(`[FilePicker] Query changed from "${lastQuery}" to "${props.searchQuery}"`)
|
||||
lastQuery = props.searchQuery
|
||||
fetchFiles(props.searchQuery)
|
||||
}
|
||||
})
|
||||
|
||||
function scrollToSelected() {
|
||||
setTimeout(() => {
|
||||
const selectedElement = containerRef?.querySelector('[data-file-selected="true"]')
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function handleSelect(path: string) {
|
||||
props.onSelect(path)
|
||||
}
|
||||
|
||||
function handleNavigateUp() {
|
||||
setSelectedIndex((prev) => {
|
||||
const next = Math.max(prev - 1, 0)
|
||||
scrollToSelected()
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleNavigateDown() {
|
||||
setSelectedIndex((prev) => {
|
||||
const next = Math.min(prev + 1, files().length - 1)
|
||||
scrollToSelected()
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
if (!props.open) return
|
||||
const fileList = files()
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (fileList.length === 0) return
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleNavigateDown()
|
||||
props.onNavigate("down")
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleNavigateUp()
|
||||
props.onNavigate("up")
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (fileList[selectedIndex()]) {
|
||||
handleSelect(fileList[selectedIndex()].path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", listener, true)
|
||||
onCleanup(() => document.removeEventListener("keydown", listener, true))
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="dropdown-surface bottom-full left-0 mb-2 max-w-2xl rounded-lg"
|
||||
style={{ "z-index": 100 }}
|
||||
>
|
||||
<div ref={scrollContainerRef} class="dropdown-content max-h-96">
|
||||
<Show
|
||||
when={!loading() && isInitialized()}
|
||||
fallback={
|
||||
<div class="dropdown-loading">
|
||||
<div class="spinner inline-block h-4 w-4 mr-2"></div>
|
||||
<span>Loading files...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={files().length > 0}
|
||||
fallback={<div class="dropdown-empty">No matching files</div>}
|
||||
>
|
||||
<For each={files()}>
|
||||
{(file, index) => (
|
||||
<div
|
||||
data-file-selected={index() === selectedIndex()}
|
||||
class={`dropdown-item border-b px-4 py-2 font-mono text-sm ${
|
||||
index() === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||
}`}
|
||||
style="border-color: var(--border-muted)"
|
||||
onClick={() => handleSelect(file.path)}
|
||||
onMouseEnter={() => setSelectedIndex(index())}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{file.path}</span>
|
||||
<Show when={file.isGitFile && (file.added || file.removed)}>
|
||||
<div class="flex gap-2">
|
||||
<Show when={file.added}>
|
||||
<span class="dropdown-diff-added">+{file.added}</span>
|
||||
</Show>
|
||||
<Show when={file.removed}>
|
||||
<span class="dropdown-diff-removed">-{file.removed}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-footer p-2">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<span>↑↓ Navigate • Enter Select • Esc Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePicker
|
||||
385
packages/ui/src/components/folder-selection-view.tsx
Normal file
385
packages/ui/src/components/folder-selection-view.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } = useConfig()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
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 && lastUsed !== selectedBinary()) {
|
||||
setSelectedBinary(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()
|
||||
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
|
||||
updateLastUsedBinary(selectedBinary())
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
updateLastUsedBinary(selectedBinary())
|
||||
props.onSelectFolder(undefined, selectedBinary())
|
||||
}
|
||||
|
||||
|
||||
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 relative"
|
||||
style="background-color: var(--surface-secondary)"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" 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">
|
||||
<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={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">
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FolderSelectionView
|
||||
12
packages/ui/src/components/hint-row.tsx
Normal file
12
packages/ui/src/components/hint-row.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Component, JSX } from "solid-js"
|
||||
|
||||
interface HintRowProps {
|
||||
children: JSX.Element
|
||||
class?: string
|
||||
}
|
||||
|
||||
const HintRow: Component<HintRowProps> = (props) => {
|
||||
return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span>
|
||||
}
|
||||
|
||||
export default HintRow
|
||||
161
packages/ui/src/components/info-view.tsx
Normal file
161
packages/ui/src/components/info-view.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import InstanceInfo from "./instance-info"
|
||||
|
||||
interface InfoViewProps {
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
const InfoView: Component<InfoViewProps> = (props) => {
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const savedState = logsScrollState.get(props.instanceId)
|
||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||
|
||||
const instance = () => instances().get(props.instanceId)
|
||||
const logs = createMemo(() => getInstanceLogs(props.instanceId))
|
||||
const streamingEnabled = createMemo(() => isInstanceLogStreaming(props.instanceId))
|
||||
|
||||
const handleEnableLogs = () => setInstanceLogStreaming(props.instanceId, true)
|
||||
const handleDisableLogs = () => setInstanceLogStreaming(props.instanceId, false)
|
||||
|
||||
onMount(() => {
|
||||
|
||||
if (scrollRef && savedState) {
|
||||
scrollRef.scrollTop = savedState.scrollTop
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (scrollRef) {
|
||||
logsScrollState.set(props.instanceId, {
|
||||
scrollTop: scrollRef.scrollTop,
|
||||
autoScroll: autoScroll(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (autoScroll() && scrollRef && logs().length > 0) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
})
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef) return
|
||||
|
||||
const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50
|
||||
|
||||
setAutoScroll(isAtBottom)
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollRef) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return "log-level-error"
|
||||
case "warn":
|
||||
return "log-level-warn"
|
||||
case "debug":
|
||||
return "log-level-debug"
|
||||
default:
|
||||
return "log-level-default"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="log-container">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
||||
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div class="log-header">
|
||||
<h2 class="panel-title">Server Logs</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
||||
Hide server logs
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
class="log-content"
|
||||
>
|
||||
<Show
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<div class="log-paused-state">
|
||||
<p class="log-paused-title">Server logs are paused</p>
|
||||
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
|
||||
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={logs().length > 0}
|
||||
fallback={<div class="log-empty-state">Waiting for server output...</div>}
|
||||
>
|
||||
<For each={logs()}>
|
||||
{(entry) => (
|
||||
<div class="log-entry">
|
||||
<span class="log-timestamp">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<span class={`log-message ${getLevelColor(entry.level)}`}>{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={!autoScroll() && streamingEnabled()}>
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
class="scroll-to-bottom"
|
||||
>
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
Scroll to bottom
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default InfoView
|
||||
47
packages/ui/src/components/instance-disconnected-modal.tsx
Normal file
47
packages/ui/src/components/instance-disconnected-modal.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
|
||||
interface InstanceDisconnectedModalProps {
|
||||
open: boolean
|
||||
folder?: string
|
||||
reason?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) {
|
||||
const folderLabel = props.folder || "this workspace"
|
||||
const reasonLabel = props.reason || "The server stopped responding"
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
339
packages/ui/src/components/instance-info.tsx
Normal file
339
packages/ui/src/components/instance-info.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
|
||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||
import { fetchLspStatus, updateInstance } from "../stores/instances"
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
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 mappedStatus: ParsedMcpStatus["status"]
|
||||
if (rawStatus === "connected") {
|
||||
mappedStatus = "running"
|
||||
} else if (rawStatus === "failed") {
|
||||
mappedStatus = "error"
|
||||
} else {
|
||||
mappedStatus = "stopped"
|
||||
}
|
||||
|
||||
result.push({
|
||||
name,
|
||||
status: mappedStatus,
|
||||
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const pendingMetadataRequests = new Set<string>()
|
||||
|
||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
||||
|
||||
const metadata = () => props.instance.metadata
|
||||
const mcpServers = () => {
|
||||
const status = metadata()?.mcpStatus
|
||||
return status ? parseMcpStatus(status) : []
|
||||
}
|
||||
const lspServers = () => metadata()?.lspStatus ?? []
|
||||
|
||||
createEffect(() => {
|
||||
const instance = props.instance
|
||||
const instanceId = instance.id
|
||||
const client = instance.client
|
||||
const hasMetadata = Boolean(instance.metadata)
|
||||
|
||||
if (!client) {
|
||||
setIsLoadingMetadata(false)
|
||||
pendingMetadataRequests.delete(instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (hasMetadata) {
|
||||
setIsLoadingMetadata(false)
|
||||
pendingMetadataRequests.delete(instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingMetadataRequests.has(instanceId)) {
|
||||
setIsLoadingMetadata(true)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
pendingMetadataRequests.add(instanceId)
|
||||
setIsLoadingMetadata(true)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
|
||||
client.project.current(),
|
||||
client.mcp.status(),
|
||||
fetchLspStatus(instanceId),
|
||||
])
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
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 nextMetadata = {
|
||||
...(instance.metadata ?? {}),
|
||||
...(project ? { project } : {}),
|
||||
...(mcpStatus ? { mcpStatus } : {}),
|
||||
...(lspStatus ? { lspStatus } : {}),
|
||||
}
|
||||
|
||||
if (!nextMetadata.version) {
|
||||
nextMetadata.version = "0.15.8"
|
||||
}
|
||||
|
||||
updateInstance(instanceId, { metadata: nextMetadata })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load instance metadata:", error)
|
||||
}
|
||||
} finally {
|
||||
pendingMetadataRequests.delete(instanceId)
|
||||
if (!cancelled) {
|
||||
setIsLoadingMetadata(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
})
|
||||
|
||||
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">
|
||||
{props.instance.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={metadata()?.version}>
|
||||
<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{metadata()?.version}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.instance.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">
|
||||
{props.instance.binaryPath}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.instance.environmentVariables && Object.keys(props.instance.environmentVariables).length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
Environment Variables ({Object.keys(props.instance.environmentVariables!).length})
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<For each={Object.entries(props.instance.environmentVariables!)}>
|
||||
{([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>
|
||||
|
||||
<Show when={!isLoadingMetadata() && lspServers().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
LSP Servers
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
MCP Servers
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<For each={mcpServers()}>
|
||||
{(server) => (
|
||||
<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-1.5 flex-shrink-0 text-xs text-secondary">
|
||||
<div
|
||||
class={`status-dot ${
|
||||
server.status === "running"
|
||||
? "ready animate-pulse"
|
||||
: server.status === "error"
|
||||
? "error"
|
||||
: "stopped"
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{
|
||||
server.status === "running"
|
||||
? "Connected"
|
||||
: server.status === "error"
|
||||
? "Error"
|
||||
: "Disabled"
|
||||
}
|
||||
</span>
|
||||
</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>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<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">{props.instance.port}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">PID:</span>
|
||||
<span class="text-primary font-mono">{props.instance.pid}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">Status:</span>
|
||||
<span
|
||||
class={`status-badge ${props.instance.status}`}
|
||||
>
|
||||
<div
|
||||
class={`status-dot ${props.instance.status === "ready" ? "ready" : props.instance.status === "starting" ? "starting" : props.instance.status === "error" ? "error" : "stopped"} ${props.instance.status === "ready" || props.instance.status === "starting" ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{props.instance.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceInfo
|
||||
59
packages/ui/src/components/instance-tab.tsx
Normal file
59
packages/ui/src/components/instance-tab.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Component } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { FolderOpen, X } from "lucide-solid"
|
||||
|
||||
interface InstanceTabProps {
|
||||
instance: Instance
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string {
|
||||
const name = path.split("/").pop() || path
|
||||
|
||||
const duplicates = instances.filter((i) => {
|
||||
const iName = i.folder.split("/").pop() || i.folder
|
||||
return iName === name
|
||||
})
|
||||
|
||||
if (duplicates.length > 1) {
|
||||
const index = duplicates.findIndex((i) => i.id === currentInstance.id)
|
||||
return `~/${name} (${index + 1})`
|
||||
}
|
||||
|
||||
return `~/${name}`
|
||||
}
|
||||
|
||||
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
return (
|
||||
<div class="group">
|
||||
<button
|
||||
class={`tab-base ${props.active ? "tab-active" : "tab-inactive"}`}
|
||||
onClick={props.onSelect}
|
||||
title={props.instance.folder}
|
||||
role="tab"
|
||||
aria-selected={props.active}
|
||||
>
|
||||
<FolderOpen class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="tab-label">
|
||||
{props.instance.folder.split("/").pop() || props.instance.folder}
|
||||
</span>
|
||||
<span
|
||||
class="tab-close ml-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onClose()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close instance"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceTab
|
||||
54
packages/ui/src/components/instance-tabs.tsx
Normal file
54
packages/ui/src/components/instance-tabs.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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 } 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
|
||||
}
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
return (
|
||||
<div class="tab-bar tab-bar-instance">
|
||||
<div class="tab-container" role="tablist">
|
||||
<div class="flex items-center gap-1 overflow-x-auto">
|
||||
<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>
|
||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||
Boolean,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceTabs
|
||||
313
packages/ui/src/components/instance-welcome-view.tsx
Normal file
313
packages/ui/src/components/instance-welcome-view.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions"
|
||||
import InstanceInfo from "./instance-info"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import Kbd from "./kbd"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
|
||||
|
||||
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 parentSessions = () => getParentSessions(props.instance.id)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
function scrollToIndex(index: number) {
|
||||
const element = document.querySelector(`[data-session-index="${index}"]`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "auto" })
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const sessions = parentSessions()
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
||||
e.preventDefault()
|
||||
handleNewSession()
|
||||
return
|
||||
}
|
||||
|
||||
if (sessions.length === 0) return
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
const newIndex = Math.max(selectedIndex() - 1, 0)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "PageDown") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "PageUp") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.max(selectedIndex() - pageSize, 0)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(0)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(0)
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault()
|
||||
const newIndex = sessions.length - 1
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleEnterKey()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnterKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
|
||||
if (index < sessions.length) {
|
||||
await handleSessionSelect(sessions[index].id)
|
||||
}
|
||||
}
|
||||
|
||||
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 formatTimestamp(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
async function handleSessionSelect(sessionId: string) {
|
||||
setActiveParentSession(props.instance.id, sessionId)
|
||||
}
|
||||
|
||||
async function handleNewSession() {
|
||||
if (isCreating()) return
|
||||
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const session = await createSession(props.instance.id)
|
||||
setActiveParentSession(props.instance.id, session.id)
|
||||
} catch (error) {
|
||||
console.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">
|
||||
<div class="flex-1 flex flex-col gap-4 min-h-0">
|
||||
<Show
|
||||
when={parentSessions().length > 0}
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Resume Session</h2>
|
||||
<p class="panel-subtitle">
|
||||
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto">
|
||||
<For each={parentSessions()}>
|
||||
{(session, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "sessions" && selectedIndex() === index(),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-session-index={index()}
|
||||
class="panel-list-item-content group w-full"
|
||||
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 truncate transition-colors"
|
||||
classList={{
|
||||
"text-accent":
|
||||
focusMode() === "sessions" && selectedIndex() === index(),
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<Show when={focusMode() === "sessions" && selectedIndex() === index()}>
|
||||
<kbd class="kbd flex-shrink-0">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel flex-shrink-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Start New Session</h2>
|
||||
<p class="panel-subtitle">We’ll reuse your last agent/model automatically</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating()}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{isCreating() ? (
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Create Session</span>
|
||||
</div>
|
||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-80 flex-shrink-0">
|
||||
<div class="sticky top-0">
|
||||
<InstanceInfo instance={props.instance} />
|
||||
</div>
|
||||
</div>
|
||||
</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">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>
|
||||
<KeyboardHint shortcuts={[newSessionShortcut()]} separator="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceWelcomeView
|
||||
|
||||
173
packages/ui/src/components/instance/instance-shell.tsx
Normal file
173
packages/ui/src/components/instance/instance-shell.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Show, createMemo, createSignal, type Component } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { Command } from "../../lib/commands"
|
||||
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||
import { buildCustomCommandEntries } from "../../lib/command-utils"
|
||||
import { getCommands as getInstanceCommands } from "../../stores/commands"
|
||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
|
||||
import SessionList from "../session-list"
|
||||
import KeyboardHint from "../keyboard-hint"
|
||||
import InstanceWelcomeView from "../instance-welcome-view"
|
||||
import InfoView from "../info-view"
|
||||
import AgentSelector from "../agent-selector"
|
||||
import ModelSelector from "../model-selector"
|
||||
import CommandPalette from "../command-palette"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
|
||||
interface InstanceShellProps {
|
||||
instance: Instance
|
||||
escapeInDebounce: boolean
|
||||
paletteCommands: Accessor<Command[]>
|
||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
||||
onNewSession: () => Promise<void> | void
|
||||
handleSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
|
||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||
onExecuteCommand: (command: Command) => void
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
|
||||
|
||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
const parentId = activeParentSessionId().get(props.instance.id)
|
||||
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||
const sessionFamily = getSessionFamily(props.instance.id, parentId)
|
||||
return new Map(sessionFamily.map((s) => [s.id, s]))
|
||||
})
|
||||
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
return activeSessionMap().get(props.instance.id) || null
|
||||
})
|
||||
|
||||
const activeSessionForInstance = createMemo(() => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") return null
|
||||
return activeSessions().get(sessionId) ?? null
|
||||
})
|
||||
|
||||
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
||||
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
||||
|
||||
const keyboardShortcuts = createMemo(() =>
|
||||
[keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter(
|
||||
(shortcut): shortcut is KeyboardShortcut => Boolean(shortcut),
|
||||
),
|
||||
)
|
||||
|
||||
const handleSessionSelect = (sessionId: string) => {
|
||||
setActiveSession(props.instance.id, sessionId)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div class="session-sidebar flex flex-col bg-surface-secondary" style={{ width: `${sessionSidebarWidth()}px` }}>
|
||||
<SessionList
|
||||
instanceId={props.instance.id}
|
||||
sessions={activeSessions()}
|
||||
activeSessionId={activeSessionIdForInstance()}
|
||||
onSelect={handleSessionSelect}
|
||||
onClose={(id) => {
|
||||
const result = props.onCloseSession(id)
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => console.error("Failed to close session:", error))
|
||||
}
|
||||
}}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => console.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
showHeader
|
||||
showFooter={false}
|
||||
headerContent={
|
||||
<div class="session-sidebar-header">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
{keyboardShortcuts().length ? (
|
||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onWidthChange={setSessionSidebarWidth}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator border-t border-base" />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
|
||||
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
|
||||
<AgentSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentAgent={activeSession().agent}
|
||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<Show
|
||||
when={activeSessionIdForInstance() === "info"}
|
||||
fallback={
|
||||
<Show
|
||||
when={activeSessionIdForInstance()}
|
||||
keyed
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<p class="mb-2">No session selected</p>
|
||||
<p class="text-sm">Select a session to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(sessionId) => (
|
||||
<SessionView
|
||||
sessionId={sessionId}
|
||||
activeSessions={activeSessions()}
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<InfoView instanceId={props.instance.id} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen()}
|
||||
onClose={() => hideCommandPalette(props.instance.id)}
|
||||
commands={instancePaletteCommands()}
|
||||
onExecute={props.onExecuteCommand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceShell
|
||||
79
packages/ui/src/components/kbd.tsx
Normal file
79
packages/ui/src/components/kbd.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Component, JSX, For } from "solid-js"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
|
||||
interface KbdProps {
|
||||
children?: JSX.Element
|
||||
shortcut?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
enter: "Enter",
|
||||
return: "Enter",
|
||||
esc: "Esc",
|
||||
escape: "Esc",
|
||||
tab: "Tab",
|
||||
space: "Space",
|
||||
backspace: "Backspace",
|
||||
delete: "Delete",
|
||||
pageup: "Page Up",
|
||||
pagedown: "Page Down",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
}
|
||||
|
||||
const Kbd: Component<KbdProps> = (props) => {
|
||||
const parts = () => {
|
||||
if (props.children) return [{ text: props.children, isModifier: false }]
|
||||
if (!props.shortcut) return []
|
||||
|
||||
const result: { text: string | JSX.Element; isModifier: boolean }[] = []
|
||||
const shortcut = props.shortcut.toLowerCase()
|
||||
const tokens = shortcut.split("+")
|
||||
|
||||
tokens.forEach((token) => {
|
||||
const trimmed = token.trim()
|
||||
const lower = trimmed.toLowerCase()
|
||||
|
||||
if (lower === "cmd" || lower === "command") {
|
||||
result.push({ text: isMac() ? "Cmd" : "Ctrl", isModifier: false })
|
||||
} else if (lower === "shift") {
|
||||
result.push({ text: "Shift", isModifier: false })
|
||||
} else if (lower === "alt" || lower === "option") {
|
||||
result.push({ text: isMac() ? "Option" : "Alt", isModifier: false })
|
||||
} else if (lower === "ctrl" || lower === "control") {
|
||||
result.push({ text: "Ctrl", isModifier: false })
|
||||
} else {
|
||||
const label = SPECIAL_KEY_LABELS[lower]
|
||||
if (label) {
|
||||
result.push({ text: label, isModifier: false })
|
||||
} else if (trimmed.length === 1) {
|
||||
result.push({ text: trimmed.toUpperCase(), isModifier: false })
|
||||
} else {
|
||||
result.push({ text: trimmed.charAt(0).toUpperCase() + trimmed.slice(1), isModifier: false })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return (
|
||||
<kbd class={`kbd ${props.class || ""}`}>
|
||||
<For each={parts()}>
|
||||
{(part, index) => (
|
||||
<>
|
||||
{index() > 0 && <span class="kbd-separator">+</span>}
|
||||
<span>{part.text}</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
export default Kbd
|
||||
44
packages/ui/src/components/keyboard-hint.tsx
Normal file
44
packages/ui/src/components/keyboard-hint.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Component, For } from "solid-js"
|
||||
import { formatShortcut, isMac } from "../lib/keyboard-utils"
|
||||
import type { KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import Kbd from "./kbd"
|
||||
import HintRow from "./hint-row"
|
||||
|
||||
const KeyboardHint: Component<{
|
||||
shortcuts: KeyboardShortcut[]
|
||||
separator?: string
|
||||
showDescription?: boolean
|
||||
}> = (props) => {
|
||||
function buildShortcutString(shortcut: KeyboardShortcut): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (shortcut.modifiers.ctrl || shortcut.modifiers.meta) {
|
||||
parts.push("cmd")
|
||||
}
|
||||
if (shortcut.modifiers.shift) {
|
||||
parts.push("shift")
|
||||
}
|
||||
if (shortcut.modifiers.alt) {
|
||||
parts.push("alt")
|
||||
}
|
||||
parts.push(shortcut.key)
|
||||
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
return (
|
||||
<HintRow>
|
||||
<For each={props.shortcuts}>
|
||||
{(shortcut, i) => (
|
||||
<>
|
||||
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
|
||||
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
|
||||
<Kbd shortcut={buildShortcutString(shortcut)} />
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</HintRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default KeyboardHint
|
||||
171
packages/ui/src/components/logs-view.tsx
Normal file
171
packages/ui/src/components/logs-view.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
|
||||
interface LogsViewProps {
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
const LogsView: Component<LogsViewProps> = (props) => {
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const savedState = logsScrollState.get(props.instanceId)
|
||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||
|
||||
const instance = () => instances().get(props.instanceId)
|
||||
const logs = createMemo(() => getInstanceLogs(props.instanceId))
|
||||
const streamingEnabled = createMemo(() => isInstanceLogStreaming(props.instanceId))
|
||||
|
||||
const handleEnableLogs = () => setInstanceLogStreaming(props.instanceId, true)
|
||||
const handleDisableLogs = () => setInstanceLogStreaming(props.instanceId, false)
|
||||
|
||||
onMount(() => {
|
||||
|
||||
if (scrollRef && savedState) {
|
||||
scrollRef.scrollTop = savedState.scrollTop
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (scrollRef) {
|
||||
logsScrollState.set(props.instanceId, {
|
||||
scrollTop: scrollRef.scrollTop,
|
||||
autoScroll: autoScroll(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (autoScroll() && scrollRef && logs().length > 0) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
})
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef) return
|
||||
|
||||
const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50
|
||||
|
||||
setAutoScroll(isAtBottom)
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollRef) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return "log-level-error"
|
||||
case "warn":
|
||||
return "log-level-warn"
|
||||
case "debug":
|
||||
return "log-level-debug"
|
||||
default:
|
||||
return "log-level-default"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="log-container">
|
||||
<div class="log-header">
|
||||
<h3 class="text-sm font-medium" style="color: var(--text-secondary)">Server Logs</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
||||
Hide server logs
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}>
|
||||
<div class="env-vars-container">
|
||||
<div class="env-vars-title">
|
||||
Environment Variables ({Object.keys(instance()?.environmentVariables!).length})
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<For each={Object.entries(instance()?.environmentVariables!)}>
|
||||
{([key, value]) => (
|
||||
<div class="env-var-item">
|
||||
<span class="env-var-key">{key}</span>
|
||||
<span class="env-var-separator">=</span>
|
||||
<span class="env-var-value" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
class="log-content"
|
||||
>
|
||||
<Show
|
||||
when={streamingEnabled()}
|
||||
fallback={
|
||||
<div class="log-paused-state">
|
||||
<p class="log-paused-title">Server logs are paused</p>
|
||||
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
|
||||
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
||||
Show server logs
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={logs().length > 0}
|
||||
fallback={<div class="log-empty-state">Waiting for server output...</div>}
|
||||
>
|
||||
<For each={logs()}>
|
||||
{(entry) => (
|
||||
<div class="log-entry">
|
||||
<span class="log-timestamp">{formatTime(entry.timestamp)}</span>
|
||||
<span class={`log-message ${getLevelColor(entry.level)}`}>{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={!autoScroll() && streamingEnabled()}>
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
class="scroll-to-bottom"
|
||||
>
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
Scroll to bottom
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default LogsView
|
||||
139
packages/ui/src/components/markdown.tsx
Normal file
139
packages/ui/src/components/markdown.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
||||
import type { TextPart } from "../types/message"
|
||||
|
||||
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
|
||||
|
||||
latestRequestedText = text
|
||||
|
||||
await initMarkdown(dark)
|
||||
|
||||
if (!highlightEnabled) {
|
||||
part.renderCache = undefined
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(rendered)
|
||||
notifyRendered()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(text)
|
||||
notifyRendered()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const cache = part.renderCache
|
||||
if (cache && cache.text === text && cache.theme === themeKey) {
|
||||
setHtml(cache.html)
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(rendered)
|
||||
part.renderCache = { text, html: rendered, theme: themeKey }
|
||||
notifyRendered()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(text)
|
||||
part.renderCache = { text, html: text, theme: themeKey }
|
||||
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) {
|
||||
console.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()} />
|
||||
}
|
||||
165
packages/ui/src/components/message-item.tsx
Normal file
165
packages/ui/src/components/message-item.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import MessagePart from "./message-part"
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
isQueued?: boolean
|
||||
parts?: ClientPart[]
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const isUser = () => props.message.type === "user"
|
||||
const timestamp = () => {
|
||||
const date = new Date(props.message.timestamp)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const messageParts = () => props.parts ?? props.message.parts
|
||||
|
||||
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.message.id)
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<div class={containerClass()}>
|
||||
<div class="flex justify-between items-center gap-2.5 pb-0.5">
|
||||
<div class="flex flex-col">
|
||||
<Show when={isUser()}>
|
||||
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
|
||||
</Show>
|
||||
|
||||
<Show when={!isUser()}>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-[var(--message-assistant-border)]">
|
||||
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={isUser() && props.onRevert}>
|
||||
<button
|
||||
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
|
||||
onClick={handleRevert}
|
||||
title="Revert to this message"
|
||||
aria-label="Revert to this message"
|
||||
>
|
||||
Revert to
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={isUser() && props.onFork}>
|
||||
<button
|
||||
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
|
||||
onClick={() => props.onFork?.(props.message.id)}
|
||||
title="Fork from this message"
|
||||
aria-label="Fork from this message"
|
||||
>
|
||||
Fork
|
||||
</button>
|
||||
</Show>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.message.type}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
)}</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.message.status === "sending"}>
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.message.status === "error"}>
|
||||
<div class="message-error">⚠ Message failed to send</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
packages/ui/src/components/message-part.tsx
Normal file
105
packages/ui/src/components/message-part.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
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"} />
|
||||
</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>
|
||||
|
||||
|
||||
|
||||
<Match when={partType() === "reasoning"}>
|
||||
<Show when={preferences().showThinkingBlocks && partHasRenderableText(props.part)}>
|
||||
<div class="message-reasoning">
|
||||
<div class="reasoning-container">
|
||||
<div class="reasoning-header" onClick={handleReasoningClick}>
|
||||
<span class="reasoning-icon">{isReasoningExpanded() ? "▼" : "▶"}</span>
|
||||
<span class="reasoning-label">Reasoning</span>
|
||||
</div>
|
||||
<Show when={isReasoningExpanded()}>
|
||||
<div class={`${textContainerClass()} mt-2`}>
|
||||
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
705
packages/ui/src/components/message-stream.tsx
Normal file
705
packages/ui/src/components/message-stream.tsx
Normal file
@@ -0,0 +1,705 @@
|
||||
import { For, Show, createSignal, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import type { Message, MessageDisplayParts, SDKPart, MessageInfo, ClientPart } from "../types/message"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
// Import ToolState types from SDK
|
||||
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
|
||||
|
||||
// Type guards
|
||||
function isToolStateRunning(state: ToolState): state is ToolStateRunning {
|
||||
return state.status === "running"
|
||||
}
|
||||
|
||||
function isToolStateCompleted(state: ToolState): state is ToolStateCompleted {
|
||||
return state.status === "completed"
|
||||
}
|
||||
|
||||
function isToolStateError(state: ToolState): state is ToolStateError {
|
||||
return state.status === "error"
|
||||
}
|
||||
|
||||
// Type guard to check if a part is a tool part
|
||||
function isToolPart(part: ClientPart): part is ToolCallPart {
|
||||
return part.type === "tool"
|
||||
}
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import Kbd from "./kbd"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
const SCROLL_OFFSET = 64
|
||||
const SCROLL_DIRECTION_THRESHOLD = 10
|
||||
|
||||
interface TaskSessionLocation {
|
||||
sessionId: string
|
||||
instanceId: string
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
const messageScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Format tokens like TUI (e.g., "110K", "1.2M")
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`
|
||||
} else if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(0)}K`
|
||||
}
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
// Format session info for the session view header
|
||||
function formatSessionInfo(usageTokens: number, contextWindow: number, usagePercent: number | null): string {
|
||||
if (contextWindow > 0) {
|
||||
const windowStr = formatTokens(contextWindow)
|
||||
const usageStr = formatTokens(usageTokens)
|
||||
const percent = usagePercent ?? Math.min(100, Math.max(0, Math.round((usageTokens / contextWindow) * 100)))
|
||||
return `${usageStr} of ${windowStr} (${percent}%)`
|
||||
}
|
||||
|
||||
return formatTokens(usageTokens)
|
||||
}
|
||||
|
||||
interface MessageStreamProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messages: Message[]
|
||||
messagesInfo?: Map<string, MessageInfo>
|
||||
revert?: {
|
||||
messageID: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
}
|
||||
|
||||
interface MessageDisplayItem {
|
||||
type: "message"
|
||||
message: Message
|
||||
combinedParts: ClientPart[]
|
||||
isQueued: boolean
|
||||
messageInfo?: MessageInfo
|
||||
}
|
||||
|
||||
interface ToolDisplayItem {
|
||||
type: "tool"
|
||||
key: string
|
||||
toolPart: ToolCallPart
|
||||
messageInfo?: MessageInfo
|
||||
messageId: string
|
||||
messageVersion: number
|
||||
partVersion: number
|
||||
}
|
||||
|
||||
type DisplayItem = MessageDisplayItem | ToolDisplayItem
|
||||
|
||||
interface MessageCacheEntry {
|
||||
message: Message
|
||||
version: number
|
||||
showThinking: boolean
|
||||
isQueued: boolean
|
||||
messageInfo?: MessageInfo
|
||||
displayParts: MessageDisplayParts
|
||||
item: MessageDisplayItem
|
||||
}
|
||||
|
||||
interface ToolCacheEntry {
|
||||
toolPart: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
signature: string
|
||||
contentKey: string
|
||||
item: ToolDisplayItem
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface SessionCache {
|
||||
messageItemCache: Map<string, MessageCacheEntry>
|
||||
toolItemCache: Map<string, ToolCacheEntry>
|
||||
}
|
||||
|
||||
const sessionCaches = new Map<string, SessionCache>()
|
||||
|
||||
function getSessionCache(instanceId: string, sessionId: string): SessionCache {
|
||||
const key = `${instanceId}:${sessionId}`
|
||||
let cache = sessionCaches.get(key)
|
||||
if (!cache) {
|
||||
cache = {
|
||||
messageItemCache: new Map(),
|
||||
toolItemCache: new Map(),
|
||||
}
|
||||
sessionCaches.set(key, cache)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
export default function MessageStream(props: MessageStreamProps) {
|
||||
const { preferences } = useConfig()
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
|
||||
const sessionCache = getSessionCache(props.instanceId, props.sessionId)
|
||||
let messageItemCache = sessionCache.messageItemCache
|
||||
let toolItemCache = sessionCache.toolItemCache
|
||||
let scrollAnimationFrame: number | null = null
|
||||
let lastKnownScrollTop = 0
|
||||
|
||||
const makeScrollKey = (instanceId: string, sessionId: string) => `${instanceId}:${sessionId}`
|
||||
|
||||
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
|
||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||
|
||||
function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string {
|
||||
const messageId = message.id
|
||||
const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}`
|
||||
return `${messageId}:${partId}`
|
||||
}
|
||||
|
||||
function createToolContentKey(toolPart: ClientPart, messageInfo?: MessageInfo): string {
|
||||
const state = isToolPart(toolPart) ? toolPart.state : undefined
|
||||
const version = typeof toolPart?.version === "number" ? toolPart.version : 0
|
||||
const status = state?.status ?? "unknown"
|
||||
return `${toolPart.id}:${version}:${status}`
|
||||
}
|
||||
|
||||
const sessionInfo = createMemo(() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: null,
|
||||
},
|
||||
)
|
||||
|
||||
const formattedSessionInfo = createMemo(() => {
|
||||
const info = sessionInfo()
|
||||
return formatSessionInfo(info.contextUsageTokens, info.contextWindow, info.contextUsagePercent)
|
||||
})
|
||||
|
||||
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element
|
||||
const distance = scrollHeight - (scrollTop + clientHeight)
|
||||
return distance <= offset
|
||||
}
|
||||
|
||||
function isNearTop(element: HTMLDivElement, offset = SCROLL_OFFSET) {
|
||||
return element.scrollTop <= offset
|
||||
}
|
||||
|
||||
function scrollToBottom(options: { smooth?: boolean } = {}) {
|
||||
if (!containerRef) return
|
||||
|
||||
const behavior = options.smooth ? "smooth" : "auto"
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
|
||||
setAutoScroll(true)
|
||||
updateScrollIndicators(containerRef)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function scrollToTop(options: { smooth?: boolean } = {}) {
|
||||
if (!containerRef) return
|
||||
|
||||
const behavior = options.smooth ? "smooth" : "auto"
|
||||
setAutoScroll(false)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
containerRef.scrollTo({ top: 0, behavior })
|
||||
setShowScrollTopButton(false)
|
||||
updateScrollIndicators(containerRef)
|
||||
})
|
||||
}
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
if (!containerRef) return
|
||||
|
||||
if (scrollAnimationFrame !== null) {
|
||||
cancelAnimationFrame(scrollAnimationFrame)
|
||||
}
|
||||
|
||||
const isUserScroll = event.isTrusted
|
||||
|
||||
scrollAnimationFrame = requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
|
||||
const currentScrollTop = containerRef.scrollTop
|
||||
const movingUp = currentScrollTop < lastKnownScrollTop - SCROLL_DIRECTION_THRESHOLD
|
||||
lastKnownScrollTop = currentScrollTop
|
||||
|
||||
const atBottom = isNearBottom(containerRef)
|
||||
|
||||
if (isUserScroll) {
|
||||
if (movingUp && !atBottom && autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
} else if (!movingUp && atBottom && !autoScroll()) {
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}
|
||||
|
||||
updateScrollIndicators(containerRef)
|
||||
scrollAnimationFrame = null
|
||||
})
|
||||
}
|
||||
|
||||
const messageView = createMemo(() => {
|
||||
const showThinking = preferences().showThinkingBlocks
|
||||
|
||||
const items: DisplayItem[] = []
|
||||
const newMessageCache = new Map<string, MessageCacheEntry>()
|
||||
const newToolCache = new Map<string, ToolCacheEntry>()
|
||||
const tokenSegments: string[] = []
|
||||
|
||||
let lastAssistantIndex = -1
|
||||
for (let i = props.messages.length - 1; i >= 0; i--) {
|
||||
if (props.messages[i].type === "assistant") {
|
||||
lastAssistantIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tokenSegments.push(`count:${props.messages.length}`)
|
||||
tokenSegments.push(`revert:${props.revert?.messageID ?? ""}`)
|
||||
tokenSegments.push(`thinking:${showThinking ? 1 : 0}`)
|
||||
|
||||
for (let index = 0; index < props.messages.length; index++) {
|
||||
const message = props.messages[index]
|
||||
const messageInfo = props.messagesInfo?.get(message.id)
|
||||
|
||||
if (props.revert?.messageID && message.id === props.revert.messageID) {
|
||||
break
|
||||
}
|
||||
|
||||
tokenSegments.push(`${message.id}:${message.version ?? 0}:${message.status}:${message.parts.length}`)
|
||||
|
||||
const baseDisplayParts = message.displayParts
|
||||
const displayParts: MessageDisplayParts =
|
||||
!baseDisplayParts || baseDisplayParts.showThinking !== showThinking
|
||||
? computeDisplayParts(message, showThinking)
|
||||
: (baseDisplayParts as MessageDisplayParts)
|
||||
|
||||
const combinedParts = displayParts.combined
|
||||
const version = message.version ?? 0
|
||||
const isQueued = message.type === "user" && (lastAssistantIndex === -1 || index > lastAssistantIndex)
|
||||
|
||||
const hasRenderableContent =
|
||||
message.type !== "assistant" ||
|
||||
combinedParts.length > 0 ||
|
||||
Boolean(messageInfo && messageInfo.role === "assistant" && messageInfo.error) ||
|
||||
message.status === "error"
|
||||
|
||||
if (hasRenderableContent) {
|
||||
const cacheEntry = messageItemCache.get(message.id)
|
||||
if (
|
||||
cacheEntry &&
|
||||
cacheEntry.version === version &&
|
||||
cacheEntry.showThinking === showThinking &&
|
||||
cacheEntry.isQueued === isQueued &&
|
||||
cacheEntry.messageInfo === messageInfo
|
||||
) {
|
||||
cacheEntry.displayParts = displayParts
|
||||
cacheEntry.version = version
|
||||
cacheEntry.showThinking = showThinking
|
||||
cacheEntry.isQueued = isQueued
|
||||
cacheEntry.messageInfo = messageInfo
|
||||
cacheEntry.item.message = message
|
||||
cacheEntry.item.combinedParts = combinedParts
|
||||
cacheEntry.item.isQueued = isQueued
|
||||
cacheEntry.item.messageInfo = messageInfo
|
||||
newMessageCache.set(message.id, cacheEntry)
|
||||
items.push(cacheEntry.item)
|
||||
} else {
|
||||
const messageItem: MessageDisplayItem = {
|
||||
type: "message",
|
||||
message,
|
||||
combinedParts,
|
||||
isQueued,
|
||||
messageInfo,
|
||||
}
|
||||
newMessageCache.set(message.id, {
|
||||
message,
|
||||
version,
|
||||
showThinking,
|
||||
isQueued,
|
||||
messageInfo,
|
||||
displayParts,
|
||||
item: messageItem,
|
||||
})
|
||||
items.push(messageItem)
|
||||
}
|
||||
}
|
||||
|
||||
const toolParts = displayParts.tool.filter(isToolPart)
|
||||
for (let toolIndex = 0; toolIndex < toolParts.length; toolIndex++) {
|
||||
const toolPart = toolParts[toolIndex]
|
||||
const originalIndex = displayParts.tool.indexOf(toolPart)
|
||||
const toolKey = toolPart?.id || `${message.id}-tool-${originalIndex}`
|
||||
const messageVersion = typeof message.version === "number" ? message.version : 0
|
||||
const partVersion = typeof toolPart?.version === "number" ? toolPart.version : 0
|
||||
|
||||
const toolSignature = createToolSignature(message, toolPart, originalIndex, messageInfo)
|
||||
const contentKey = createToolContentKey(toolPart, messageInfo)
|
||||
tokenSegments.push(`tool:${toolKey}:${partVersion}`)
|
||||
const toolEntry = toolItemCache.get(toolKey)
|
||||
|
||||
if (toolEntry && toolEntry.signature === toolSignature) {
|
||||
if (toolEntry.contentKey !== contentKey) {
|
||||
const updatedItem: ToolDisplayItem = {
|
||||
...toolEntry.item,
|
||||
toolPart,
|
||||
messageInfo,
|
||||
messageId: message.id,
|
||||
messageVersion,
|
||||
partVersion,
|
||||
}
|
||||
toolEntry.toolPart = toolPart
|
||||
toolEntry.messageInfo = messageInfo
|
||||
toolEntry.signature = toolSignature
|
||||
toolEntry.contentKey = contentKey
|
||||
toolEntry.item = updatedItem
|
||||
console.debug("[ToolCall] update", toolKey, toolPart.state?.status)
|
||||
newToolCache.set(toolKey, toolEntry)
|
||||
items.push(updatedItem)
|
||||
} else {
|
||||
const cachedItem = toolEntry.item
|
||||
cachedItem.toolPart = toolPart
|
||||
cachedItem.messageInfo = messageInfo
|
||||
cachedItem.messageId = message.id
|
||||
cachedItem.messageVersion = messageVersion
|
||||
cachedItem.partVersion = partVersion
|
||||
toolEntry.toolPart = toolPart
|
||||
toolEntry.messageInfo = messageInfo
|
||||
newToolCache.set(toolKey, toolEntry)
|
||||
items.push(cachedItem)
|
||||
}
|
||||
} else {
|
||||
const toolItem: ToolDisplayItem = {
|
||||
type: "tool",
|
||||
key: toolKey,
|
||||
toolPart,
|
||||
messageInfo,
|
||||
messageId: message.id,
|
||||
messageVersion,
|
||||
partVersion,
|
||||
}
|
||||
console.debug("[ToolCall] create", toolKey, toolPart.state?.status)
|
||||
newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, contentKey, item: toolItem })
|
||||
items.push(toolItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messageItemCache = newMessageCache
|
||||
toolItemCache = newToolCache
|
||||
sessionCache.messageItemCache = messageItemCache
|
||||
sessionCache.toolItemCache = toolItemCache
|
||||
|
||||
tokenSegments.push(`items:${items.length}`)
|
||||
|
||||
if (items.length > 0) {
|
||||
const tail = items[items.length - 1]
|
||||
if (tail.type === "message") {
|
||||
tokenSegments.push(`tail:${tail.message.id}:${tail.message.version ?? 0}`)
|
||||
} else {
|
||||
tokenSegments.push(`tail:${tail.key}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { items, token: tokenSegments.join("|") }
|
||||
})
|
||||
|
||||
const displayItems = () => messageView().items
|
||||
const changeToken = () => messageView().token
|
||||
|
||||
function updateScrollIndicators(element: HTMLDivElement) {
|
||||
const itemsLength = displayItems().length
|
||||
setShowScrollBottomButton(!isNearBottom(element) && itemsLength > 0)
|
||||
setShowScrollTopButton(!isNearTop(element) && itemsLength > 0)
|
||||
persistScrollState()
|
||||
}
|
||||
|
||||
function getActiveScrollKey() {
|
||||
return containerRef?.dataset.scrollKey || scrollStateKey()
|
||||
}
|
||||
|
||||
function persistScrollState() {
|
||||
if (!containerRef) return
|
||||
const key = getActiveScrollKey()
|
||||
messageScrollState.set(key, {
|
||||
scrollTop: containerRef.scrollTop,
|
||||
autoScroll: autoScroll(),
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const key = scrollStateKey()
|
||||
if (containerRef) {
|
||||
containerRef.dataset.scrollKey = key
|
||||
}
|
||||
const savedState = messageScrollState.get(key)
|
||||
const shouldAutoScroll = savedState?.autoScroll ?? true
|
||||
|
||||
setAutoScroll(shouldAutoScroll)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
|
||||
if (savedState) {
|
||||
if (shouldAutoScroll) {
|
||||
scrollToBottom({ smooth: false })
|
||||
} else {
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
containerRef.scrollTop = Math.min(savedState.scrollTop, maxScrollTop)
|
||||
updateScrollIndicators(containerRef)
|
||||
}
|
||||
} else {
|
||||
scrollToBottom({ smooth: false })
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (containerRef) {
|
||||
messageScrollState.set(key, {
|
||||
scrollTop: containerRef.scrollTop,
|
||||
autoScroll: autoScroll(),
|
||||
})
|
||||
if (containerRef.dataset.scrollKey === key) {
|
||||
delete containerRef.dataset.scrollKey
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let previousToken: string | undefined
|
||||
createEffect(() => {
|
||||
const token = changeToken()
|
||||
const shouldScroll = autoScroll()
|
||||
|
||||
if (!token || token === previousToken) {
|
||||
return
|
||||
}
|
||||
|
||||
previousToken = token
|
||||
|
||||
if (!shouldScroll) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (displayItems().length === 0) {
|
||||
setShowScrollBottomButton(false)
|
||||
setShowScrollTopButton(false)
|
||||
setAutoScroll(true)
|
||||
persistScrollState()
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (scrollAnimationFrame !== null) {
|
||||
cancelAnimationFrame(scrollAnimationFrame)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<div class="connection-status">
|
||||
<div class="connection-status-text connection-status-info flex items-center gap-2 text-sm font-medium">
|
||||
<span>{formattedSessionInfo()}</span>
|
||||
</div>
|
||||
<div class="connection-status-text connection-status-shortcut flex items-center gap-2 text-sm font-medium">
|
||||
<span>Command Palette</span>
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</div>
|
||||
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
Connected
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
Connecting...
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
Disconnected
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} class="message-stream" onScroll={handleScroll}>
|
||||
<Show when={!props.loading && displayItems().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>
|
||||
|
||||
<For each={displayItems()} fallback={null}>
|
||||
{(item) => {
|
||||
if (item.type === "message") {
|
||||
return (
|
||||
<MessageItem
|
||||
message={item.message}
|
||||
messageInfo={item.messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={item.isQueued}
|
||||
parts={item.combinedParts}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
/>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
const toolPart = item.toolPart
|
||||
|
||||
const taskSessionId =
|
||||
(isToolStateRunning(toolPart.state) || isToolStateCompleted(toolPart.state) || isToolStateError(toolPart.state))
|
||||
? toolPart.state.metadata?.sessionId === "string" ? toolPart.state.metadata.sessionId : ""
|
||||
: ""
|
||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
||||
|
||||
const handleGoToTaskSession = (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!taskLocation) return
|
||||
navigateToTaskSession(taskLocation)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-message" data-key={item.key}>
|
||||
<div class="tool-call-header-label">
|
||||
<div class="tool-call-header-meta">
|
||||
<span class="tool-call-icon">🔧</span>
|
||||
<span>Tool Call</span>
|
||||
<span class="tool-name">{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={toolPart}
|
||||
toolCallId={item.key}
|
||||
messageId={item.messageId}
|
||||
messageVersion={item.messageVersion}
|
||||
partVersion={item.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToTop({ smooth: true })}
|
||||
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({ smooth: true })}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
packages/ui/src/components/model-selector.tsx
Normal file
140
packages/ui/src/components/model-selector.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
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 Kbd from "./kbd"
|
||||
|
||||
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(console.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>
|
||||
<span class="hint sidebar-selector-hint">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
324
packages/ui/src/components/opencode-binary-selector.tsx
Normal file
324
packages/ui/src/components/opencode-binary-selector.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
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"
|
||||
|
||||
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 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(console.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 window.electronAPI.validateOpenCodeBinary(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() {
|
||||
try {
|
||||
const path = await window.electronAPI.selectOpenCodeBinary()
|
||||
if (!path) return
|
||||
|
||||
setCustomPath(path)
|
||||
await handleValidateAndAdd(path)
|
||||
} catch (error) {
|
||||
setValidationError(error instanceof Error ? error.message : "Failed to select binary")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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={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>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenCodeBinarySelector
|
||||
893
packages/ui/src/components/prompt-input.tsx
Normal file
893
packages/ui/src/components/prompt-input.tsx
Normal file
@@ -0,0 +1,893 @@
|
||||
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
|
||||
import UnifiedPicker from "./unified-picker"
|
||||
import { addToHistory, getHistory } from "../stores/message-history"
|
||||
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
import type { Agent } from "../types/session"
|
||||
import Kbd from "./kbd"
|
||||
import HintRow from "./hint-row"
|
||||
import { getActiveInstance } from "../stores/instances"
|
||||
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
|
||||
|
||||
interface PromptInputProps {
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
sessionId: string
|
||||
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
|
||||
onRunShell?: (command: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
escapeInDebounce?: boolean
|
||||
}
|
||||
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const [prompt, setPromptInternal] = createSignal("")
|
||||
const [history, setHistory] = createSignal<string[]>([])
|
||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||
const [isFocused, setIsFocused] = createSignal(false)
|
||||
const [showPicker, setShowPicker] = createSignal(false)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
||||
const [isDragging, setIsDragging] = createSignal(false)
|
||||
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set<number>())
|
||||
const [pasteCount, setPasteCount] = createSignal(0)
|
||||
const [imageCount, setImageCount] = createSignal(0)
|
||||
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
||||
let textareaRef: HTMLTextAreaElement | undefined
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
|
||||
|
||||
|
||||
|
||||
const attachments = () => getAttachments(props.instanceId, props.sessionId)
|
||||
const instanceAgents = () => agents().get(props.instanceId) || []
|
||||
|
||||
const setPrompt = (value: string) => {
|
||||
setPromptInternal(value)
|
||||
setSessionDraftPrompt(props.instanceId, props.sessionId, value)
|
||||
}
|
||||
|
||||
const clearPrompt = () => {
|
||||
clearSessionDraftPrompt(props.instanceId, props.sessionId)
|
||||
setPromptInternal("")
|
||||
setHistoryDraft(null)
|
||||
setMode("normal")
|
||||
}
|
||||
|
||||
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
||||
let highestPaste = 0
|
||||
let highestImage = 0
|
||||
|
||||
for (const match of currentPrompt.matchAll(/\[pasted #(\d+)\]/g)) {
|
||||
const value = Number.parseInt(match[1], 10)
|
||||
if (!Number.isNaN(value)) {
|
||||
highestPaste = Math.max(highestPaste, value)
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of sessionAttachments) {
|
||||
if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(/pasted #(\d+)/)
|
||||
if (placeholderMatch) {
|
||||
const value = Number.parseInt(placeholderMatch[1], 10)
|
||||
if (!Number.isNaN(value)) {
|
||||
highestPaste = Math.max(highestPaste, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch = attachment.display.match(/Image #(\d+)/)
|
||||
if (imageMatch) {
|
||||
const value = Number.parseInt(imageMatch[1], 10)
|
||||
if (!Number.isNaN(value)) {
|
||||
highestImage = Math.max(highestImage, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of currentPrompt.matchAll(/\[Image #(\d+)\]/g)) {
|
||||
const value = Number.parseInt(match[1], 10)
|
||||
if (!Number.isNaN(value)) {
|
||||
highestImage = Math.max(highestImage, value)
|
||||
}
|
||||
}
|
||||
|
||||
setPasteCount(highestPaste)
|
||||
setImageCount(highestImage)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => `${props.instanceId}:${props.sessionId}`,
|
||||
() => {
|
||||
const instanceId = props.instanceId
|
||||
const sessionId = props.sessionId
|
||||
|
||||
onCleanup(() => {
|
||||
setSessionDraftPrompt(instanceId, sessionId, prompt())
|
||||
})
|
||||
|
||||
const storedPrompt = getSessionDraftPrompt(instanceId, sessionId)
|
||||
const currentAttachments = untrack(() => getAttachments(instanceId, sessionId))
|
||||
|
||||
setPromptInternal(storedPrompt)
|
||||
setSessionDraftPrompt(instanceId, sessionId, storedPrompt)
|
||||
setHistoryIndex(-1)
|
||||
setHistoryDraft(null)
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
syncAttachmentCounters(storedPrompt, currentAttachments)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
function handleRemoveAttachment(attachmentId: string) {
|
||||
const currentAttachments = attachments()
|
||||
const attachment = currentAttachments.find((a) => a.id === attachmentId)
|
||||
|
||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||
|
||||
if (attachment) {
|
||||
const currentPrompt = prompt()
|
||||
let newPrompt = currentPrompt
|
||||
|
||||
if (attachment.source.type === "file") {
|
||||
if (attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch = attachment.display.match(/\[Image #(\d+)\]/)
|
||||
if (imageMatch) {
|
||||
const placeholder = `[Image #${imageMatch[1]}]`
|
||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
} else {
|
||||
const filename = attachment.filename
|
||||
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
} else if (attachment.source.type === "agent") {
|
||||
const agentName = attachment.filename
|
||||
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
|
||||
} else if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(/pasted #(\d+)/)
|
||||
if (placeholderMatch) {
|
||||
const placeholder = `[pasted #${placeholderMatch[1]}]`
|
||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
}
|
||||
|
||||
setPrompt(newPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault()
|
||||
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) continue
|
||||
|
||||
const count = imageCount() + 1
|
||||
setImageCount(count)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const base64Data = (reader.result as string).split(",")[1]
|
||||
const display = `[Image #${count}]`
|
||||
const filename = `image-${count}.png`
|
||||
|
||||
const attachment = createFileAttachment(
|
||||
filename,
|
||||
filename,
|
||||
"image/png",
|
||||
new TextEncoder().encode(base64Data),
|
||||
props.instanceFolder,
|
||||
)
|
||||
attachment.url = `data:image/png;base64,${base64Data}`
|
||||
attachment.display = display
|
||||
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||
|
||||
const textarea = textareaRef
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = prompt()
|
||||
const placeholder = `[Image #${count}]`
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + placeholder.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const pastedText = e.clipboardData?.getData("text/plain")
|
||||
if (!pastedText) return
|
||||
|
||||
const lineCount = pastedText.split("\n").length
|
||||
const charCount = pastedText.length
|
||||
|
||||
const isLongPaste = charCount > 150 || lineCount > 3
|
||||
|
||||
if (isLongPaste) {
|
||||
e.preventDefault()
|
||||
|
||||
const count = pasteCount() + 1
|
||||
setPasteCount(count)
|
||||
|
||||
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
|
||||
const display = `pasted #${count} (${summary})`
|
||||
const filename = `paste-${count}.txt`
|
||||
|
||||
const attachment = createTextAttachment(pastedText, display, filename)
|
||||
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||
|
||||
const textarea = textareaRef
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = prompt()
|
||||
const placeholder = `[pasted #${count}]`
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + placeholder.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
|
||||
const isInputElement =
|
||||
activeElement?.tagName === "INPUT" ||
|
||||
activeElement?.tagName === "TEXTAREA" ||
|
||||
activeElement?.tagName === "SELECT" ||
|
||||
activeElement?.isContentEditable
|
||||
|
||||
if (isInputElement) return
|
||||
|
||||
const isModifierKey = e.ctrlKey || e.metaKey || e.altKey
|
||||
if (isModifierKey) return
|
||||
|
||||
const isSpecialKey =
|
||||
e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete"
|
||||
if (isSpecialKey) return
|
||||
|
||||
if (e.key.length === 1 && textareaRef && !props.disabled) {
|
||||
textareaRef.focus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleGlobalKeyDown)
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleGlobalKeyDown)
|
||||
})
|
||||
|
||||
void (async () => {
|
||||
const loaded = await getHistory(props.instanceFolder)
|
||||
setHistory(loaded)
|
||||
})()
|
||||
})
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const textarea = textareaRef
|
||||
if (!textarea) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentText = prompt()
|
||||
const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
const isShellMode = mode() === "shell"
|
||||
|
||||
if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !props.disabled) {
|
||||
e.preventDefault()
|
||||
setMode("shell")
|
||||
return
|
||||
}
|
||||
|
||||
if (showPicker() && e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handlePickerClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (isShellMode) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMode("normal")
|
||||
return
|
||||
}
|
||||
if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) {
|
||||
e.preventDefault()
|
||||
setMode("normal")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
const cursorPos = textarea.selectionStart
|
||||
const text = currentText
|
||||
|
||||
const pastePlaceholderRegex = /\[pasted #(\d+)\]/g
|
||||
let pasteMatch
|
||||
|
||||
while ((pasteMatch = pastePlaceholderRegex.exec(text)) !== null) {
|
||||
const placeholderStart = pasteMatch.index
|
||||
const placeholderEnd = pasteMatch.index + pasteMatch[0].length
|
||||
const pasteNumber = pasteMatch[1]
|
||||
|
||||
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
|
||||
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
|
||||
const isSelected =
|
||||
textarea.selectionStart <= placeholderStart &&
|
||||
textarea.selectionEnd >= placeholderEnd &&
|
||||
textarea.selectionStart !== textarea.selectionEnd
|
||||
|
||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||
e.preventDefault()
|
||||
|
||||
const currentAttachments = attachments()
|
||||
const attachment = currentAttachments.find(
|
||||
(a) => a.source.type === "text" && a.display.includes(`pasted #${pasteNumber}`),
|
||||
)
|
||||
|
||||
if (attachment) {
|
||||
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
||||
}
|
||||
|
||||
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
|
||||
setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(placeholderStart, placeholderStart)
|
||||
}, 0)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const imagePlaceholderRegex = /\[Image #(\d+)\]/g
|
||||
let imageMatch
|
||||
|
||||
while ((imageMatch = imagePlaceholderRegex.exec(text)) !== null) {
|
||||
const placeholderStart = imageMatch.index
|
||||
const placeholderEnd = imageMatch.index + imageMatch[0].length
|
||||
const imageNumber = imageMatch[1]
|
||||
|
||||
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
|
||||
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
|
||||
const isSelected =
|
||||
textarea.selectionStart <= placeholderStart &&
|
||||
textarea.selectionEnd >= placeholderEnd &&
|
||||
textarea.selectionStart !== textarea.selectionEnd
|
||||
|
||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||
e.preventDefault()
|
||||
|
||||
const currentAttachments = attachments()
|
||||
const attachment = currentAttachments.find(
|
||||
(a) =>
|
||||
a.source.type === "file" &&
|
||||
a.mediaType.startsWith("image/") &&
|
||||
a.display.includes(`Image #${imageNumber}`),
|
||||
)
|
||||
|
||||
if (attachment) {
|
||||
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
||||
}
|
||||
|
||||
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
|
||||
setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(placeholderStart, placeholderStart)
|
||||
}, 0)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const mentionRegex = /@(\S+)/g
|
||||
let mentionMatch
|
||||
|
||||
while ((mentionMatch = mentionRegex.exec(text)) !== null) {
|
||||
const mentionStart = mentionMatch.index
|
||||
const mentionEnd = mentionMatch.index + mentionMatch[0].length
|
||||
const name = mentionMatch[1]
|
||||
|
||||
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === mentionEnd
|
||||
const isDeletingFromStart = e.key === "Delete" && cursorPos === mentionStart
|
||||
const isSelected =
|
||||
textarea.selectionStart <= mentionStart &&
|
||||
textarea.selectionEnd >= mentionEnd &&
|
||||
textarea.selectionStart !== textarea.selectionEnd
|
||||
|
||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||
const currentAttachments = attachments()
|
||||
const attachment = currentAttachments.find(
|
||||
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
|
||||
)
|
||||
|
||||
if (attachment) {
|
||||
e.preventDefault()
|
||||
|
||||
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
||||
|
||||
setIgnoredAtPositions((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(mentionStart)
|
||||
return next
|
||||
})
|
||||
|
||||
const newText = text.substring(0, mentionStart) + text.substring(mentionEnd)
|
||||
setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(mentionStart, mentionStart)
|
||||
}, 0)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
if (showPicker()) {
|
||||
handlePickerClose()
|
||||
}
|
||||
handleSend()
|
||||
return
|
||||
}
|
||||
|
||||
const atStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
const currentHistory = history()
|
||||
|
||||
if (e.key === "ArrowUp" && !showPicker() && atStart && currentHistory.length > 0) {
|
||||
e.preventDefault()
|
||||
if (historyIndex() === -1) {
|
||||
setHistoryDraft(prompt())
|
||||
}
|
||||
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, currentHistory.length - 1)
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(currentHistory[newIndex])
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown" && !showPicker() && historyIndex() >= 0) {
|
||||
e.preventDefault()
|
||||
const newIndex = historyIndex() - 1
|
||||
if (newIndex >= 0) {
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(currentHistory[newIndex])
|
||||
} else {
|
||||
setHistoryIndex(-1)
|
||||
const draft = historyDraft()
|
||||
setPrompt(draft ?? "")
|
||||
setHistoryDraft(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = prompt().trim()
|
||||
const currentAttachments = attachments()
|
||||
if (props.disabled || !text) return
|
||||
|
||||
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
|
||||
const isShellMode = mode() === "shell"
|
||||
|
||||
clearPrompt()
|
||||
clearAttachments(props.instanceId, props.sessionId)
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
setPasteCount(0)
|
||||
setImageCount(0)
|
||||
setHistoryDraft(null)
|
||||
|
||||
try {
|
||||
await addToHistory(props.instanceFolder, resolvedPrompt)
|
||||
const updated = await getHistory(props.instanceFolder)
|
||||
setHistory(updated)
|
||||
setHistoryIndex(-1)
|
||||
if (isShellMode) {
|
||||
if (props.onRunShell) {
|
||||
await props.onRunShell(resolvedPrompt)
|
||||
} else {
|
||||
await props.onSend(resolvedPrompt, [])
|
||||
}
|
||||
} else {
|
||||
await props.onSend(text, currentAttachments)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error)
|
||||
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
textareaRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
const value = target.value
|
||||
setPrompt(value)
|
||||
setHistoryIndex(-1)
|
||||
setHistoryDraft(null)
|
||||
|
||||
const cursorPos = target.selectionStart
|
||||
const textBeforeCursor = value.substring(0, cursorPos)
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
|
||||
|
||||
const previousAtPosition = atPosition()
|
||||
|
||||
if (lastAtIndex === -1) {
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
|
||||
setIgnoredAtPositions((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(previousAtPosition)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
const textAfterAt = value.substring(lastAtIndex + 1, cursorPos)
|
||||
const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n")
|
||||
|
||||
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
|
||||
if (!ignoredAtPositions().has(lastAtIndex)) {
|
||||
setAtPosition(lastAtIndex)
|
||||
setSearchQuery(textAfterAt)
|
||||
setShowPicker(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
}
|
||||
|
||||
function handlePickerSelect(item: { type: "agent"; agent: Agent } | { type: "file"; file: { path: string; isGitFile: boolean } }) {
|
||||
if (item.type === "agent") {
|
||||
const agentName = item.agent.name
|
||||
const existingAttachments = attachments()
|
||||
const alreadyAttached = existingAttachments.some(
|
||||
(att) => att.source.type === "agent" && att.source.name === agentName,
|
||||
)
|
||||
|
||||
if (!alreadyAttached) {
|
||||
const attachment = createAgentAttachment(agentName)
|
||||
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||
}
|
||||
|
||||
const currentPrompt = prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textareaRef?.selectionStart || 0
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const attachmentText = `@${agentName}`
|
||||
const newPrompt = before + attachmentText + " " + after
|
||||
setPrompt(newPrompt)
|
||||
|
||||
setTimeout(() => {
|
||||
if (textareaRef) {
|
||||
const newCursorPos = pos + attachmentText.length + 1
|
||||
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
} else if (item.type === "file") {
|
||||
const path = item.file.path
|
||||
const isFolder = path.endsWith("/")
|
||||
const filename = path.split("/").pop() || path
|
||||
|
||||
if (isFolder) {
|
||||
const currentPrompt = prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textareaRef?.selectionStart || 0
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos + 1)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const newPrompt = before + path + after
|
||||
setPrompt(newPrompt)
|
||||
setSearchQuery(path)
|
||||
|
||||
setTimeout(() => {
|
||||
if (textareaRef) {
|
||||
const newCursorPos = pos + 1 + path.length
|
||||
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const existingAttachments = attachments()
|
||||
const alreadyAttached = existingAttachments.some((att) => att.source.type === "file" && att.source.path === path)
|
||||
|
||||
if (!alreadyAttached) {
|
||||
const attachment = createFileAttachment(path, filename, "text/plain", undefined, props.instanceFolder)
|
||||
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||
}
|
||||
|
||||
const currentPrompt = prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textareaRef?.selectionStart || 0
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const attachmentText = `@${filename}`
|
||||
const newPrompt = before + attachmentText + " " + after
|
||||
setPrompt(newPrompt)
|
||||
|
||||
setTimeout(() => {
|
||||
if (textareaRef) {
|
||||
const newCursorPos = pos + attachmentText.length + 1
|
||||
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
function handlePickerClose() {
|
||||
const pos = atPosition()
|
||||
if (pos !== null) {
|
||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||
}
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
setTimeout(() => textareaRef?.focus(), 0)
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
const files = e.dataTransfer?.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const path = (file as File & { path?: string }).path || file.name
|
||||
const filename = file.name
|
||||
const mime = file.type || "text/plain"
|
||||
|
||||
const attachment = createFileAttachment(path, filename, mime, undefined, props.instanceFolder)
|
||||
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||
}
|
||||
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
const canSend = () => {
|
||||
if (props.disabled) return false
|
||||
const hasText = prompt().trim().length > 0
|
||||
if (mode() === "shell") return hasText
|
||||
return hasText || attachments().length > 0
|
||||
}
|
||||
|
||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
|
||||
|
||||
const instance = () => getActiveInstance()
|
||||
|
||||
return (
|
||||
<div class="prompt-input-container">
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
|
||||
style={
|
||||
isDragging()
|
||||
? "border-color: var(--accent-primary); background-color: rgba(0, 102, 255, 0.05);"
|
||||
: ""
|
||||
}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Show when={showPicker() && instance()}>
|
||||
<UnifiedPicker
|
||||
open={showPicker()}
|
||||
onClose={handlePickerClose}
|
||||
onSelect={handlePickerSelect}
|
||||
agents={instanceAgents()}
|
||||
instanceClient={instance()!.client}
|
||||
searchQuery={searchQuery()}
|
||||
textareaRef={textareaRef}
|
||||
workspaceFolder={props.instanceFolder}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-1 flex-col">
|
||||
<Show when={attachments().length > 0}>
|
||||
<div class="flex flex-wrap gap-1.5 border-b pb-2" style="border-color: var(--border-base);">
|
||||
<For each={attachments()}>
|
||||
{(attachment) => {
|
||||
const isImage = attachment.mediaType.startsWith("image/")
|
||||
return (
|
||||
<div class="attachment-chip">
|
||||
<Show
|
||||
when={isImage}
|
||||
fallback={
|
||||
<Show
|
||||
when={attachment.source.type === "text"}
|
||||
fallback={
|
||||
<Show
|
||||
when={attachment.source.type === "agent"}
|
||||
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 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="h-3 w-3" 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>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<img src={attachment.url} alt={attachment.filename} class="h-5 w-5 rounded object-cover" />
|
||||
</Show>
|
||||
<span>{attachment.source.type === "text" ? attachment.display : attachment.filename}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(attachment.id)}
|
||||
class="attachment-remove"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
placeholder={
|
||||
mode() === "shell"
|
||||
? "Run a shell command (Esc to exit)..."
|
||||
: "Type your message, @file, @agent, or paste images and text..."
|
||||
}
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={props.disabled}
|
||||
rows={4}
|
||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autoCapitalize="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
onClick={handleSend}
|
||||
disabled={!canSend()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Show
|
||||
when={mode() === "shell"}
|
||||
fallback={<span class="send-icon">▶</span>}
|
||||
>
|
||||
<svg class="shell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8l5 4-5 4" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h6" />
|
||||
</svg>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-input-hints">
|
||||
<div class="flex justify-between w-full gap-4">
|
||||
<HintRow>
|
||||
<Show
|
||||
when={props.escapeInDebounce}
|
||||
fallback={
|
||||
<>
|
||||
<Kbd>Enter</Kbd> for new line • <Kbd shortcut="cmd+enter" /> to send • <Kbd>@</Kbd> for files/agents • <Kbd>↑↓</Kbd> for history
|
||||
<Show when={attachments().length > 0}>
|
||||
<span class="ml-2 text-xs" style="color: var(--text-muted);">• {attachments().length} file(s) attached</span>
|
||||
</Show>
|
||||
<span class="ml-2">
|
||||
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span class="font-medium" style="color: var(--status-warning);">
|
||||
Press <Kbd>Esc</Kbd> again to abort session
|
||||
</span>
|
||||
</Show>
|
||||
</HintRow>
|
||||
<Show when={mode() === "shell"}>
|
||||
<HintRow>Shell mode active</HintRow>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
380
packages/ui/src/components/session-list.tsx
Normal file
380
packages/ui/src/components/session-list.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo, JSX } from "solid-js"
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { MessageSquare, Info, X, Copy } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import Kbd from "./kbd"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { formatShortcut } from "../lib/keyboard-utils"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
|
||||
|
||||
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
|
||||
onWidthChange?: (width: number) => void
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 200
|
||||
const MAX_WIDTH = 500
|
||||
const DEFAULT_WIDTH = 280
|
||||
const STORAGE_KEY = "opencode-session-sidebar-width"
|
||||
|
||||
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 [sidebarWidth, setSidebarWidth] = createSignal(DEFAULT_WIDTH)
|
||||
const [isResizing, setIsResizing] = createSignal(false)
|
||||
const [startX, setStartX] = createSignal(0)
|
||||
const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH)
|
||||
const infoShortcut = keyboardRegistry.get("switch-to-info")
|
||||
|
||||
const selectSession = (sessionId: string) => {
|
||||
props.onSelect(sessionId)
|
||||
}
|
||||
|
||||
let mouseMoveHandler: ((event: MouseEvent) => void) | null = null
|
||||
let mouseUpHandler: (() => void) | null = null
|
||||
let touchMoveHandler: ((event: TouchEvent) => void) | null = null
|
||||
let touchEndHandler: (() => void) | null = null
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const saved = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!saved) return
|
||||
|
||||
const width = Number.parseInt(saved, 10)
|
||||
if (Number.isFinite(width) && width >= MIN_WIDTH && width <= MAX_WIDTH) {
|
||||
setSidebarWidth(width)
|
||||
setStartWidth(width)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const width = sidebarWidth()
|
||||
window.localStorage.setItem(STORAGE_KEY, width.toString())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.onWidthChange?.(sidebarWidth())
|
||||
})
|
||||
|
||||
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) {
|
||||
console.error(`Failed to copy session ID ${sessionId}:`, error)
|
||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const clampWidth = (width: number) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
|
||||
|
||||
|
||||
const removeMouseListeners = () => {
|
||||
if (mouseMoveHandler) {
|
||||
document.removeEventListener("mousemove", mouseMoveHandler)
|
||||
mouseMoveHandler = null
|
||||
}
|
||||
if (mouseUpHandler) {
|
||||
document.removeEventListener("mouseup", mouseUpHandler)
|
||||
mouseUpHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
const removeTouchListeners = () => {
|
||||
if (touchMoveHandler) {
|
||||
document.removeEventListener("touchmove", touchMoveHandler)
|
||||
touchMoveHandler = null
|
||||
}
|
||||
if (touchEndHandler) {
|
||||
document.removeEventListener("touchend", touchEndHandler)
|
||||
touchEndHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
const stopResizing = () => {
|
||||
setIsResizing(false)
|
||||
removeMouseListeners()
|
||||
removeTouchListeners()
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!isResizing()) return
|
||||
const diff = event.clientX - startX()
|
||||
const newWidth = clampWidth(startWidth() + diff)
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
stopResizing()
|
||||
}
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (!isResizing()) return
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
const diff = touch.clientX - startX()
|
||||
const newWidth = clampWidth(startWidth() + diff)
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
stopResizing()
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setIsResizing(true)
|
||||
setStartX(event.clientX)
|
||||
setStartWidth(sidebarWidth())
|
||||
|
||||
mouseMoveHandler = handleMouseMove
|
||||
mouseUpHandler = handleMouseUp
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
event.preventDefault()
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
setIsResizing(true)
|
||||
setStartX(touch.clientX)
|
||||
setStartWidth(sidebarWidth())
|
||||
|
||||
touchMoveHandler = handleTouchMove
|
||||
touchEndHandler = handleTouchEnd
|
||||
|
||||
document.addEventListener("touchmove", handleTouchMove)
|
||||
document.addEventListener("touchend", handleTouchEnd)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
removeMouseListeners()
|
||||
removeTouchListeners()
|
||||
})
|
||||
|
||||
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>
|
||||
</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"
|
||||
style={{ width: `${sidebarWidth()}px` }}
|
||||
>
|
||||
<div
|
||||
class="session-resize-handle"
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionList
|
||||
190
packages/ui/src/components/session-picker.tsx
Normal file
190
packages/ui/src/components/session-picker.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
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"
|
||||
|
||||
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) {
|
||||
console.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
|
||||
63
packages/ui/src/components/session/context-usage-panel.tsx
Normal file
63
packages/ui/src/components/session/context-usage-panel.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createMemo, type Component } from "solid-js"
|
||||
import { getSessionInfo } from "../../stores/sessions"
|
||||
import { formatTokenTotal } from "../../lib/formatters"
|
||||
|
||||
interface ContextUsagePanelProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
const info = createMemo(
|
||||
() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: null,
|
||||
},
|
||||
)
|
||||
|
||||
const tokens = createMemo(() => info().tokens)
|
||||
const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0)
|
||||
const contextWindow = createMemo(() => info().contextWindow)
|
||||
const contextUsagePercent = createMemo(() => info().contextUsagePercent)
|
||||
|
||||
const costLabel = createMemo(() => {
|
||||
if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan"
|
||||
return `$${info().cost.toFixed(2)} spent`
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="session-context-panel border-r border-base border-b px-3 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Tokens (last call)</div>
|
||||
<div class="text-lg font-semibold text-primary">{formatTokenTotal(tokens())}</div>
|
||||
</div>
|
||||
<div class="text-xs text-primary/70 text-right leading-tight">{costLabel()}</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Context window usage</div>
|
||||
<div class="text-sm font-medium text-primary">{contextUsagePercent() !== null ? `${contextUsagePercent()}%` : "--"}</div>
|
||||
</div>
|
||||
<div class="text-sm text-primary/90">
|
||||
{contextWindow()
|
||||
? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}`
|
||||
: "Window size unavailable"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 h-1.5 rounded-full bg-base relative overflow-hidden">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full bg-accent-primary transition-[width]"
|
||||
style={{ width: contextUsagePercent() === null ? "0%" : `${contextUsagePercent()}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContextUsagePanel
|
||||
150
packages/ui/src/components/session/session-view.tsx
Normal file
150
packages/ui/src/components/session/session-view.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Show, createMemo, createEffect, onCleanup, type Component } from "solid-js"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import MessageStream from "../message-stream"
|
||||
import PromptInput from "../prompt-input"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions"
|
||||
|
||||
interface SessionViewProps {
|
||||
sessionId: string
|
||||
activeSessions: Map<string, Session>
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
escapeInDebounce: boolean
|
||||
}
|
||||
|
||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
const session = () => props.activeSessions.get(props.sessionId)
|
||||
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
||||
|
||||
createEffect(() => {
|
||||
const currentSession = session()
|
||||
if (currentSession) {
|
||||
loadMessages(props.instanceId, currentSession.id).catch(console.error)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
||||
}
|
||||
|
||||
async function handleRunShell(command: string) {
|
||||
await runShellCommand(props.instanceId, props.sessionId, command)
|
||||
}
|
||||
|
||||
function getUserMessageText(messageId: string): string | null {
|
||||
const currentSession = session()
|
||||
if (!currentSession) return null
|
||||
|
||||
const targetMessage = currentSession.messages.find((m) => m.id === messageId)
|
||||
const targetInfo = currentSession.messagesInfo.get(messageId)
|
||||
if (!targetMessage || targetInfo?.role !== "user") {
|
||||
return null
|
||||
}
|
||||
|
||||
const textParts = targetMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text")
|
||||
if (textParts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return textParts.map((p) => p.text).join("\n")
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error("Failed to revert:", error)
|
||||
alert("Failed to revert to message")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFork(messageId?: string) {
|
||||
if (!messageId) {
|
||||
console.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(console.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) {
|
||||
console.error("Failed to fork session:", error)
|
||||
alert("Failed to fork session")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
}
|
||||
>
|
||||
{(s) => (
|
||||
<div class="session-view">
|
||||
<MessageStream
|
||||
instanceId={props.instanceId}
|
||||
sessionId={s().id}
|
||||
messages={s().messages || []}
|
||||
messagesInfo={s().messagesInfo}
|
||||
revert={s().revert}
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onFork={handleFork}
|
||||
/>
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={s().id}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionView
|
||||
1207
packages/ui/src/components/tool-call.tsx
Normal file
1207
packages/ui/src/components/tool-call.tsx
Normal file
File diff suppressed because it is too large
Load Diff
291
packages/ui/src/components/unified-picker.tsx
Normal file
291
packages/ui/src/components/unified-picker.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||
import type { Agent } from "../types/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
|
||||
interface FileItem {
|
||||
path: string
|
||||
added?: number
|
||||
removed?: number
|
||||
isGitFile: boolean
|
||||
}
|
||||
|
||||
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
|
||||
workspaceFolder: string
|
||||
}
|
||||
|
||||
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
||||
const [isInitialized, setIsInitialized] = createSignal(false)
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
|
||||
async function fetchFiles(searchQuery: string) {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
if (allFiles().length === 0) {
|
||||
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder)
|
||||
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({
|
||||
path,
|
||||
isGitFile: false,
|
||||
}))
|
||||
setAllFiles(scannedFiles)
|
||||
}
|
||||
|
||||
const filteredFiles = searchQuery.trim()
|
||||
? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: allFiles()
|
||||
|
||||
setFiles(filteredFiles)
|
||||
setSelectedIndex(0)
|
||||
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef) {
|
||||
scrollContainerRef.scrollTop = 0
|
||||
}
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
console.error(`[UnifiedPicker] Failed to fetch files:`, error)
|
||||
setFiles([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
let lastQuery = ""
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open && !isInitialized()) {
|
||||
setIsInitialized(true)
|
||||
fetchFiles(props.searchQuery)
|
||||
lastQuery = props.searchQuery
|
||||
return
|
||||
}
|
||||
|
||||
if (props.open && props.searchQuery !== lastQuery) {
|
||||
lastQuery = props.searchQuery
|
||||
fetchFiles(props.searchQuery)
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
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={loading()}>
|
||||
<span class="ml-2">Loading...</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.path === file.path)
|
||||
const isFolder = file.path.endsWith("/")
|
||||
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
|
||||
BIN
packages/ui/src/images/CodeNomad-Icon.png
Normal file
BIN
packages/ui/src/images/CodeNomad-Icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
64
packages/ui/src/index.css
Normal file
64
packages/ui/src/index.css
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
51
packages/ui/src/lib/command-utils.ts
Normal file
51
packages/ui/src/lib/command-utils.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Command } from "./commands"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
||||
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
|
||||
|
||||
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") {
|
||||
alert("Select a session before running a custom command.")
|
||||
return
|
||||
}
|
||||
const args = promptForCommandArguments(cmd)
|
||||
if (args === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
|
||||
} catch (error) {
|
||||
console.error("Failed to run custom command:", error)
|
||||
alert("Failed to run custom command. Check the console for details.")
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
68
packages/ui/src/lib/commands.ts
Normal file
68
packages/ui/src/lib/commands.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface KeyboardShortcut {
|
||||
key: string
|
||||
meta?: boolean
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
alt?: boolean
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: string
|
||||
label: string | (() => string)
|
||||
description: string
|
||||
keywords?: string[]
|
||||
shortcut?: KeyboardShortcut
|
||||
action: () => void | Promise<void>
|
||||
category?: string
|
||||
}
|
||||
|
||||
export function createCommandRegistry() {
|
||||
const commands = new Map<string, Command>()
|
||||
|
||||
function register(command: Command) {
|
||||
commands.set(command.id, command)
|
||||
}
|
||||
|
||||
function unregister(id: string) {
|
||||
commands.delete(id)
|
||||
}
|
||||
|
||||
function get(id: string) {
|
||||
return commands.get(id)
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return Array.from(commands.values())
|
||||
}
|
||||
|
||||
function execute(id: string) {
|
||||
const command = commands.get(id)
|
||||
if (command) {
|
||||
return command.action()
|
||||
}
|
||||
}
|
||||
|
||||
function search(query: string) {
|
||||
if (!query) return getAll()
|
||||
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return getAll().filter((cmd) => {
|
||||
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
|
||||
const labelMatch = label.toLowerCase().includes(lowerQuery)
|
||||
const descMatch = cmd.description.toLowerCase().includes(lowerQuery)
|
||||
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(lowerQuery))
|
||||
return labelMatch || descMatch || keywordMatch
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
unregister,
|
||||
get,
|
||||
getAll,
|
||||
execute,
|
||||
search,
|
||||
}
|
||||
}
|
||||
|
||||
export type CommandRegistry = ReturnType<typeof createCommandRegistry>
|
||||
50
packages/ui/src/lib/diff-utils.ts
Normal file
50
packages/ui/src/lib/diff-utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
const HUNK_PATTERN = /(^|\n)@@/m
|
||||
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
|
||||
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
|
||||
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
|
||||
|
||||
function stripCodeFence(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.startsWith("```")) return trimmed
|
||||
const lines = trimmed.split("\n")
|
||||
if (lines.length < 2) return ""
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (!lastLine.startsWith("```")) return trimmed
|
||||
return lines.slice(1, -1).join("\n")
|
||||
}
|
||||
|
||||
export function normalizeDiffText(raw: string): string {
|
||||
if (!raw) return ""
|
||||
const withoutFence = stripCodeFence(raw.replace(/\r\n/g, "\n"))
|
||||
const lines = withoutFence.split("\n").map((line) => line.replace(/\s+$/u, ""))
|
||||
|
||||
let pendingFilePath: string | null = null
|
||||
const cleanedLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue
|
||||
if (BEGIN_PATCH_PATTERN.test(line)) {
|
||||
continue
|
||||
}
|
||||
const updateMatch = line.match(UPDATE_FILE_PATTERN)
|
||||
if (updateMatch) {
|
||||
pendingFilePath = updateMatch[1]?.trim() || null
|
||||
continue
|
||||
}
|
||||
cleanedLines.push(line)
|
||||
}
|
||||
|
||||
if (pendingFilePath && !FILE_MARKER_PATTERN.test(cleanedLines.join("\n"))) {
|
||||
cleanedLines.unshift(`+++ b/${pendingFilePath}`)
|
||||
cleanedLines.unshift(`--- a/${pendingFilePath}`)
|
||||
}
|
||||
|
||||
return cleanedLines.join("\n").trim()
|
||||
}
|
||||
|
||||
export function isRenderableDiffText(raw?: string | null): raw is string {
|
||||
if (!raw) return false
|
||||
const normalized = normalizeDiffText(raw)
|
||||
if (!normalized) return false
|
||||
return HUNK_PATTERN.test(normalized)
|
||||
}
|
||||
9
packages/ui/src/lib/formatters.ts
Normal file
9
packages/ui/src/lib/formatters.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function formatTokenTotal(value: number): string {
|
||||
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()
|
||||
}
|
||||
178
packages/ui/src/lib/hooks/use-app-lifecycle.ts
Normal file
178
packages/ui/src/lib/hooks/use-app-lifecycle.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
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 { addLog, updateInstance } from "../../stores/instances"
|
||||
import type { Instance } from "../../types/instance"
|
||||
|
||||
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 modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
|
||||
if (modelInput) {
|
||||
modelInput.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
code: "ArrowDown",
|
||||
keyCode: 40,
|
||||
which: 40,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
modelInput.dispatchEvent(event)
|
||||
}, 10)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
|
||||
if (agentTrigger) {
|
||||
agentTrigger.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
agentTrigger.dispatchEvent(event)
|
||||
}, 50)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
console.log("Session aborted successfully")
|
||||
} catch (error) {
|
||||
console.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)
|
||||
|
||||
window.electronAPI.onNewInstance(() => {
|
||||
options.handleNewInstanceRequest()
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceStarted(({ id, port, pid, binaryPath }) => {
|
||||
console.log("Instance started:", { id, port, pid, binaryPath })
|
||||
updateInstance(id, { port, pid, status: "ready", binaryPath })
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceError(({ id, error }) => {
|
||||
console.error("Instance error:", { id, error })
|
||||
updateInstance(id, { status: "error", error })
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceStopped(({ id }) => {
|
||||
console.log("Instance stopped:", id)
|
||||
updateInstance(id, { status: "stopped" })
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceLog(({ id, entry }) => {
|
||||
addLog(id, entry)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
})
|
||||
}
|
||||
450
packages/ui/src/lib/hooks/use-commands.ts
Normal file
450
packages/ui/src/lib/hooks/use-commands.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
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 {
|
||||
activeParentSessionId,
|
||||
activeSessionId as activeSessionMap,
|
||||
getSessionFamily,
|
||||
getSessions,
|
||||
setActiveSession,
|
||||
} from "../../stores/sessions"
|
||||
import { setSessionCompactionState } from "../../stores/session-compaction"
|
||||
import type { Instance } from "../../types/instance"
|
||||
|
||||
export interface UseCommandsOptions {
|
||||
preferences: Accessor<Preferences>
|
||||
toggleShowThinkingBlocks: () => void
|
||||
setDiffViewMode: (mode: "split" | "unified") => void
|
||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||
setDiagnosticsExpansion: (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
|
||||
}
|
||||
|
||||
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: "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])
|
||||
},
|
||||
})
|
||||
|
||||
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])
|
||||
},
|
||||
})
|
||||
|
||||
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: unknown) {
|
||||
setSessionCompactionState(instance.id, sessionId, false)
|
||||
console.error("Failed to compact session:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to compact session"
|
||||
alert(`Compact failed: ${message}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
let after = 0
|
||||
const revert = session.revert
|
||||
|
||||
if (revert?.messageID) {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const msg = session.messages[i]
|
||||
const info = session.messagesInfo.get(msg.id)
|
||||
if (info?.id === revert.messageID) {
|
||||
after = info.time?.created || 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let messageID = ""
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const msg = session.messages[i]
|
||||
const info = session.messagesInfo.get(msg.id)
|
||||
|
||||
if (msg.type === "user" && info?.time?.created) {
|
||||
if (after > 0 && info.time.created >= after) {
|
||||
continue
|
||||
}
|
||||
messageID = msg.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!messageID) {
|
||||
alert("Nothing to undo")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.session.revert({
|
||||
path: { id: sessionId },
|
||||
body: { messageID },
|
||||
})
|
||||
|
||||
const revertedMessage = session.messages.find((m) => m.id === messageID)
|
||||
const revertedInfo = session.messagesInfo.get(messageID)
|
||||
|
||||
if (revertedMessage && revertedInfo?.role === "user") {
|
||||
const textParts = revertedMessage.parts.filter((p) => p.type === "text")
|
||||
if (textParts.length > 0) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.value = textParts.map((p: any) => p.text).join("\n")
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to revert message:", error)
|
||||
alert("Failed to revert message")
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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 modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
|
||||
if (modelInput) {
|
||||
modelInput.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
code: "ArrowDown",
|
||||
keyCode: 40,
|
||||
which: 40,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
modelInput.dispatchEvent(event)
|
||||
}, 10)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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 agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
|
||||
if (agentTrigger) {
|
||||
agentTrigger.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
agentTrigger.dispatchEvent(event)
|
||||
}, 50)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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", "toggle", "show", "hide"],
|
||||
action: options.toggleShowThinkingBlocks,
|
||||
})
|
||||
|
||||
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: "help",
|
||||
label: "Show Help",
|
||||
description: "Display keyboard shortcuts and help",
|
||||
category: "System",
|
||||
keywords: ["/help", "shortcuts", "help"],
|
||||
action: () => {
|
||||
console.log("Show help modal (not implemented)")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function executeCommand(command: Command) {
|
||||
try {
|
||||
const result = command.action?.()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => {
|
||||
console.error("Command execution failed:", error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Command execution failed:", error)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
registerCommands()
|
||||
refreshCommands()
|
||||
})
|
||||
|
||||
return {
|
||||
commands,
|
||||
commandRegistry,
|
||||
refreshCommands,
|
||||
executeCommand,
|
||||
}
|
||||
}
|
||||
73
packages/ui/src/lib/keyboard-registry.ts
Normal file
73
packages/ui/src/lib/keyboard-registry.ts
Normal 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()
|
||||
30
packages/ui/src/lib/keyboard-utils.ts
Normal file
30
packages/ui/src/lib/keyboard-utils.ts
Normal 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("+")
|
||||
}
|
||||
87
packages/ui/src/lib/keyboard.ts
Normal file
87
packages/ui/src/lib/keyboard.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
372
packages/ui/src/lib/markdown.ts
Normal file
372
packages/ui/src/lib/markdown.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { marked } from "marked"
|
||||
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
|
||||
|
||||
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) {
|
||||
console.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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}
|
||||
return text.replace(/[&<"']/g, (m) => map[m])
|
||||
}
|
||||
71
packages/ui/src/lib/notifications.tsx
Normal file
71
packages/ui/src/lib/notifications.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import toast from "solid-toast"
|
||||
|
||||
export type ToastVariant = "info" | "success" | "warning" | "error"
|
||||
|
||||
export type ToastPayload = {
|
||||
title?: string
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
duration?: number
|
||||
}
|
||||
|
||||
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) {
|
||||
const accent = variantAccent[payload.variant]
|
||||
const duration = payload.duration ?? 10000
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
duration,
|
||||
ariaProps: {
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
36
packages/ui/src/lib/prompt-placeholders.ts
Normal file
36
packages/ui/src/lib/prompt-placeholders.ts
Normal 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
|
||||
})
|
||||
}
|
||||
32
packages/ui/src/lib/sdk-manager.ts
Normal file
32
packages/ui/src/lib/sdk-manager.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
|
||||
class SDKManager {
|
||||
private clients = new Map<number, OpencodeClient>()
|
||||
|
||||
createClient(port: number): OpencodeClient {
|
||||
if (this.clients.has(port)) {
|
||||
return this.clients.get(port)!
|
||||
}
|
||||
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: `http://localhost:${port}`,
|
||||
})
|
||||
|
||||
this.clients.set(port, client)
|
||||
return client
|
||||
}
|
||||
|
||||
getClient(port: number): OpencodeClient | null {
|
||||
return this.clients.get(port) || null
|
||||
}
|
||||
|
||||
destroyClient(port: number): void {
|
||||
this.clients.delete(port)
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
this.clients.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export const sdkManager = new SDKManager()
|
||||
23
packages/ui/src/lib/shortcuts/agent.ts
Normal file
23
packages/ui/src/lib/shortcuts/agent.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
67
packages/ui/src/lib/shortcuts/escape.ts
Normal file
67
packages/ui/src/lib/shortcuts/escape.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
23
packages/ui/src/lib/shortcuts/input.ts
Normal file
23
packages/ui/src/lib/shortcuts/input.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
118
packages/ui/src/lib/shortcuts/navigation.ts
Normal file
118
packages/ui/src/lib/shortcuts/navigation.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
237
packages/ui/src/lib/sse-manager.ts
Normal file
237
packages/ui/src/lib/sse-manager.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
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"
|
||||
|
||||
interface SSEConnection {
|
||||
instanceId: string
|
||||
port: number
|
||||
eventSource: EventSource
|
||||
status: "connecting" | "connected" | "disconnected" | "error"
|
||||
reconnectAttempts: number
|
||||
reconnectTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
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> } // Fallback for unknown event types
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<
|
||||
Map<string, "connecting" | "connected" | "disconnected" | "error">
|
||||
>(new Map())
|
||||
|
||||
class SSEManager {
|
||||
private connections = new Map<string, SSEConnection>()
|
||||
private static readonly MAX_RECONNECT_ATTEMPTS = 3
|
||||
|
||||
connect(instanceId: string, port: number, reconnectAttempts = 0): void {
|
||||
const existing = this.connections.get(instanceId)
|
||||
if (existing) {
|
||||
this.clearReconnectTimer(existing)
|
||||
existing.eventSource.close()
|
||||
}
|
||||
|
||||
const url = `http://localhost:${port}/event`
|
||||
const eventSource = new EventSource(url)
|
||||
|
||||
const connection: SSEConnection = {
|
||||
instanceId,
|
||||
port,
|
||||
eventSource,
|
||||
status: "connecting",
|
||||
reconnectAttempts,
|
||||
}
|
||||
|
||||
this.connections.set(instanceId, connection)
|
||||
this.updateConnectionStatus(instanceId, "connecting")
|
||||
|
||||
eventSource.onopen = () => {
|
||||
connection.status = "connected"
|
||||
connection.reconnectAttempts = 0
|
||||
this.updateConnectionStatus(instanceId, "connected")
|
||||
console.log(`[SSE] Connected to instance ${instanceId}`)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
this.handleEvent(instanceId, data)
|
||||
} catch (error) {
|
||||
console.error("[SSE] Failed to parse event:", error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connection.status = "error"
|
||||
this.updateConnectionStatus(instanceId, "error")
|
||||
console.error(`[SSE] Connection error for instance ${instanceId}`)
|
||||
this.handleConnectionError(instanceId, "Connection to instance lost")
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(instanceId: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (connection) {
|
||||
this.clearReconnectTimer(connection)
|
||||
connection.eventSource.close()
|
||||
this.connections.delete(instanceId)
|
||||
this.updateConnectionStatus(instanceId, "disconnected")
|
||||
console.log(`[SSE] Disconnected from instance ${instanceId}`)
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(instanceId: string, event: SSEEvent): void {
|
||||
console.log("[SSE] Received event:", 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:
|
||||
console.warn("[SSE] Unknown event type:", event.type)
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectionError(instanceId: string, reason: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (!connection) return
|
||||
|
||||
connection.eventSource.close()
|
||||
|
||||
if (connection.reconnectAttempts >= SSEManager.MAX_RECONNECT_ATTEMPTS) {
|
||||
this.handleConnectionLost(instanceId, reason)
|
||||
return
|
||||
}
|
||||
|
||||
const nextAttempt = connection.reconnectAttempts + 1
|
||||
const delay = Math.min(nextAttempt * 1000, 5000)
|
||||
|
||||
connection.reconnectAttempts = nextAttempt
|
||||
connection.status = "connecting"
|
||||
this.updateConnectionStatus(instanceId, "connecting")
|
||||
|
||||
console.warn(`[SSE] Attempting reconnect ${nextAttempt} for instance ${instanceId}`)
|
||||
|
||||
connection.reconnectTimer = setTimeout(() => {
|
||||
connection.reconnectTimer = undefined
|
||||
this.connect(instanceId, connection.port, nextAttempt)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private handleConnectionLost(instanceId: string, reason: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (!connection) return
|
||||
|
||||
this.clearReconnectTimer(connection)
|
||||
connection.eventSource.close()
|
||||
this.connections.delete(instanceId)
|
||||
connection.status = "disconnected"
|
||||
this.updateConnectionStatus(instanceId, "disconnected")
|
||||
this.onConnectionLost?.(instanceId, reason)
|
||||
}
|
||||
|
||||
private clearReconnectTimer(connection: SSEConnection): void {
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer)
|
||||
connection.reconnectTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): 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): "connecting" | "connected" | "disconnected" | "error" | null {
|
||||
return connectionStatus().get(instanceId) ?? null
|
||||
}
|
||||
|
||||
getStatuses() {
|
||||
return connectionStatus()
|
||||
}
|
||||
}
|
||||
|
||||
export const sseManager = new SSEManager()
|
||||
162
packages/ui/src/lib/storage.ts
Normal file
162
packages/ui/src/lib/storage.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { Preferences, RecentFolder, OpenCodeBinary } from "../stores/preferences"
|
||||
|
||||
export interface ConfigData {
|
||||
preferences: Preferences
|
||||
recentFolders: RecentFolder[]
|
||||
opencodeBinaries: OpenCodeBinary[]
|
||||
theme?: "light" | "dark" | "system"
|
||||
}
|
||||
|
||||
export interface InstanceData {
|
||||
messageHistory: string[]
|
||||
}
|
||||
|
||||
export class FileStorage {
|
||||
private configPath: string | undefined
|
||||
private instancesDir: string | undefined
|
||||
private configChangeListeners: Set<() => void> = new Set()
|
||||
private initialized = false
|
||||
|
||||
constructor() {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
private async initialize() {
|
||||
if (this.initialized) return
|
||||
|
||||
this.configPath = await window.electronAPI.getConfigPath()
|
||||
this.instancesDir = await window.electronAPI.getInstancesDir()
|
||||
|
||||
// Listen for config changes from other instances
|
||||
window.electronAPI.onConfigChanged(() => {
|
||||
this.configChangeListeners.forEach((listener) => listener())
|
||||
})
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
private async ensureInitialized() {
|
||||
if (!this.initialized) {
|
||||
await this.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
private parseConfig(content: string): ConfigData {
|
||||
const trimmed = content.trim()
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch (error) {
|
||||
const firstBrace = trimmed.indexOf("{")
|
||||
const lastBrace = trimmed.lastIndexOf("}")
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||
const sanitized = trimmed.slice(firstBrace, lastBrace + 1)
|
||||
|
||||
if (sanitized.length !== trimmed.length) {
|
||||
console.warn("Config file contained trailing data; attempting recovery")
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(sanitized)
|
||||
} catch {
|
||||
// Fall through to rethrow original error below
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Config operations
|
||||
async loadConfig(): Promise<ConfigData> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const content = await window.electronAPI.readConfigFile()
|
||||
return this.parseConfig(content)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load config, using defaults:", error)
|
||||
return {
|
||||
preferences: {
|
||||
showThinkingBlocks: false,
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
agentModelSelections: {},
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
},
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveConfig(config: ConfigData): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
await window.electronAPI.writeConfigFile(JSON.stringify(config, null, 2))
|
||||
} catch (error) {
|
||||
console.error("Failed to save config:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Instance operations
|
||||
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const filename = this.instanceIdToFilename(instanceId)
|
||||
const content = await window.electronAPI.readInstanceFile(filename)
|
||||
return JSON.parse(content)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load instance data for ${instanceId}, using defaults:`, error)
|
||||
return {
|
||||
messageHistory: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const filename = this.instanceIdToFilename(instanceId)
|
||||
await window.electronAPI.writeInstanceFile(filename, JSON.stringify(data, null, 2))
|
||||
} catch (error) {
|
||||
console.error(`Failed to save instance data for ${instanceId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteInstanceData(instanceId: string): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const filename = this.instanceIdToFilename(instanceId)
|
||||
await window.electronAPI.deleteInstanceFile(filename)
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete instance data for ${instanceId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Convert folder path to safe filename
|
||||
private instanceIdToFilename(instanceId: string): string {
|
||||
// Convert folder path to safe filename
|
||||
// Replace path separators and other invalid characters
|
||||
return instanceId
|
||||
.replace(/[\\/]/g, "_") // Replace path separators
|
||||
.replace(/[^a-zA-Z0-9_.-]/g, "_") // Replace other invalid chars
|
||||
.replace(/_{2,}/g, "_") // Replace multiple underscores with single
|
||||
.replace(/^_|_$/g, "") // Remove leading/trailing underscores
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
// Config change listeners
|
||||
onConfigChanged(listener: () => void): () => void {
|
||||
this.configChangeListeners.add(listener)
|
||||
return () => this.configChangeListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const storage = new FileStorage()
|
||||
117
packages/ui/src/lib/theme.tsx
Normal file
117
packages/ui/src/lib/theme.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createContext, createSignal, useContext, onMount, createEffect, type JSX } from "solid-js"
|
||||
import { storage, type ConfigData } from "./storage"
|
||||
|
||||
interface ThemeContextValue {
|
||||
isDark: () => boolean
|
||||
toggleTheme: () => void
|
||||
setTheme: (dark: boolean) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>()
|
||||
|
||||
function applyTheme(dark: boolean) {
|
||||
if (dark) {
|
||||
document.documentElement.setAttribute("data-theme", "dark")
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
}
|
||||
|
||||
export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const [isDark, setIsDarkSignal] = createSignal(true) //systemPrefersDark.matches)
|
||||
let themePreference: "system" | "dark" | "light" = "dark"
|
||||
|
||||
applyTheme(true) //systemPrefersDark.matches)
|
||||
|
||||
async function loadTheme() {
|
||||
try {
|
||||
const config = await storage.loadConfig()
|
||||
const savedTheme = config.theme
|
||||
let themeDark: boolean
|
||||
|
||||
if (savedTheme === "system") {
|
||||
themePreference = "system"
|
||||
themeDark = systemPrefersDark.matches
|
||||
} else if (savedTheme === "dark") {
|
||||
themePreference = "dark"
|
||||
themeDark = true
|
||||
} else if (savedTheme === "light") {
|
||||
themePreference = "light"
|
||||
themeDark = false
|
||||
} else {
|
||||
themePreference = "dark"
|
||||
themeDark = true
|
||||
}
|
||||
|
||||
setIsDarkSignal(themeDark)
|
||||
applyTheme(themeDark)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load theme from config:", error)
|
||||
themePreference = "dark"
|
||||
const themeDark = true
|
||||
setIsDarkSignal(themeDark)
|
||||
applyTheme(themeDark)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTheme(dark: boolean) {
|
||||
try {
|
||||
const config = await storage.loadConfig()
|
||||
const nextPreference = dark ? "dark" : "light"
|
||||
config.theme = nextPreference
|
||||
themePreference = nextPreference
|
||||
await storage.saveConfig(config)
|
||||
} catch (error) {
|
||||
console.warn("Failed to save theme to config:", error)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadTheme()
|
||||
|
||||
const unsubscribe = storage.onConfigChanged(() => {
|
||||
loadTheme()
|
||||
})
|
||||
|
||||
// Listen for system theme changes
|
||||
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
|
||||
if (themePreference === "system") {
|
||||
setIsDarkSignal(event.matches)
|
||||
applyTheme(event.matches)
|
||||
}
|
||||
}
|
||||
|
||||
systemPrefersDark.addEventListener("change", handleSystemThemeChange)
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
systemPrefersDark.removeEventListener("change", handleSystemThemeChange)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
applyTheme(isDark())
|
||||
})
|
||||
|
||||
const setTheme = (dark: boolean) => {
|
||||
setIsDarkSignal(dark)
|
||||
applyTheme(dark)
|
||||
saveTheme(dark)
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(!isDark())
|
||||
}
|
||||
|
||||
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
22
packages/ui/src/lib/tool-render-cache.ts
Normal file
22
packages/ui/src/lib/tool-render-cache.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { RenderCache } from "../types/message"
|
||||
|
||||
const toolRenderCache = new Map<string, RenderCache>()
|
||||
|
||||
export function getToolRenderCache(key?: string | null): RenderCache | undefined {
|
||||
if (!key) return undefined
|
||||
return toolRenderCache.get(key)
|
||||
}
|
||||
|
||||
export function setToolRenderCache(key: string | undefined | null, cache?: RenderCache): void {
|
||||
if (!key) return
|
||||
if (cache) {
|
||||
toolRenderCache.set(key, cache)
|
||||
} else {
|
||||
toolRenderCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToolRenderCache(key?: string | null): void {
|
||||
if (!key) return
|
||||
toolRenderCache.delete(key)
|
||||
}
|
||||
23
packages/ui/src/main.tsx
Normal file
23
packages/ui/src/main.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { render } from "solid-js/web"
|
||||
import App from "./App"
|
||||
import { ThemeProvider } from "./lib/theme"
|
||||
import { ConfigProvider } from "./stores/preferences"
|
||||
import "./index.css"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element not found")
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<ConfigProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
root,
|
||||
)
|
||||
43
packages/ui/src/renderer/index.html
Normal file
43
packages/ui/src/renderer/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CodeNomad</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
/* html,
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { */
|
||||
html,
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
/* } */
|
||||
</style>
|
||||
<script>
|
||||
;(function () {
|
||||
try {
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
// if (prefersDark) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
// } else {
|
||||
// document.documentElement.removeAttribute('data-theme')
|
||||
// }
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply initial theme', error)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
packages/ui/src/renderer/main.tsx
Normal file
1
packages/ui/src/renderer/main.tsx
Normal file
@@ -0,0 +1 @@
|
||||
import "../main.tsx"
|
||||
47
packages/ui/src/stores/attachments.ts
Normal file
47
packages/ui/src/stores/attachments.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
|
||||
const [attachments, setAttachments] = createSignal<Map<string, Attachment[]>>(new Map())
|
||||
|
||||
function getSessionKey(instanceId: string, sessionId: string): string {
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
function getAttachments(instanceId: string, sessionId: string): Attachment[] {
|
||||
const key = getSessionKey(instanceId, sessionId)
|
||||
return attachments().get(key) || []
|
||||
}
|
||||
|
||||
function addAttachment(instanceId: string, sessionId: string, attachment: Attachment) {
|
||||
const key = getSessionKey(instanceId, sessionId)
|
||||
setAttachments((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(key) || []
|
||||
next.set(key, [...existing, attachment])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function removeAttachment(instanceId: string, sessionId: string, attachmentId: string) {
|
||||
const key = getSessionKey(instanceId, sessionId)
|
||||
setAttachments((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(key) || []
|
||||
next.set(
|
||||
key,
|
||||
existing.filter((a) => a.id !== attachmentId),
|
||||
)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearAttachments(instanceId: string, sessionId: string) {
|
||||
const key = getSessionKey(instanceId, sessionId)
|
||||
setAttachments((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export { getAttachments, addAttachment, removeAttachment, clearAttachments }
|
||||
36
packages/ui/src/stores/command-palette.ts
Normal file
36
packages/ui/src/stores/command-palette.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const [openStates, setOpenStates] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
function updateState(instanceId: string, open: boolean) {
|
||||
setOpenStates((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, open)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function showCommandPalette(instanceId: string) {
|
||||
if (!instanceId) return
|
||||
updateState(instanceId, true)
|
||||
}
|
||||
|
||||
export function hideCommandPalette(instanceId?: string) {
|
||||
if (!instanceId) {
|
||||
setOpenStates(new Map())
|
||||
return
|
||||
}
|
||||
updateState(instanceId, false)
|
||||
}
|
||||
|
||||
export function toggleCommandPalette(instanceId: string) {
|
||||
if (!instanceId) return
|
||||
const current = openStates().get(instanceId) ?? false
|
||||
updateState(instanceId, !current)
|
||||
}
|
||||
|
||||
export function isOpen(instanceId: string): boolean {
|
||||
return openStates().get(instanceId) ?? false
|
||||
}
|
||||
|
||||
export { openStates }
|
||||
30
packages/ui/src/stores/commands.ts
Normal file
30
packages/ui/src/stores/commands.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
|
||||
const [commandMap, setCommandMap] = createSignal<Map<string, SDKCommand[]>>(new Map())
|
||||
|
||||
export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise<void> {
|
||||
const response = await client.command.list()
|
||||
const commands = response.data ?? []
|
||||
setCommandMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, commands)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function getCommands(instanceId: string): SDKCommand[] {
|
||||
return commandMap().get(instanceId) ?? []
|
||||
}
|
||||
|
||||
export function clearCommands(instanceId: string): void {
|
||||
setCommandMap((prev) => {
|
||||
if (!prev.has(instanceId)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export { commandMap as commands }
|
||||
640
packages/ui/src/stores/instances.ts
Normal file
640
packages/ui/src/stores/instances.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Instance, LogEntry } from "../types/instance"
|
||||
import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import type { ClientPart, Message } from "../types/message"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import {
|
||||
fetchSessions,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
removeSessionIndexes,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences, updateLastUsedBinary } from "./preferences"
|
||||
import { computeDisplayParts } from "./session-messages"
|
||||
import { withSession, setSessionPendingPermission } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
|
||||
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
||||
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
|
||||
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
||||
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
// Permission queue management per instance
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||
interface DisconnectedInstanceInfo {
|
||||
id: string
|
||||
folder: string
|
||||
reason: string
|
||||
}
|
||||
const [disconnectedInstance, setDisconnectedInstance] = createSignal<DisconnectedInstanceInfo | null>(null)
|
||||
|
||||
const MAX_LOG_ENTRIES = 1000
|
||||
|
||||
function ensureLogContainer(id: string) {
|
||||
setInstanceLogs((prev) => {
|
||||
if (prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.set(id, [])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function ensureLogStreamingState(id: string) {
|
||||
setLogStreamingState((prev) => {
|
||||
if (prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.set(id, false)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function removeLogContainer(id: string) {
|
||||
setInstanceLogs((prev) => {
|
||||
if (!prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
setLogStreamingState((prev) => {
|
||||
if (!prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function getInstanceLogs(instanceId: string): LogEntry[] {
|
||||
return instanceLogs().get(instanceId) ?? []
|
||||
}
|
||||
|
||||
function isInstanceLogStreaming(instanceId: string): boolean {
|
||||
return logStreamingState().get(instanceId) ?? false
|
||||
}
|
||||
|
||||
function setInstanceLogStreaming(instanceId: string, enabled: boolean) {
|
||||
ensureLogStreamingState(instanceId)
|
||||
setLogStreamingState((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, enabled)
|
||||
return next
|
||||
})
|
||||
if (!enabled) {
|
||||
clearLogs(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
function addInstance(instance: Instance) {
|
||||
setInstances((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instance.id, instance)
|
||||
return next
|
||||
})
|
||||
ensureLogContainer(instance.id)
|
||||
ensureLogStreamingState(instance.id)
|
||||
}
|
||||
|
||||
function updateInstance(id: string, updates: Partial<Instance>) {
|
||||
setInstances((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instance = next.get(id)
|
||||
if (instance) {
|
||||
next.set(id, { ...instance, ...updates })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function removeInstance(id: string) {
|
||||
let nextActiveId: string | null = null
|
||||
|
||||
setInstances((prev) => {
|
||||
if (!prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const keys = Array.from(prev.keys())
|
||||
const index = keys.indexOf(id)
|
||||
const next = new Map(prev)
|
||||
next.delete(id)
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
if (index > 0) {
|
||||
const prevKey = keys[index - 1]
|
||||
nextActiveId = prevKey ?? null
|
||||
} else {
|
||||
const remainingKeys = Array.from(next.keys())
|
||||
nextActiveId = remainingKeys.length > 0 ? (remainingKeys[0] ?? null) : null
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
removeLogContainer(id)
|
||||
clearCommands(id)
|
||||
clearPermissionQueue(id)
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
setActiveInstanceId(nextActiveId)
|
||||
}
|
||||
|
||||
// Clean up session indexes and drafts for removed instance
|
||||
removeSessionIndexes(id)
|
||||
clearInstanceDraftPrompts(id)
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, binaryPath?: string): Promise<string> {
|
||||
const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const instance: Instance = {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
client: null,
|
||||
environmentVariables: preferences().environmentVariables ?? {},
|
||||
}
|
||||
|
||||
addInstance(instance)
|
||||
|
||||
// Update last used binary
|
||||
if (binaryPath) {
|
||||
updateLastUsedBinary(binaryPath)
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
id: returnedId,
|
||||
port,
|
||||
pid,
|
||||
binaryPath: actualBinaryPath,
|
||||
} = await window.electronAPI.createInstance(id, folder, binaryPath, preferences().environmentVariables)
|
||||
|
||||
const client = sdkManager.createClient(port)
|
||||
|
||||
updateInstance(id, {
|
||||
port,
|
||||
pid,
|
||||
client,
|
||||
status: "ready",
|
||||
binaryPath: actualBinaryPath,
|
||||
})
|
||||
|
||||
setActiveInstanceId(id)
|
||||
sseManager.connect(id, port)
|
||||
|
||||
try {
|
||||
await fetchSessions(id)
|
||||
await fetchAgents(id)
|
||||
await fetchProviders(id)
|
||||
await fetchCommands(id, client)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch initial data:", error)
|
||||
}
|
||||
|
||||
return id
|
||||
} catch (error) {
|
||||
updateInstance(id, {
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function stopInstance(id: string) {
|
||||
const instance = instances().get(id)
|
||||
if (!instance) return
|
||||
|
||||
sseManager.disconnect(id)
|
||||
|
||||
if (instance.port) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
}
|
||||
|
||||
if (instance.pid) {
|
||||
await window.electronAPI.stopInstance(instance.pid)
|
||||
}
|
||||
|
||||
removeInstance(id)
|
||||
}
|
||||
|
||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
console.warn(`[LSP] Skipping fetch; instance ${instanceId} not found`)
|
||||
return undefined
|
||||
}
|
||||
if (!instance.client) {
|
||||
console.warn(`[LSP] Skipping fetch; instance ${instanceId} client not ready`)
|
||||
return undefined
|
||||
}
|
||||
const lsp = instance.client.lsp
|
||||
if (!lsp?.status) {
|
||||
console.warn(`[LSP] Skipping fetch; lsp.status API unavailable for instance ${instanceId}`)
|
||||
return undefined
|
||||
}
|
||||
console.log(`[HTTP] GET /lsp.status for instance ${instanceId}`)
|
||||
const response = await lsp.status()
|
||||
return response.data ?? []
|
||||
}
|
||||
|
||||
function getActiveInstance(): Instance | null {
|
||||
const id = activeInstanceId()
|
||||
return id ? instances().get(id) || null : null
|
||||
}
|
||||
|
||||
function addLog(id: string, entry: LogEntry) {
|
||||
if (!isInstanceLogStreaming(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
setInstanceLogs((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(id) ?? []
|
||||
const updated = existing.length >= MAX_LOG_ENTRIES ? [...existing.slice(1), entry] : [...existing, entry]
|
||||
next.set(id, updated)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearLogs(id: string) {
|
||||
setInstanceLogs((prev) => {
|
||||
if (!prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.set(id, [])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Permission management functions
|
||||
function getPermissionQueue(instanceId: string): Permission[] {
|
||||
const queue = permissionQueues().get(instanceId)
|
||||
if (!queue) {
|
||||
return []
|
||||
}
|
||||
return queue
|
||||
}
|
||||
|
||||
function getPermissionQueueLength(instanceId: string): number {
|
||||
return getPermissionQueue(instanceId).length
|
||||
}
|
||||
|
||||
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
|
||||
let sessionCounts = permissionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) {
|
||||
sessionCounts = new Map()
|
||||
permissionSessionCounts.set(instanceId, sessionCounts)
|
||||
}
|
||||
const current = sessionCounts.get(sessionId) ?? 0
|
||||
sessionCounts.set(sessionId, current + 1)
|
||||
}
|
||||
|
||||
function decrementSessionPendingCount(instanceId: string, sessionId: string): number {
|
||||
const sessionCounts = permissionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) return 0
|
||||
const current = sessionCounts.get(sessionId) ?? 0
|
||||
if (current <= 1) {
|
||||
sessionCounts.delete(sessionId)
|
||||
if (sessionCounts.size === 0) {
|
||||
permissionSessionCounts.delete(instanceId)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
const nextValue = current - 1
|
||||
sessionCounts.set(sessionId, nextValue)
|
||||
return nextValue
|
||||
}
|
||||
|
||||
function clearSessionPendingCounts(instanceId: string): void {
|
||||
const sessionCounts = permissionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) return
|
||||
for (const sessionId of sessionCounts.keys()) {
|
||||
setSessionPendingPermission(instanceId, sessionId, false)
|
||||
}
|
||||
permissionSessionCounts.delete(instanceId)
|
||||
}
|
||||
|
||||
function addPermissionToQueue(instanceId: string, permission: Permission): void {
|
||||
let inserted = false
|
||||
|
||||
setPermissionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
const queue = next.get(instanceId) ?? []
|
||||
|
||||
if (queue.some((p) => p.id === permission.id)) {
|
||||
return next
|
||||
}
|
||||
|
||||
const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created)
|
||||
next.set(instanceId, updatedQueue)
|
||||
inserted = true
|
||||
return next
|
||||
})
|
||||
|
||||
if (!inserted) {
|
||||
return
|
||||
}
|
||||
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (!next.get(instanceId)) {
|
||||
next.set(instanceId, permission.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
incrementSessionPendingCount(instanceId, sessionId)
|
||||
setSessionPendingPermission(instanceId, sessionId, true)
|
||||
|
||||
const isActive = getActivePermission(instanceId)?.id === permission.id
|
||||
attachPermissionToToolPart(instanceId, permission, isActive)
|
||||
}
|
||||
|
||||
function getActivePermission(instanceId: string): Permission | null {
|
||||
const activeId = activePermissionId().get(instanceId)
|
||||
if (!activeId) return null
|
||||
|
||||
const queue = getPermissionQueue(instanceId)
|
||||
return queue.find(p => p.id === activeId) ?? null
|
||||
}
|
||||
|
||||
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
|
||||
let removedPermission: Permission | null = null
|
||||
|
||||
setPermissionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
const queue = next.get(instanceId) ?? []
|
||||
const filtered: Permission[] = []
|
||||
|
||||
for (const item of queue) {
|
||||
if (item.id === permissionId) {
|
||||
removedPermission = item
|
||||
continue
|
||||
}
|
||||
filtered.push(item)
|
||||
}
|
||||
|
||||
if (filtered.length > 0) {
|
||||
next.set(instanceId, filtered)
|
||||
} else {
|
||||
next.delete(instanceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const updatedQueue = getPermissionQueue(instanceId)
|
||||
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
const activeId = next.get(instanceId)
|
||||
if (activeId === permissionId) {
|
||||
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as Permission) : null
|
||||
next.set(instanceId, nextPermission?.id ?? null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const removed = removedPermission
|
||||
if (removed) {
|
||||
clearPermissionFromToolPart(instanceId, removed)
|
||||
const removedSessionId = getPermissionSessionId(removed)
|
||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
|
||||
}
|
||||
|
||||
const nextActivePermission = getActivePermission(instanceId)
|
||||
if (nextActivePermission) {
|
||||
attachPermissionToToolPart(instanceId, nextActivePermission, true)
|
||||
}
|
||||
}
|
||||
|
||||
function clearPermissionQueue(instanceId: string): void {
|
||||
setPermissionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
clearSessionPendingCounts(instanceId)
|
||||
}
|
||||
|
||||
function getPermissionSessionId(permission: Permission): string {
|
||||
return (permission as any).sessionID
|
||||
}
|
||||
|
||||
function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null {
|
||||
const expectedCallId = permission.callID
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool") continue
|
||||
const toolCallId = (part as any).callID
|
||||
if (expectedCallId) {
|
||||
if (toolCallId === expectedCallId) {
|
||||
return part as ClientPart
|
||||
}
|
||||
if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) {
|
||||
return part as ClientPart
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) {
|
||||
return part as ClientPart
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mutateToolPartPermission(
|
||||
instanceId: string,
|
||||
permission: Permission,
|
||||
mutator: (part: ClientPart, message: Message) => boolean,
|
||||
): void {
|
||||
const permissionSessionId = getPermissionSessionId(permission)
|
||||
withSession(instanceId, permissionSessionId, (session) => {
|
||||
const message = session.messages.find((msg) => msg.id === permission.messageID)
|
||||
if (!message) return
|
||||
const targetPart = findToolPartForPermission(message, permission)
|
||||
if (!targetPart) return
|
||||
|
||||
const changed = mutator(targetPart, message)
|
||||
if (!changed) return
|
||||
|
||||
const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1
|
||||
targetPart.version = nextPartVersion
|
||||
message.version = (message.version ?? 0) + 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
})
|
||||
}
|
||||
|
||||
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
|
||||
mutateToolPartPermission(instanceId, permission, (part) => {
|
||||
const existing = part.pendingPermission
|
||||
if (existing && existing.permission.id === permission.id && existing.active === active) {
|
||||
return false
|
||||
}
|
||||
part.pendingPermission = { permission, active }
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function clearPermissionFromToolPart(instanceId: string, permission: Permission): void {
|
||||
mutateToolPartPermission(instanceId, permission, (part) => {
|
||||
if (!part.pendingPermission || part.pendingPermission.permission.id !== permission.id) {
|
||||
return false
|
||||
}
|
||||
delete part.pendingPermission
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function refreshPermissionsForSession(instanceId: string, sessionId: string): void {
|
||||
const queue = getPermissionQueue(instanceId)
|
||||
if (queue.length === 0) {
|
||||
setSessionPendingPermission(instanceId, sessionId, false)
|
||||
return
|
||||
}
|
||||
|
||||
const activeId = activePermissionId().get(instanceId)
|
||||
|
||||
for (const permission of queue) {
|
||||
if (getPermissionSessionId(permission) !== sessionId) continue
|
||||
const isActive = permission.id === activeId
|
||||
attachPermissionToToolPart(instanceId, permission, isActive)
|
||||
}
|
||||
|
||||
const pendingCount = permissionSessionCounts.get(instanceId)?.get(sessionId) ?? 0
|
||||
setSessionPendingPermission(instanceId, sessionId, pendingCount > 0)
|
||||
}
|
||||
|
||||
async function sendPermissionResponse(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
permissionId: string,
|
||||
response: "once" | "always" | "reject"
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.postSessionIdPermissionsPermissionId({
|
||||
path: { id: sessionId, permissionID: permissionId },
|
||||
body: { response }
|
||||
})
|
||||
|
||||
// Remove from queue after successful response
|
||||
removePermissionFromQueue(instanceId, permissionId)
|
||||
} catch (error) {
|
||||
console.error("Failed to send permission response:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
sseManager.onConnectionLost = (instanceId, reason) => {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
return
|
||||
}
|
||||
|
||||
setDisconnectedInstance({
|
||||
id: instanceId,
|
||||
folder: instance.folder,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
sseManager.onLspUpdated = async (instanceId) => {
|
||||
console.log(`[LSP] Received lsp.updated event for instance ${instanceId}`)
|
||||
try {
|
||||
const lspStatus = await fetchLspStatus(instanceId)
|
||||
if (!lspStatus) {
|
||||
return
|
||||
}
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
console.warn(`[LSP] Instance ${instanceId} disappeared before metadata update`)
|
||||
return
|
||||
}
|
||||
updateInstance(instanceId, {
|
||||
metadata: {
|
||||
...(instance.metadata ?? {}),
|
||||
lspStatus,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh LSP status:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledgeDisconnectedInstance(): Promise<void> {
|
||||
const pending = disconnectedInstance()
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await stopInstance(pending.id)
|
||||
} catch (error) {
|
||||
console.error("Failed to stop disconnected instance:", error)
|
||||
} finally {
|
||||
setDisconnectedInstance(null)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
instances,
|
||||
activeInstanceId,
|
||||
setActiveInstanceId,
|
||||
addInstance,
|
||||
updateInstance,
|
||||
removeInstance,
|
||||
createInstance,
|
||||
stopInstance,
|
||||
getActiveInstance,
|
||||
addLog,
|
||||
clearLogs,
|
||||
instanceLogs,
|
||||
getInstanceLogs,
|
||||
isInstanceLogStreaming,
|
||||
setInstanceLogStreaming,
|
||||
// Permission management
|
||||
permissionQueues,
|
||||
activePermissionId,
|
||||
getPermissionQueue,
|
||||
getPermissionQueueLength,
|
||||
addPermissionToQueue,
|
||||
getActivePermission,
|
||||
removePermissionFromQueue,
|
||||
clearPermissionQueue,
|
||||
refreshPermissionsForSession,
|
||||
sendPermissionResponse,
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
fetchLspStatus,
|
||||
}
|
||||
58
packages/ui/src/stores/message-history.ts
Normal file
58
packages/ui/src/stores/message-history.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { storage, type InstanceData } from "../lib/storage"
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
const instanceHistories = new Map<string, string[]>()
|
||||
const historyLoaded = new Set<string>()
|
||||
|
||||
export async function addToHistory(instanceId: string, text: string): Promise<void> {
|
||||
await ensureHistoryLoaded(instanceId)
|
||||
|
||||
const history = instanceHistories.get(instanceId) || []
|
||||
|
||||
history.unshift(text)
|
||||
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.length = MAX_HISTORY
|
||||
}
|
||||
|
||||
instanceHistories.set(instanceId, history)
|
||||
|
||||
try {
|
||||
await storage.saveInstanceData(instanceId, { messageHistory: history })
|
||||
} catch (err) {
|
||||
console.warn("Failed to persist message history:", err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHistory(instanceId: string): Promise<string[]> {
|
||||
await ensureHistoryLoaded(instanceId)
|
||||
return instanceHistories.get(instanceId) || []
|
||||
}
|
||||
|
||||
export async function clearHistory(instanceId: string): Promise<void> {
|
||||
instanceHistories.delete(instanceId)
|
||||
historyLoaded.delete(instanceId)
|
||||
|
||||
try {
|
||||
await storage.saveInstanceData(instanceId, { messageHistory: [] })
|
||||
} catch (error) {
|
||||
console.warn("Failed to clear history:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureHistoryLoaded(instanceId: string): Promise<void> {
|
||||
if (historyLoaded.has(instanceId)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await storage.loadInstanceData(instanceId)
|
||||
instanceHistories.set(instanceId, data.messageHistory)
|
||||
historyLoaded.add(instanceId)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load history:", error)
|
||||
instanceHistories.set(instanceId, [])
|
||||
historyLoaded.add(instanceId)
|
||||
}
|
||||
}
|
||||
339
packages/ui/src/stores/preferences.tsx
Normal file
339
packages/ui/src/stores/preferences.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import { createContext, createSignal, onMount, useContext } from "solid-js"
|
||||
import type { Accessor, ParentComponent } from "solid-js"
|
||||
import { storage, type ConfigData } from "../lib/storage"
|
||||
|
||||
export interface ModelPreference {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
|
||||
export interface AgentModelSelections {
|
||||
[instanceId: string]: Record<string, ModelPreference>
|
||||
}
|
||||
|
||||
export type DiffViewMode = "split" | "unified"
|
||||
export type ExpansionPreference = "expanded" | "collapsed"
|
||||
|
||||
export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
lastUsedBinary?: string
|
||||
environmentVariables?: Record<string, string>
|
||||
modelRecents?: ModelPreference[]
|
||||
agentModelSelections?: AgentModelSelections
|
||||
diffViewMode?: DiffViewMode
|
||||
toolOutputExpansion?: ExpansionPreference
|
||||
diagnosticsExpansion?: ExpansionPreference
|
||||
}
|
||||
|
||||
export interface OpenCodeBinary {
|
||||
path: string
|
||||
version?: string
|
||||
lastUsed: number
|
||||
}
|
||||
|
||||
export interface RecentFolder {
|
||||
path: string
|
||||
lastAccessed: number
|
||||
}
|
||||
|
||||
const MAX_RECENT_FOLDERS = 20
|
||||
const MAX_RECENT_MODELS = 5
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
showThinkingBlocks: false,
|
||||
modelRecents: [],
|
||||
agentModelSelections: {},
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
}
|
||||
|
||||
const [preferences, setPreferences] = createSignal<Preferences>(defaultPreferences)
|
||||
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
|
||||
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
|
||||
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||
let cachedConfig: ConfigData = {
|
||||
preferences: defaultPreferences,
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
async function loadConfig(): Promise<void> {
|
||||
try {
|
||||
const config = await storage.loadConfig()
|
||||
cachedConfig = {
|
||||
...config,
|
||||
preferences: { ...defaultPreferences, ...config.preferences },
|
||||
recentFolders: config.recentFolders || [],
|
||||
opencodeBinaries: config.opencodeBinaries || [],
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load config:", error)
|
||||
cachedConfig = {
|
||||
...cachedConfig,
|
||||
preferences: { ...defaultPreferences },
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
}
|
||||
|
||||
setPreferences(cachedConfig.preferences)
|
||||
setRecentFolders(cachedConfig.recentFolders)
|
||||
setOpenCodeBinaries(cachedConfig.opencodeBinaries)
|
||||
setIsConfigLoaded(true)
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
try {
|
||||
await ensureConfigLoaded()
|
||||
const config: ConfigData = {
|
||||
...cachedConfig,
|
||||
preferences: preferences(),
|
||||
recentFolders: recentFolders(),
|
||||
opencodeBinaries: opencodeBinaries(),
|
||||
}
|
||||
cachedConfig = config
|
||||
await storage.saveConfig(config)
|
||||
} catch (error) {
|
||||
console.error("Failed to save config:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureConfigLoaded(): Promise<void> {
|
||||
if (isConfigLoaded()) return
|
||||
if (!loadPromise) {
|
||||
loadPromise = loadConfig().finally(() => {
|
||||
loadPromise = null
|
||||
})
|
||||
}
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
|
||||
function updatePreferences(updates: Partial<Preferences>): void {
|
||||
const updated = { ...preferences(), ...updates }
|
||||
setPreferences(updated)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
function setDiffViewMode(mode: DiffViewMode): void {
|
||||
if (preferences().diffViewMode === mode) return
|
||||
updatePreferences({ diffViewMode: mode })
|
||||
}
|
||||
|
||||
function setToolOutputExpansion(mode: ExpansionPreference): void {
|
||||
if (preferences().toolOutputExpansion === mode) return
|
||||
updatePreferences({ toolOutputExpansion: mode })
|
||||
}
|
||||
|
||||
function setDiagnosticsExpansion(mode: ExpansionPreference): void {
|
||||
if (preferences().diagnosticsExpansion === mode) return
|
||||
updatePreferences({ diagnosticsExpansion: mode })
|
||||
}
|
||||
|
||||
function toggleShowThinkingBlocks(): void {
|
||||
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
const folders = recentFolders().filter((f) => f.path !== path)
|
||||
folders.unshift({ path, lastAccessed: Date.now() })
|
||||
|
||||
const trimmed = folders.slice(0, MAX_RECENT_FOLDERS)
|
||||
setRecentFolders(trimmed)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
function removeRecentFolder(path: string): void {
|
||||
const folders = recentFolders().filter((f) => f.path !== path)
|
||||
setRecentFolders(folders)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
function addOpenCodeBinary(path: string, version?: string): void {
|
||||
const binaries = opencodeBinaries().filter((b) => b.path !== path)
|
||||
const lastUsed = Date.now()
|
||||
const binaryEntry: OpenCodeBinary = version ? { path, version, lastUsed } : { path, lastUsed }
|
||||
binaries.unshift(binaryEntry)
|
||||
|
||||
const trimmed = binaries.slice(0, 10) // Keep max 10 binaries
|
||||
setOpenCodeBinaries(trimmed)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
function removeOpenCodeBinary(path: string): void {
|
||||
const binaries = opencodeBinaries().filter((b) => b.path !== path)
|
||||
setOpenCodeBinaries(binaries)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
function updateLastUsedBinary(path: string): void {
|
||||
updatePreferences({ lastUsedBinary: path })
|
||||
|
||||
const binaries = opencodeBinaries()
|
||||
let binary = binaries.find((b) => b.path === path)
|
||||
|
||||
// If binary not found in list, add it (for system PATH "opencode")
|
||||
if (!binary) {
|
||||
addOpenCodeBinary(path)
|
||||
binary = { path, lastUsed: Date.now() }
|
||||
} else {
|
||||
binary.lastUsed = Date.now()
|
||||
// Move to front
|
||||
const sorted = [binary, ...binaries.filter((b) => b.path !== path)]
|
||||
setOpenCodeBinaries(sorted)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateEnvironmentVariables(envVars: Record<string, string>): void {
|
||||
updatePreferences({ environmentVariables: envVars })
|
||||
}
|
||||
|
||||
function addEnvironmentVariable(key: string, value: string): void {
|
||||
const current = preferences().environmentVariables || {}
|
||||
const updated = { ...current, [key]: value }
|
||||
updateEnvironmentVariables(updated)
|
||||
}
|
||||
|
||||
function removeEnvironmentVariable(key: string): void {
|
||||
const current = preferences().environmentVariables || {}
|
||||
const { [key]: removed, ...rest } = current
|
||||
updateEnvironmentVariables(rest)
|
||||
}
|
||||
|
||||
function addRecentModelPreference(model: ModelPreference): void {
|
||||
if (!model.providerId || !model.modelId) return
|
||||
const recents = preferences().modelRecents ?? []
|
||||
const filtered = recents.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId)
|
||||
const updated = [model, ...filtered].slice(0, MAX_RECENT_MODELS)
|
||||
updatePreferences({ modelRecents: updated })
|
||||
}
|
||||
|
||||
function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): void {
|
||||
if (!instanceId || !agent || !model.providerId || !model.modelId) return
|
||||
const selections = preferences().agentModelSelections ?? {}
|
||||
const instanceSelections = selections[instanceId] ?? {}
|
||||
const existing = instanceSelections[agent]
|
||||
if (existing && existing.providerId === model.providerId && existing.modelId === model.modelId) {
|
||||
return
|
||||
}
|
||||
updatePreferences({
|
||||
agentModelSelections: {
|
||||
...selections,
|
||||
[instanceId]: {
|
||||
...instanceSelections,
|
||||
[agent]: model,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getAgentModelPreference(instanceId: string, agent: string): ModelPreference | undefined {
|
||||
return preferences().agentModelSelections?.[instanceId]?.[agent]
|
||||
}
|
||||
|
||||
void ensureConfigLoaded().catch((error) => {
|
||||
console.error("Failed to initialize config:", error)
|
||||
})
|
||||
|
||||
interface ConfigContextValue {
|
||||
isLoaded: Accessor<boolean>
|
||||
preferences: typeof preferences
|
||||
recentFolders: typeof recentFolders
|
||||
opencodeBinaries: typeof opencodeBinaries
|
||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||
setDiffViewMode: typeof setDiffViewMode
|
||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||
addRecentFolder: typeof addRecentFolder
|
||||
removeRecentFolder: typeof removeRecentFolder
|
||||
addOpenCodeBinary: typeof addOpenCodeBinary
|
||||
removeOpenCodeBinary: typeof removeOpenCodeBinary
|
||||
updateLastUsedBinary: typeof updateLastUsedBinary
|
||||
updatePreferences: typeof updatePreferences
|
||||
updateEnvironmentVariables: typeof updateEnvironmentVariables
|
||||
addEnvironmentVariable: typeof addEnvironmentVariable
|
||||
removeEnvironmentVariable: typeof removeEnvironmentVariable
|
||||
addRecentModelPreference: typeof addRecentModelPreference
|
||||
setAgentModelPreference: typeof setAgentModelPreference
|
||||
getAgentModelPreference: typeof getAgentModelPreference
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<ConfigContextValue>()
|
||||
|
||||
const configContextValue: ConfigContextValue = {
|
||||
isLoaded: isConfigLoaded,
|
||||
preferences,
|
||||
recentFolders,
|
||||
opencodeBinaries,
|
||||
toggleShowThinkingBlocks,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
updateLastUsedBinary,
|
||||
updatePreferences,
|
||||
updateEnvironmentVariables,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
addRecentModelPreference,
|
||||
setAgentModelPreference,
|
||||
getAgentModelPreference,
|
||||
}
|
||||
|
||||
const ConfigProvider: ParentComponent = (props) => {
|
||||
onMount(() => {
|
||||
ensureConfigLoaded().catch((error) => {
|
||||
console.error("Failed to initialize config:", error)
|
||||
})
|
||||
|
||||
const unsubscribe = storage.onConfigChanged(() => {
|
||||
loadConfig().catch((error) => {
|
||||
console.error("Failed to refresh config:", error)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
return <ConfigContext.Provider value={configContextValue}>{props.children}</ConfigContext.Provider>
|
||||
}
|
||||
|
||||
function useConfig(): ConfigContextValue {
|
||||
const context = useContext(ConfigContext)
|
||||
if (!context) {
|
||||
throw new Error("useConfig must be used within ConfigProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export {
|
||||
ConfigProvider,
|
||||
useConfig,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
toggleShowThinkingBlocks,
|
||||
recentFolders,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
opencodeBinaries,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
updateLastUsedBinary,
|
||||
updateEnvironmentVariables,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
addRecentModelPreference,
|
||||
setAgentModelPreference,
|
||||
getAgentModelPreference,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
}
|
||||
352
packages/ui/src/stores/session-actions.ts
Normal file
352
packages/ui/src/stores/session-actions.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
|
||||
import {
|
||||
addRecentModelPreference,
|
||||
preferences,
|
||||
setAgentModelPreference,
|
||||
} from "./preferences"
|
||||
import { sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import {
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
|
||||
const ID_LENGTH = 26
|
||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
let lastTimestamp = 0
|
||||
let localCounter = 0
|
||||
|
||||
function randomBase62(length: number): string {
|
||||
let result = ""
|
||||
const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto
|
||||
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(length)
|
||||
cryptoObj.getRandomValues(bytes)
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length]
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < length; i++) {
|
||||
const idx = Math.floor(Math.random() * BASE62_CHARS.length)
|
||||
result += BASE62_CHARS[idx]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function createId(prefix: string): string {
|
||||
const timestamp = Date.now()
|
||||
if (timestamp !== lastTimestamp) {
|
||||
lastTimestamp = timestamp
|
||||
localCounter = 0
|
||||
}
|
||||
localCounter++
|
||||
|
||||
const value = (BigInt(timestamp) << BigInt(12)) + BigInt(localCounter)
|
||||
const bytes = new Array<number>(6)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const shift = BigInt(8 * (5 - i))
|
||||
bytes[i] = Number((value >> shift) & BigInt(0xff))
|
||||
}
|
||||
const hex = bytes.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
const random = randomBase62(ID_LENGTH - 12)
|
||||
|
||||
return `${prefix}_${hex}${random}`
|
||||
}
|
||||
|
||||
async function sendMessage(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
attachments: any[] = [],
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const messageId = createId("msg")
|
||||
const textPartId = createId("part")
|
||||
|
||||
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
|
||||
|
||||
const optimisticParts: any[] = [
|
||||
{
|
||||
id: textPartId,
|
||||
type: "text" as const,
|
||||
text: resolvedPrompt,
|
||||
synthetic: true,
|
||||
renderCache: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
const optimisticMessage: Message = {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
type: "user",
|
||||
parts: optimisticParts,
|
||||
timestamp: Date.now(),
|
||||
status: "sending",
|
||||
version: 0,
|
||||
}
|
||||
|
||||
optimisticParts.forEach((part: any) => initializePartVersion(part))
|
||||
|
||||
optimisticMessage.displayParts = computeDisplayParts(optimisticMessage, preferences().showThinkingBlocks)
|
||||
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
session.messages.push(optimisticMessage)
|
||||
const index = getSessionIndex(instanceId, sessionId)
|
||||
index.messageIndex.set(optimisticMessage.id, session.messages.length - 1)
|
||||
})
|
||||
|
||||
const requestParts: any[] = [
|
||||
{
|
||||
id: textPartId,
|
||||
type: "text" as const,
|
||||
text: resolvedPrompt,
|
||||
},
|
||||
]
|
||||
|
||||
if (attachments.length > 0) {
|
||||
for (const att of attachments) {
|
||||
const source = att.source
|
||||
if (source.type === "file") {
|
||||
const partId = createId("part")
|
||||
requestParts.push({
|
||||
id: partId,
|
||||
type: "file" as const,
|
||||
url: att.url,
|
||||
mime: source.mime,
|
||||
filename: att.filename,
|
||||
})
|
||||
optimisticParts.push({
|
||||
id: partId,
|
||||
type: "file" as const,
|
||||
url: att.url,
|
||||
mime: source.mime,
|
||||
filename: att.filename,
|
||||
synthetic: true,
|
||||
})
|
||||
} else if (source.type === "text") {
|
||||
const display: string | undefined = att.display
|
||||
const value: unknown = source.value
|
||||
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
||||
|
||||
if (isPastedPlaceholder || typeof value !== "string") {
|
||||
continue
|
||||
}
|
||||
|
||||
const partId = createId("part")
|
||||
requestParts.push({
|
||||
id: partId,
|
||||
type: "text" as const,
|
||||
text: value,
|
||||
})
|
||||
optimisticParts.push({
|
||||
id: partId,
|
||||
type: "text" as const,
|
||||
text: value,
|
||||
synthetic: true,
|
||||
renderCache: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
messageID: messageId,
|
||||
parts: requestParts,
|
||||
...(session.agent && { agent: session.agent }),
|
||||
...(session.model.providerId &&
|
||||
session.model.modelId && {
|
||||
model: {
|
||||
providerID: session.model.providerId,
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
console.log("[sendMessage] Sending prompt:", {
|
||||
sessionId,
|
||||
requestBody,
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] POST /session.prompt for instance ${instanceId}`, { sessionId, requestBody })
|
||||
const response = await instance.client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: requestBody,
|
||||
})
|
||||
|
||||
console.log("[sendMessage] Response:", response)
|
||||
|
||||
if (response.error) {
|
||||
console.error("[sendMessage] Server returned error:", response.error)
|
||||
throw new Error(JSON.stringify(response.error) || "Failed to send message")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[sendMessage] Failed to send prompt:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCustomCommand(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
commandName: string,
|
||||
args: string,
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const body: {
|
||||
command: string
|
||||
arguments: string
|
||||
messageID: string
|
||||
agent?: string
|
||||
model?: string
|
||||
} = {
|
||||
command: commandName,
|
||||
arguments: args,
|
||||
messageID: createId("msg"),
|
||||
}
|
||||
|
||||
if (session.agent) {
|
||||
body.agent = session.agent
|
||||
}
|
||||
|
||||
if (session.model.providerId && session.model.modelId) {
|
||||
body.model = `${session.model.providerId}/${session.model.modelId}`
|
||||
}
|
||||
|
||||
await instance.client.session.command({
|
||||
path: { id: sessionId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
async function runShellCommand(instanceId: string, sessionId: string, command: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const agent = session.agent || "build"
|
||||
|
||||
await instance.client.session.shell({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
agent,
|
||||
command,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
console.log("[abortSession] Aborting session:", { instanceId, sessionId })
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] POST /session.abort for instance ${instanceId}`, { sessionId })
|
||||
await instance.client.session.abort({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
console.log("[abortSession] Session aborted successfully")
|
||||
} catch (error) {
|
||||
console.error("[abortSession] Failed to abort session:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSessionAgent(instanceId: string, sessionId: string, agent: string): Promise<void> {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const nextModel = await getDefaultModel(instanceId, agent)
|
||||
const shouldApplyModel = isModelValid(instanceId, nextModel)
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.agent = agent
|
||||
if (shouldApplyModel) {
|
||||
current.model = nextModel
|
||||
}
|
||||
})
|
||||
|
||||
if (agent && shouldApplyModel) {
|
||||
setAgentModelPreference(instanceId, agent, nextModel)
|
||||
}
|
||||
|
||||
if (shouldApplyModel) {
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSessionModel(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
model: { providerId: string; modelId: string },
|
||||
): Promise<void> {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
if (!isModelValid(instanceId, model)) {
|
||||
console.warn("Invalid model selection", model)
|
||||
return
|
||||
}
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.model = model
|
||||
})
|
||||
|
||||
if (session.agent) {
|
||||
setAgentModelPreference(instanceId, session.agent, model)
|
||||
}
|
||||
addRecentModelPreference(model)
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
executeCustomCommand,
|
||||
runShellCommand,
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
}
|
||||
625
packages/ui/src/stores/session-api.ts
Normal file
625
packages/ui/src/stores/session-api.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
import type { Session } from "../types/session"
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { instances, refreshPermissionsForSession } from "./instances"
|
||||
import { preferences, setAgentModelPreference } from "./preferences"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
activeSessionId,
|
||||
agents,
|
||||
clearSessionDraftPrompt,
|
||||
messagesLoaded,
|
||||
providers,
|
||||
pruneDraftPrompts,
|
||||
setActiveSessionId,
|
||||
setAgents,
|
||||
setMessagesLoaded,
|
||||
setProviders,
|
||||
setSessionInfoByInstance,
|
||||
setSessions,
|
||||
sessions,
|
||||
loading,
|
||||
setLoading,
|
||||
} from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import {
|
||||
computeDisplayParts,
|
||||
clearSessionIndex,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
|
||||
interface SessionForkResponse {
|
||||
id: string
|
||||
title?: string
|
||||
parentID?: string | null
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
time?: {
|
||||
created?: number
|
||||
updated?: number
|
||||
}
|
||||
revert?: {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSessions(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.fetchingSessions.set(instanceId, true)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /session.list for instance ${instanceId}`)
|
||||
const response = await instance.client.session.list()
|
||||
|
||||
const sessionMap = new Map<string, Session>()
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingSessions = sessions().get(instanceId)
|
||||
|
||||
for (const apiSession of response.data) {
|
||||
const existingSession = existingSessions?.get(apiSession.id)
|
||||
|
||||
sessionMap.set(apiSession.id, {
|
||||
id: apiSession.id,
|
||||
instanceId,
|
||||
title: apiSession.title || "Untitled",
|
||||
parentId: apiSession.parentID || null,
|
||||
agent: existingSession?.agent ?? "",
|
||||
model: existingSession?.model ?? { providerId: "", modelId: "" },
|
||||
version: apiSession.version,
|
||||
time: {
|
||||
...apiSession.time,
|
||||
},
|
||||
revert: apiSession.revert
|
||||
? {
|
||||
messageID: apiSession.revert.messageID,
|
||||
partID: apiSession.revert.partID,
|
||||
snapshot: apiSession.revert.snapshot,
|
||||
diff: apiSession.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
messages: existingSession?.messages ?? [],
|
||||
messagesInfo: existingSession?.messagesInfo ?? new Map(),
|
||||
})
|
||||
}
|
||||
|
||||
const validSessionIds = new Set(sessionMap.keys())
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, sessionMap)
|
||||
return next
|
||||
})
|
||||
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId)
|
||||
if (loadedSet) {
|
||||
const filtered = new Set<string>()
|
||||
for (const id of loadedSet) {
|
||||
if (validSessionIds.has(id)) {
|
||||
filtered.add(id)
|
||||
}
|
||||
}
|
||||
next.set(instanceId, filtered)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
for (const session of sessionMap.values()) {
|
||||
const flag = (session.time as (Session["time"] & { compacting?: number | boolean }) | undefined)?.compacting
|
||||
const active = typeof flag === "number" ? flag > 0 : Boolean(flag)
|
||||
setSessionCompactionState(instanceId, session.id, active)
|
||||
}
|
||||
|
||||
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sessions:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.fetchingSessions.set(instanceId, false)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(instanceId: string, agent?: string): Promise<Session> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const instanceAgents = agents().get(instanceId) || []
|
||||
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
|
||||
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
|
||||
|
||||
const defaultModel = await getDefaultModel(instanceId, selectedAgent)
|
||||
|
||||
if (selectedAgent && isModelValid(instanceId, defaultModel)) {
|
||||
setAgentModelPreference(instanceId, selectedAgent, defaultModel)
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.creatingSession.set(instanceId, true)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] POST /session.create for instance ${instanceId}`)
|
||||
const response = await instance.client.session.create()
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to create session: No data returned")
|
||||
}
|
||||
|
||||
const session: Session = {
|
||||
id: response.data.id,
|
||||
instanceId,
|
||||
title: response.data.title || "New Session",
|
||||
parentId: null,
|
||||
agent: selectedAgent,
|
||||
model: defaultModel,
|
||||
version: response.data.version,
|
||||
time: {
|
||||
...response.data.time,
|
||||
},
|
||||
revert: response.data.revert
|
||||
? {
|
||||
messageID: response.data.revert.messageID,
|
||||
partID: response.data.revert.partID,
|
||||
snapshot: response.data.revert.snapshot,
|
||||
diff: response.data.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId) || new Map()
|
||||
instanceSessions.set(session.id, session)
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
|
||||
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
||||
const initialContextWindow = initialModel?.limit?.context ?? 0
|
||||
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
||||
const initialContextPercent = initialContextWindow > 0 ? 0 : null
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(session.id, {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: initialContextWindow,
|
||||
isSubscriptionModel: Boolean(initialSubscriptionModel),
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: initialContextPercent,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
|
||||
getSessionIndex(instanceId, session.id)
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.creatingSession.set(instanceId, false)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function forkSession(
|
||||
instanceId: string,
|
||||
sourceSessionId: string,
|
||||
options?: { messageId?: string },
|
||||
): Promise<Session> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const request: {
|
||||
path: { id: string }
|
||||
body?: { messageID: string }
|
||||
} = {
|
||||
path: { id: sourceSessionId },
|
||||
}
|
||||
|
||||
if (options?.messageId) {
|
||||
request.body = { messageID: options.messageId }
|
||||
}
|
||||
|
||||
console.log(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
||||
const response = await instance.client.session.fork(request)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to fork session: No data returned")
|
||||
}
|
||||
|
||||
const info = response.data as SessionForkResponse
|
||||
const forkedSession = {
|
||||
id: info.id,
|
||||
instanceId,
|
||||
title: info.title || "Forked Session",
|
||||
parentId: info.parentID || null,
|
||||
agent: info.agent || "",
|
||||
model: {
|
||||
providerId: info.model?.providerID || "",
|
||||
modelId: info.model?.modelID || "",
|
||||
},
|
||||
version: "0",
|
||||
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: info.revert.messageID,
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
} as unknown as Session
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId) || new Map()
|
||||
instanceSessions.set(forkedSession.id, forkedSession)
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
|
||||
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
||||
const forkContextWindow = forkModel?.limit?.context ?? 0
|
||||
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
||||
const forkContextPercent = forkContextWindow > 0 ? 0 : null
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(forkedSession.id, {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: forkContextWindow,
|
||||
isSubscriptionModel: Boolean(forkSubscriptionModel),
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: forkContextPercent,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
|
||||
getSessionIndex(instanceId, forkedSession.id)
|
||||
|
||||
return forkedSession
|
||||
}
|
||||
|
||||
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const deleting = next.deletingSession.get(instanceId) || new Set()
|
||||
deleting.add(sessionId)
|
||||
next.deletingSession.set(instanceId, deleting)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
||||
await instance.client.session.delete({ path: { id: sessionId } })
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId)
|
||||
if (instanceSessions) {
|
||||
instanceSessions.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
setSessionCompactionState(instanceId, sessionId, false)
|
||||
clearSessionDraftPrompt(instanceId, sessionId)
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = next.get(instanceId)
|
||||
if (instanceInfo) {
|
||||
const updatedInstanceInfo = new Map(instanceInfo)
|
||||
updatedInstanceInfo.delete(sessionId)
|
||||
if (updatedInstanceInfo.size === 0) {
|
||||
next.delete(instanceId)
|
||||
} else {
|
||||
next.set(instanceId, updatedInstanceInfo)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
clearSessionIndex(instanceId, sessionId)
|
||||
|
||||
if (activeSessionId().get(instanceId) === sessionId) {
|
||||
setActiveSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const deleting = next.deletingSession.get(instanceId)
|
||||
if (deleting) {
|
||||
deleting.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAgents(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
||||
const response = await instance.client.app.agents()
|
||||
const agentList = (response.data ?? []).map((agent) => ({
|
||||
name: agent.name,
|
||||
description: agent.description || "",
|
||||
mode: agent.mode,
|
||||
model: agent.model?.modelID
|
||||
? {
|
||||
providerId: agent.model.providerID || "",
|
||||
modelId: agent.model.modelID,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
setAgents((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, agentList)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agents:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProviders(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /config.providers for instance ${instanceId}`)
|
||||
const response = await instance.client.config.providers()
|
||||
if (!response.data) return
|
||||
|
||||
const providerList = response.data.providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
defaultModelId: response.data?.default?.[provider.id],
|
||||
models: Object.entries(provider.models).map(([id, model]) => ({
|
||||
id,
|
||||
name: model.name,
|
||||
providerId: provider.id,
|
||||
limit: model.limit,
|
||||
cost: model.cost,
|
||||
})),
|
||||
}))
|
||||
|
||||
setProviders((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, providerList)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch providers:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(instanceId: string, sessionId: string, force = false): Promise<void> {
|
||||
if (force) {
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId)
|
||||
if (loadedSet) {
|
||||
loadedSet.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId)
|
||||
if (alreadyLoaded && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId)
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const loadingSet = next.loadingMessages.get(instanceId) || new Set()
|
||||
loadingSet.add(sessionId)
|
||||
next.loadingMessages.set(instanceId, loadingSet)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /session.messages for instance ${instanceId}`, { sessionId })
|
||||
const response = await instance.client.session.messages({ path: { id: sessionId } })
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
return
|
||||
}
|
||||
|
||||
const messagesInfo = new Map<string, any>()
|
||||
const messages: Message[] = response.data.map((apiMessage: any) => {
|
||||
const info = apiMessage.info || apiMessage
|
||||
const role = info.role || "assistant"
|
||||
const messageId = info.id || String(Date.now())
|
||||
|
||||
messagesInfo.set(messageId, info)
|
||||
|
||||
const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part))
|
||||
|
||||
const message: Message = {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
type: role === "user" ? "user" : "assistant",
|
||||
parts,
|
||||
timestamp: info.time?.created || Date.now(),
|
||||
status: "complete" as const,
|
||||
version: 0,
|
||||
}
|
||||
|
||||
parts.forEach((part: any) => initializePartVersion(part))
|
||||
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
|
||||
return message
|
||||
})
|
||||
|
||||
let agentName = ""
|
||||
let providerID = ""
|
||||
let modelID = ""
|
||||
|
||||
for (let i = response.data.length - 1; i >= 0; i--) {
|
||||
const apiMessage = response.data[i]
|
||||
const info = apiMessage.info || apiMessage
|
||||
|
||||
if (info.role === "assistant") {
|
||||
agentName = (info as any).mode || (info as any).agent || ""
|
||||
providerID = (info as any).providerID || ""
|
||||
modelID = (info as any).modelID || ""
|
||||
if (agentName && providerID && modelID) break
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentName && !providerID && !modelID) {
|
||||
const defaultModel = await getDefaultModel(instanceId, session.agent)
|
||||
agentName = session.agent
|
||||
providerID = defaultModel.providerId
|
||||
modelID = defaultModel.modelId
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const nextInstanceSessions = next.get(instanceId)
|
||||
if (nextInstanceSessions) {
|
||||
const existingSession = nextInstanceSessions.get(sessionId)
|
||||
if (existingSession) {
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
messages,
|
||||
messagesInfo,
|
||||
agent: agentName || existingSession.agent,
|
||||
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
|
||||
}
|
||||
const updatedInstanceSessions = new Map(nextInstanceSessions)
|
||||
updatedInstanceSessions.set(sessionId, updatedSession)
|
||||
next.set(instanceId, updatedInstanceSessions)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
rebuildSessionIndex(instanceId, sessionId, messages)
|
||||
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId) || new Set()
|
||||
loadedSet.add(sessionId)
|
||||
next.set(instanceId, loadedSet)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to load messages:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const loadingSet = next.loadingMessages.get(instanceId)
|
||||
if (loadingSet) {
|
||||
loadingSet.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
refreshPermissionsForSession(instanceId, sessionId)
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
createSession,
|
||||
deleteSession,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
fetchSessions,
|
||||
forkSession,
|
||||
loadMessages,
|
||||
}
|
||||
24
packages/ui/src/stores/session-compaction.ts
Normal file
24
packages/ui/src/stores/session-compaction.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
function makeKey(instanceId: string, sessionId: string): string {
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
const [compactingSessions, setCompactingSessions] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
export function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
|
||||
setCompactingSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const key = makeKey(instanceId, sessionId)
|
||||
if (isCompacting) {
|
||||
next.set(key, true)
|
||||
} else {
|
||||
next.delete(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function isSessionCompactionActive(instanceId: string, sessionId: string): boolean {
|
||||
return compactingSessions().get(makeKey(instanceId, sessionId)) ?? false
|
||||
}
|
||||
507
packages/ui/src/stores/session-events.ts
Normal file
507
packages/ui/src/stores/session-events.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import type {
|
||||
MessagePartRemovedEvent,
|
||||
MessagePartUpdatedEvent,
|
||||
MessageRemovedEvent,
|
||||
MessageUpdateEvent,
|
||||
} from "../types/message"
|
||||
import type {
|
||||
EventPermissionReplied,
|
||||
EventPermissionUpdated,
|
||||
EventSessionCompacted,
|
||||
EventSessionError,
|
||||
EventSessionIdle,
|
||||
EventSessionUpdated,
|
||||
} from "@opencode-ai/sdk"
|
||||
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { preferences } from "./preferences"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
|
||||
import {
|
||||
sessions,
|
||||
setSessions,
|
||||
withSession,
|
||||
} from "./session-state"
|
||||
import {
|
||||
bumpPartVersion,
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
import { loadMessages } from "./session-api"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
|
||||
interface TuiToastEvent {
|
||||
type: "tui.toast.show"
|
||||
properties: {
|
||||
title?: string
|
||||
message: string
|
||||
variant: "info" | "success" | "warning" | "error"
|
||||
duration?: number
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
|
||||
|
||||
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const rawPart = event.properties?.part
|
||||
if (!rawPart) return
|
||||
|
||||
const part = normalizeMessagePart(rawPart)
|
||||
|
||||
const session = instanceSessions.get(part.sessionID)
|
||||
if (!session) return
|
||||
|
||||
const index = getSessionIndex(instanceId, part.sessionID)
|
||||
let messageIndex = index.messageIndex.get(part.messageID)
|
||||
let replacedTemp = false
|
||||
|
||||
if (messageIndex === undefined) {
|
||||
for (let i = 0; i < session.messages.length; i++) {
|
||||
const msg = session.messages[i]
|
||||
if (msg.sessionId === part.sessionID && msg.status === "sending") {
|
||||
messageIndex = i
|
||||
replacedTemp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messageIndex === undefined) {
|
||||
const newMessage: any = {
|
||||
id: part.messageID,
|
||||
sessionId: part.sessionID,
|
||||
type: "assistant" as const,
|
||||
parts: [part],
|
||||
timestamp: Date.now(),
|
||||
status: "streaming" as const,
|
||||
version: 0,
|
||||
}
|
||||
|
||||
initializePartVersion(part)
|
||||
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
|
||||
|
||||
let insertIndex = session.messages.length
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
if (session.messages[i].id < newMessage.id) {
|
||||
insertIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
session.messages.splice(insertIndex, 0, newMessage)
|
||||
rebuildSessionIndex(instanceId, part.sessionID, session.messages)
|
||||
} else {
|
||||
const message = session.messages[messageIndex]
|
||||
if (typeof message.version !== "number") {
|
||||
message.version = 0
|
||||
}
|
||||
|
||||
let filteredSynthetics = false
|
||||
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
|
||||
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
|
||||
filteredSynthetics = true
|
||||
message.parts.forEach((partItem: any) => {
|
||||
if (partItem.type === "text") {
|
||||
partItem.renderCache = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let baseParts: any[]
|
||||
if (replacedTemp) {
|
||||
baseParts = message.parts.filter((partItem: any) => partItem.type !== "text")
|
||||
message.parts = baseParts
|
||||
baseParts.forEach((partItem: any) => {
|
||||
if (partItem.type === "text") {
|
||||
partItem.renderCache = undefined
|
||||
}
|
||||
})
|
||||
} else {
|
||||
baseParts = message.parts
|
||||
}
|
||||
|
||||
let partMap = index.partIndex.get(message.id)
|
||||
if (!partMap) {
|
||||
partMap = new Map()
|
||||
index.partIndex.set(message.id, partMap)
|
||||
}
|
||||
|
||||
let shouldIncrementVersion = filteredSynthetics || replacedTemp
|
||||
const partIndex = partMap.get(part.id)
|
||||
|
||||
if (partIndex === undefined) {
|
||||
initializePartVersion(part)
|
||||
baseParts.push(part)
|
||||
if (part.id && typeof part.id === "string") {
|
||||
partMap.set(part.id, baseParts.length - 1)
|
||||
}
|
||||
shouldIncrementVersion = true
|
||||
if (part.type === "text") {
|
||||
part.renderCache = undefined
|
||||
}
|
||||
} else {
|
||||
const previousPart = baseParts[partIndex]
|
||||
const textUnchanged =
|
||||
!filteredSynthetics &&
|
||||
!replacedTemp &&
|
||||
part.type === "text" &&
|
||||
previousPart?.type === "text" &&
|
||||
previousPart.text === part.text
|
||||
|
||||
if (textUnchanged) {
|
||||
return
|
||||
}
|
||||
|
||||
bumpPartVersion(previousPart, part)
|
||||
baseParts[partIndex] = part
|
||||
if (part.type !== "text" || !previousPart || previousPart.text !== part.text) {
|
||||
shouldIncrementVersion = true
|
||||
if (part.type === "text") {
|
||||
part.renderCache = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const oldId = message.id
|
||||
message.id = replacedTemp ? part.messageID : message.id
|
||||
message.status = message.status === "sending" ? "streaming" : message.status
|
||||
message.parts = baseParts
|
||||
|
||||
if (shouldIncrementVersion) {
|
||||
message.version += 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
} else if (
|
||||
!message.displayParts ||
|
||||
message.displayParts.showThinking !== preferences().showThinkingBlocks ||
|
||||
message.displayParts.version !== message.version
|
||||
) {
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
}
|
||||
|
||||
if (oldId !== message.id) {
|
||||
index.messageIndex.delete(oldId)
|
||||
index.messageIndex.set(message.id, messageIndex)
|
||||
const existingPartMap = index.partIndex.get(oldId)
|
||||
if (existingPartMap) {
|
||||
index.partIndex.delete(oldId)
|
||||
index.partIndex.set(message.id, existingPartMap)
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredSynthetics || replacedTemp) {
|
||||
const refreshed = new Map<string, number>()
|
||||
message.parts.forEach((partItem, idx) => {
|
||||
if (partItem.id && typeof partItem.id === "string") {
|
||||
refreshed.set(partItem.id, idx)
|
||||
}
|
||||
})
|
||||
index.partIndex.set(message.id, refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
withSession(instanceId, part.sessionID, () => {
|
||||
/* mutations already applied above */
|
||||
})
|
||||
|
||||
updateSessionInfo(instanceId, part.sessionID)
|
||||
refreshPermissionsForSession(instanceId, part.sessionID)
|
||||
} else if (event.type === "message.updated") {
|
||||
const info = event.properties?.info
|
||||
if (!info) return
|
||||
|
||||
const session = instanceSessions.get(info.sessionID)
|
||||
if (!session) return
|
||||
|
||||
const index = getSessionIndex(instanceId, info.sessionID)
|
||||
let messageIndex = index.messageIndex.get(info.id)
|
||||
|
||||
if (messageIndex === undefined) {
|
||||
let tempMessageIndex = -1
|
||||
for (let i = 0; i < session.messages.length; i++) {
|
||||
const msg = session.messages[i]
|
||||
if (
|
||||
msg.sessionId === info.sessionID &&
|
||||
msg.type === (info.role === "user" ? "user" : "assistant") &&
|
||||
msg.status === "sending"
|
||||
) {
|
||||
tempMessageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (tempMessageIndex === -1) {
|
||||
for (let i = 0; i < session.messages.length; i++) {
|
||||
const msg = session.messages[i]
|
||||
if (msg.sessionId === info.sessionID && msg.status === "sending") {
|
||||
tempMessageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tempMessageIndex > -1) {
|
||||
const message = session.messages[tempMessageIndex]
|
||||
if (typeof message.version !== "number") {
|
||||
message.version = 0
|
||||
}
|
||||
|
||||
const oldId = message.id
|
||||
message.id = info.id
|
||||
message.type = (info.role === "user" ? "user" : "assistant") as "user" | "assistant"
|
||||
message.timestamp = info.time?.created || Date.now()
|
||||
message.status = "complete" as const
|
||||
message.version += 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
|
||||
if (oldId !== message.id) {
|
||||
index.messageIndex.delete(oldId)
|
||||
index.messageIndex.set(message.id, tempMessageIndex)
|
||||
const existingPartMap = index.partIndex.get(oldId)
|
||||
if (existingPartMap) {
|
||||
index.partIndex.delete(oldId)
|
||||
index.partIndex.set(message.id, existingPartMap)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const newMessage: any = {
|
||||
id: info.id,
|
||||
sessionId: info.sessionID,
|
||||
type: (info.role === "user" ? "user" : "assistant") as "user" | "assistant",
|
||||
parts: [],
|
||||
timestamp: info.time?.created || Date.now(),
|
||||
status: "complete" as const,
|
||||
version: 0,
|
||||
}
|
||||
|
||||
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
|
||||
|
||||
let insertIndex = session.messages.length
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
if (session.messages[i].id < newMessage.id) {
|
||||
insertIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
session.messages.splice(insertIndex, 0, newMessage)
|
||||
rebuildSessionIndex(instanceId, info.sessionID, session.messages)
|
||||
}
|
||||
} else {
|
||||
const message = session.messages[messageIndex]
|
||||
if (typeof message.version !== "number") {
|
||||
message.version = 0
|
||||
}
|
||||
message.status = "complete" as const
|
||||
message.version += 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
}
|
||||
|
||||
session.messagesInfo.set(info.id, info)
|
||||
withSession(instanceId, info.sessionID, () => {
|
||||
/* ensure reactivity */
|
||||
})
|
||||
|
||||
updateSessionInfo(instanceId, info.sessionID)
|
||||
refreshPermissionsForSession(instanceId, info.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
||||
const info = event.properties?.info
|
||||
if (!info) return
|
||||
|
||||
const compactingFlag = info.time?.compacting
|
||||
const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag)
|
||||
setSessionCompactionState(instanceId, info.id, isCompacting)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const existingSession = instanceSessions.get(info.id)
|
||||
|
||||
if (!existingSession) {
|
||||
const newSession = {
|
||||
id: info.id,
|
||||
instanceId,
|
||||
title: info.title || "Untitled",
|
||||
parentId: info.parentID || null,
|
||||
agent: "",
|
||||
model: {
|
||||
providerId: "",
|
||||
modelId: "",
|
||||
},
|
||||
version: info.version || "0",
|
||||
time: info.time
|
||||
? { ...info.time }
|
||||
: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
} as any
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const updated = new Map(prev.get(instanceId))
|
||||
updated.set(newSession.id, newSession)
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
|
||||
console.log(`[SSE] New session created: ${info.id}`, newSession)
|
||||
} else {
|
||||
const mergedTime = {
|
||||
...existingSession.time,
|
||||
...(info.time ?? {}),
|
||||
}
|
||||
if (!info.time?.updated) {
|
||||
mergedTime.updated = Date.now()
|
||||
}
|
||||
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
title: info.title || existingSession.title,
|
||||
time: mergedTime,
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: info.revert.messageID,
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: existingSession.revert,
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const updated = new Map(prev.get(instanceId))
|
||||
updated.set(existingSession.id, updatedSession)
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
console.log(`[SSE] Session idle: ${sessionId}`)
|
||||
}
|
||||
|
||||
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
console.log(`[SSE] Session compacted: ${sessionID}`)
|
||||
|
||||
setSessionCompactionState(instanceId, sessionID, false)
|
||||
|
||||
withSession(instanceId, sessionID, (session) => {
|
||||
const time = { ...(session.time ?? {}) }
|
||||
time.compacting = 0
|
||||
session.time = time
|
||||
})
|
||||
|
||||
loadMessages(instanceId, sessionID, true).catch(console.error)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionID)
|
||||
const label = session?.title?.trim() ? session.title : sessionID
|
||||
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
||||
const instanceName = instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
||||
|
||||
showToastNotification({
|
||||
title: instanceName,
|
||||
message: `Session ${label ? `"${label}"` : sessionID} was compacted`,
|
||||
variant: "info",
|
||||
duration: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
function handleSessionError(_instanceId: string, event: EventSessionError): void {
|
||||
const error = event.properties?.error
|
||||
console.error(`[SSE] Session error:`, error)
|
||||
|
||||
let message = "Unknown error"
|
||||
|
||||
if (error) {
|
||||
if ("data" in error && error.data && typeof error.data === "object" && "message" in error.data) {
|
||||
message = error.data.message as string
|
||||
} else if ("message" in error && typeof error.message === "string") {
|
||||
message = error.message
|
||||
}
|
||||
}
|
||||
|
||||
alert(`Error: ${message}`)
|
||||
}
|
||||
|
||||
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
console.log(`[SSE] Message removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch(console.error)
|
||||
}
|
||||
|
||||
function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
console.log(`[SSE] Message part removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch(console.error)
|
||||
}
|
||||
|
||||
function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
|
||||
const payload = event?.properties
|
||||
if (!payload || typeof payload.message !== "string" || typeof payload.variant !== "string") return
|
||||
if (!payload.message.trim()) return
|
||||
|
||||
const variant: ToastVariant = ALLOWED_TOAST_VARIANTS.has(payload.variant as ToastVariant)
|
||||
? (payload.variant as ToastVariant)
|
||||
: "info"
|
||||
|
||||
showToastNotification({
|
||||
title: typeof payload.title === "string" ? payload.title : undefined,
|
||||
message: payload.message,
|
||||
variant,
|
||||
duration: typeof payload.duration === "number" ? payload.duration : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdated): void {
|
||||
const permission = event.properties
|
||||
if (!permission) return
|
||||
|
||||
console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
}
|
||||
|
||||
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
||||
const { permissionID } = event.properties
|
||||
if (!permissionID) return
|
||||
|
||||
console.log(`[SSE] Permission replied: ${permissionID}`)
|
||||
removePermissionFromQueue(instanceId, permissionID)
|
||||
}
|
||||
|
||||
export {
|
||||
handleMessagePartRemoved,
|
||||
handleMessageRemoved,
|
||||
handleMessageUpdate,
|
||||
handlePermissionReplied,
|
||||
handlePermissionUpdated,
|
||||
handleSessionCompacted,
|
||||
handleSessionError,
|
||||
handleSessionIdle,
|
||||
handleSessionUpdate,
|
||||
handleTuiToast,
|
||||
}
|
||||
295
packages/ui/src/stores/session-messages.ts
Normal file
295
packages/ui/src/stores/session-messages.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { Message, MessageDisplayParts } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { Provider } from "../types/session"
|
||||
|
||||
import { decodeHtmlEntities } from "../lib/markdown"
|
||||
import { providers, sessions, setSessionInfoByInstance } from "./session-state"
|
||||
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
|
||||
|
||||
interface SessionIndexCache {
|
||||
messageIndex: Map<string, number>
|
||||
partIndex: Map<string, Map<string, number>>
|
||||
}
|
||||
|
||||
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
|
||||
|
||||
function decodeTextSegment(segment: any): any {
|
||||
if (typeof segment === "string") {
|
||||
return decodeHtmlEntities(segment)
|
||||
}
|
||||
|
||||
if (segment && typeof segment === "object") {
|
||||
const updated: Record<string, any> = { ...segment }
|
||||
|
||||
if (typeof updated.text === "string") {
|
||||
updated.text = decodeHtmlEntities(updated.text)
|
||||
}
|
||||
|
||||
if (typeof updated.value === "string") {
|
||||
updated.value = decodeHtmlEntities(updated.value)
|
||||
}
|
||||
|
||||
if (Array.isArray(updated.content)) {
|
||||
updated.content = updated.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
return segment
|
||||
}
|
||||
|
||||
function normalizeMessagePart(part: any): any {
|
||||
if (!part || typeof part !== "object") {
|
||||
return part
|
||||
}
|
||||
|
||||
if (part.type !== "text") {
|
||||
return part
|
||||
}
|
||||
|
||||
const normalized: Record<string, any> = { ...part, renderCache: undefined }
|
||||
|
||||
if (typeof normalized.text === "string") {
|
||||
normalized.text = decodeHtmlEntities(normalized.text)
|
||||
} else if (normalized.text && typeof normalized.text === "object") {
|
||||
const textObject: Record<string, any> = { ...normalized.text }
|
||||
|
||||
if (typeof textObject.value === "string") {
|
||||
textObject.value = decodeHtmlEntities(textObject.value)
|
||||
}
|
||||
|
||||
if (Array.isArray(textObject.content)) {
|
||||
textObject.content = textObject.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
|
||||
if (typeof textObject.text === "string") {
|
||||
textObject.text = decodeHtmlEntities(textObject.text)
|
||||
}
|
||||
|
||||
normalized.text = textObject
|
||||
}
|
||||
|
||||
if (Array.isArray(normalized.content)) {
|
||||
normalized.content = normalized.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
|
||||
if (normalized.thinking && typeof normalized.thinking === "object") {
|
||||
const thinking: Record<string, any> = { ...normalized.thinking }
|
||||
if (Array.isArray(thinking.content)) {
|
||||
thinking.content = thinking.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
normalized.thinking = thinking
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function computeDisplayParts(message: Message, showThinking: boolean): MessageDisplayParts {
|
||||
const text: any[] = []
|
||||
const tool: any[] = []
|
||||
const reasoning: any[] = []
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) {
|
||||
text.push(part)
|
||||
} else if (part.type === "tool") {
|
||||
tool.push(part)
|
||||
} else if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) {
|
||||
reasoning.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
const combined = reasoning.length > 0 ? [...text, ...reasoning] : [...text]
|
||||
const version = typeof message.version === "number" ? message.version : 0
|
||||
|
||||
return { text, tool, reasoning, combined, showThinking, version }
|
||||
}
|
||||
|
||||
function initializePartVersion(part: any, version = 0) {
|
||||
if (!part || typeof part !== "object") return
|
||||
const partAny = part as any
|
||||
if (typeof partAny.version !== "number") {
|
||||
partAny.version = version
|
||||
}
|
||||
}
|
||||
|
||||
function bumpPartVersion(previousPart: any, nextPart: any): number {
|
||||
const prevVersion = typeof previousPart?.version === "number" ? previousPart.version : -1
|
||||
const nextVersion = prevVersion + 1
|
||||
nextPart.version = nextVersion
|
||||
return nextVersion
|
||||
}
|
||||
|
||||
function getSessionIndex(instanceId: string, sessionId: string) {
|
||||
let instanceMap = sessionIndexes.get(instanceId)
|
||||
if (!instanceMap) {
|
||||
instanceMap = new Map()
|
||||
sessionIndexes.set(instanceId, instanceMap)
|
||||
}
|
||||
|
||||
let sessionMap = instanceMap.get(sessionId)
|
||||
if (!sessionMap) {
|
||||
sessionMap = { messageIndex: new Map(), partIndex: new Map() }
|
||||
instanceMap.set(sessionId, sessionMap)
|
||||
}
|
||||
|
||||
return sessionMap
|
||||
}
|
||||
|
||||
function rebuildSessionIndex(instanceId: string, sessionId: string, messages: Message[]) {
|
||||
const index = getSessionIndex(instanceId, sessionId)
|
||||
index.messageIndex.clear()
|
||||
index.partIndex.clear()
|
||||
|
||||
messages.forEach((message, messageIdx) => {
|
||||
index.messageIndex.set(message.id, messageIdx)
|
||||
|
||||
const partMap = new Map<string, number>()
|
||||
message.parts.forEach((part, partIdx) => {
|
||||
if (part.id && typeof part.id === "string") {
|
||||
partMap.set(part.id, partIdx)
|
||||
}
|
||||
})
|
||||
index.partIndex.set(message.id, partMap)
|
||||
})
|
||||
}
|
||||
|
||||
function clearSessionIndex(instanceId: string, sessionId: string) {
|
||||
const instanceMap = sessionIndexes.get(instanceId)
|
||||
if (instanceMap) {
|
||||
instanceMap.delete(sessionId)
|
||||
if (instanceMap.size === 0) {
|
||||
sessionIndexes.delete(instanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeSessionIndexes(instanceId: string) {
|
||||
sessionIndexes.delete(instanceId)
|
||||
}
|
||||
|
||||
function updateSessionInfo(instanceId: string, sessionId: string) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
let tokens = 0
|
||||
let cost = 0
|
||||
let contextWindow = 0
|
||||
let isSubscriptionModel = false
|
||||
let modelID = ""
|
||||
let providerID = ""
|
||||
let actualUsageTokens = 0
|
||||
let contextUsagePercent: number | null = null
|
||||
let hasContextUsage = false
|
||||
|
||||
if (session.messagesInfo.size > 0) {
|
||||
const messageArray = Array.from(session.messagesInfo.values()).reverse()
|
||||
|
||||
for (const info of messageArray) {
|
||||
if (info.role === "assistant" && info.tokens) {
|
||||
const usage = info.tokens
|
||||
|
||||
if (usage.output > 0) {
|
||||
const inputTokens = usage.input || 0
|
||||
const reasoningTokens = usage.reasoning || 0
|
||||
const cacheReadTokens = usage.cache?.read || 0
|
||||
const cacheWriteTokens = usage.cache?.write || 0
|
||||
const outputTokens = usage.output || 0
|
||||
|
||||
if (info.summary) {
|
||||
tokens = outputTokens
|
||||
} else {
|
||||
tokens = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
|
||||
}
|
||||
|
||||
cost = info.cost || 0
|
||||
actualUsageTokens = tokens
|
||||
hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
|
||||
|
||||
modelID = info.modelID || ""
|
||||
providerID = info.providerID || ""
|
||||
isSubscriptionModel = cost === 0
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
|
||||
const sessionModel = session.model
|
||||
let selectedModel: Provider["models"][number] | undefined
|
||||
|
||||
if (sessionModel?.providerId && sessionModel?.modelId) {
|
||||
const provider = instanceProviders.find((p) => p.id === sessionModel.providerId)
|
||||
selectedModel = provider?.models.find((m) => m.id === sessionModel.modelId)
|
||||
}
|
||||
|
||||
if (!selectedModel && modelID && providerID) {
|
||||
const provider = instanceProviders.find((p) => p.id === providerID)
|
||||
selectedModel = provider?.models.find((m) => m.id === modelID)
|
||||
}
|
||||
|
||||
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
|
||||
|
||||
if (selectedModel) {
|
||||
if (selectedModel.limit?.context) {
|
||||
contextWindow = selectedModel.limit.context
|
||||
}
|
||||
|
||||
if (selectedModel.limit?.output && selectedModel.limit.output > 0) {
|
||||
modelOutputLimit = selectedModel.limit.output
|
||||
}
|
||||
|
||||
if (selectedModel.cost?.input === 0 && selectedModel.cost?.output === 0) {
|
||||
isSubscriptionModel = true
|
||||
}
|
||||
}
|
||||
|
||||
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
||||
let contextUsageTokens = 0
|
||||
|
||||
if (hasContextUsage && actualUsageTokens > 0) {
|
||||
contextUsageTokens = actualUsageTokens + outputBudget
|
||||
if (contextWindow > 0) {
|
||||
const percent = Math.round((contextUsageTokens / contextWindow) * 100)
|
||||
contextUsagePercent = Math.min(100, Math.max(0, percent))
|
||||
} else {
|
||||
contextUsagePercent = null
|
||||
}
|
||||
} else {
|
||||
contextUsagePercent = contextWindow > 0 ? 0 : null
|
||||
}
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(sessionId, {
|
||||
tokens,
|
||||
cost,
|
||||
contextWindow,
|
||||
isSubscriptionModel,
|
||||
contextUsageTokens,
|
||||
contextUsagePercent,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
bumpPartVersion,
|
||||
clearSessionIndex,
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
removeSessionIndexes,
|
||||
updateSessionInfo,
|
||||
}
|
||||
83
packages/ui/src/stores/session-models.ts
Normal file
83
packages/ui/src/stores/session-models.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { agents, providers } from "./session-state"
|
||||
import { preferences, getAgentModelPreference } from "./preferences"
|
||||
|
||||
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
|
||||
|
||||
function isModelValid(
|
||||
instanceId: string,
|
||||
model?: { providerId: string; modelId: string } | null,
|
||||
): model is { providerId: string; modelId: string } {
|
||||
if (!model?.providerId || !model.modelId) return false
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const provider = instanceProviders.find((p) => p.id === model.providerId)
|
||||
if (!provider) return false
|
||||
return provider.models.some((item) => item.id === model.modelId)
|
||||
}
|
||||
|
||||
function getRecentModelPreferenceForInstance(
|
||||
instanceId: string,
|
||||
): { providerId: string; modelId: string } | undefined {
|
||||
const recents = preferences().modelRecents ?? []
|
||||
for (const item of recents) {
|
||||
if (isModelValid(instanceId, item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getDefaultModel(
|
||||
instanceId: string,
|
||||
agentName?: string,
|
||||
): Promise<{ providerId: string; modelId: string }> {
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const instanceAgents = agents().get(instanceId) || []
|
||||
|
||||
if (agentName) {
|
||||
const stored = getAgentModelPreference(instanceId, agentName)
|
||||
if (isModelValid(instanceId, stored)) {
|
||||
return stored
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
const agent = instanceAgents.find((a) => a.name === agentName)
|
||||
if (agent && agent.model && isModelValid(instanceId, agent.model)) {
|
||||
return {
|
||||
providerId: agent.model.providerId,
|
||||
modelId: agent.model.modelId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recent = getRecentModelPreferenceForInstance(instanceId)
|
||||
if (recent) {
|
||||
return recent
|
||||
}
|
||||
|
||||
for (const provider of instanceProviders) {
|
||||
if (provider.defaultModelId) {
|
||||
const model = provider.models.find((m) => m.id === provider.defaultModelId)
|
||||
if (model) {
|
||||
return {
|
||||
providerId: provider.id,
|
||||
modelId: model.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (instanceProviders.length > 0) {
|
||||
const firstProvider = instanceProviders[0]
|
||||
const firstModel = firstProvider.models[0]
|
||||
if (firstModel) {
|
||||
return {
|
||||
providerId: firstProvider.id,
|
||||
modelId: firstModel.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { providerId: "", modelId: "" }
|
||||
}
|
||||
|
||||
export { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, getRecentModelPreferenceForInstance, isModelValid }
|
||||
261
packages/ui/src/stores/session-state.ts
Normal file
261
packages/ui/src/stores/session-state.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
import type { Session, Agent, Provider } from "../types/session"
|
||||
|
||||
export interface SessionInfo {
|
||||
tokens: number
|
||||
cost: number
|
||||
contextWindow: number
|
||||
isSubscriptionModel: boolean
|
||||
contextUsageTokens: number
|
||||
contextUsagePercent: number | null
|
||||
}
|
||||
|
||||
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
||||
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
const [agents, setAgents] = createSignal<Map<string, Agent[]>>(new Map())
|
||||
const [providers, setProviders] = createSignal<Map<string, Provider[]>>(new Map())
|
||||
const [sessionDraftPrompts, setSessionDraftPrompts] = createSignal<Map<string, string>>(new Map())
|
||||
|
||||
const [loading, setLoading] = createSignal({
|
||||
fetchingSessions: new Map<string, boolean>(),
|
||||
creatingSession: new Map<string, boolean>(),
|
||||
deletingSession: new Map<string, Set<string>>(),
|
||||
loadingMessages: new Map<string, Set<string>>(),
|
||||
})
|
||||
|
||||
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
|
||||
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
|
||||
|
||||
function getDraftKey(instanceId: string, sessionId: string): string {
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
function getSessionDraftPrompt(instanceId: string, sessionId: string): string {
|
||||
if (!instanceId || !sessionId) return ""
|
||||
const key = getDraftKey(instanceId, sessionId)
|
||||
return sessionDraftPrompts().get(key) ?? ""
|
||||
}
|
||||
|
||||
function setSessionDraftPrompt(instanceId: string, sessionId: string, value: string) {
|
||||
const key = getDraftKey(instanceId, sessionId)
|
||||
setSessionDraftPrompts((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (!value) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.set(key, value)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearSessionDraftPrompt(instanceId: string, sessionId: string) {
|
||||
const key = getDraftKey(instanceId, sessionId)
|
||||
setSessionDraftPrompts((prev) => {
|
||||
if (!prev.has(key)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearInstanceDraftPrompts(instanceId: string) {
|
||||
if (!instanceId) return
|
||||
setSessionDraftPrompts((prev) => {
|
||||
let changed = false
|
||||
const next = new Map(prev)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of Array.from(next.keys())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
function pruneDraftPrompts(instanceId: string, validSessionIds: Set<string>) {
|
||||
setSessionDraftPrompts((prev) => {
|
||||
let changed = false
|
||||
const next = new Map(prev)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of Array.from(next.keys())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
const sessionId = key.slice(prefix.length)
|
||||
if (!validSessionIds.has(sessionId)) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
updater(session)
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
messages: [...session.messages],
|
||||
messagesInfo: new Map(session.messagesInfo),
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const newInstanceSessions = new Map(instanceSessions)
|
||||
newInstanceSessions.set(sessionId, updatedSession)
|
||||
next.set(instanceId, newInstanceSessions)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
const time = { ...(session.time ?? {}) }
|
||||
time.compacting = isCompacting ? Date.now() : 0
|
||||
session.time = time
|
||||
})
|
||||
}
|
||||
|
||||
function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (session.pendingPermission === pending) return
|
||||
session.pendingPermission = pending
|
||||
})
|
||||
}
|
||||
|
||||
function setActiveSession(instanceId: string, sessionId: string): void {
|
||||
setActiveSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, sessionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setActiveParentSession(instanceId: string, parentSessionId: string): void {
|
||||
setActiveParentSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, parentSessionId)
|
||||
return next
|
||||
})
|
||||
|
||||
setActiveSession(instanceId, parentSessionId)
|
||||
}
|
||||
|
||||
function clearActiveParentSession(instanceId: string): void {
|
||||
setActiveParentSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
|
||||
setActiveSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function getActiveParentSession(instanceId: string): Session | null {
|
||||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return null
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions?.get(parentId) || null
|
||||
}
|
||||
|
||||
function getActiveSession(instanceId: string): Session | null {
|
||||
const sessionId = activeSessionId().get(instanceId)
|
||||
if (!sessionId) return null
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions?.get(sessionId) || null
|
||||
}
|
||||
|
||||
function getSessions(instanceId: string): Session[] {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions ? Array.from(instanceSessions.values()) : []
|
||||
}
|
||||
|
||||
function getParentSessions(instanceId: string): Session[] {
|
||||
const allSessions = getSessions(instanceId)
|
||||
return allSessions.filter((s) => s.parentId === null)
|
||||
}
|
||||
|
||||
function getChildSessions(instanceId: string, parentId: string): Session[] {
|
||||
const allSessions = getSessions(instanceId)
|
||||
return allSessions.filter((s) => s.parentId === parentId)
|
||||
}
|
||||
|
||||
function getSessionFamily(instanceId: string, parentId: string): Session[] {
|
||||
const parent = sessions().get(instanceId)?.get(parentId)
|
||||
if (!parent) return []
|
||||
|
||||
const children = getChildSessions(instanceId, parentId)
|
||||
return [parent, ...children]
|
||||
}
|
||||
|
||||
function isSessionBusy(instanceId: string, sessionId: string): boolean {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return false
|
||||
if (!instanceSessions.has(sessionId)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function isSessionMessagesLoading(instanceId: string, sessionId: string): boolean {
|
||||
return Boolean(loading().loadingMessages.get(instanceId)?.has(sessionId))
|
||||
}
|
||||
|
||||
function getSessionInfo(instanceId: string, sessionId: string): SessionInfo | undefined {
|
||||
return sessionInfoByInstance().get(instanceId)?.get(sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
sessions,
|
||||
setSessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
activeParentSessionId,
|
||||
setActiveParentSessionId,
|
||||
agents,
|
||||
setAgents,
|
||||
providers,
|
||||
setProviders,
|
||||
loading,
|
||||
setLoading,
|
||||
messagesLoaded,
|
||||
setMessagesLoaded,
|
||||
sessionInfoByInstance,
|
||||
setSessionInfoByInstance,
|
||||
getSessionDraftPrompt,
|
||||
setSessionDraftPrompt,
|
||||
clearSessionDraftPrompt,
|
||||
clearInstanceDraftPrompts,
|
||||
pruneDraftPrompts,
|
||||
withSession,
|
||||
setSessionCompactionState,
|
||||
setSessionPendingPermission,
|
||||
setActiveSession,
|
||||
|
||||
setActiveParentSession,
|
||||
clearActiveParentSession,
|
||||
getActiveSession,
|
||||
getActiveParentSession,
|
||||
getSessions,
|
||||
getParentSessions,
|
||||
getChildSessions,
|
||||
getSessionFamily,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
getSessionInfo,
|
||||
}
|
||||
166
packages/ui/src/stores/session-status.ts
Normal file
166
packages/ui/src/stores/session-status.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import type { Message, MessageInfo } from "../types/message"
|
||||
import { sessions } from "./sessions"
|
||||
import { isSessionCompactionActive } from "./session-compaction"
|
||||
|
||||
function getSession(instanceId: string, sessionId: string): Session | null {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions?.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
function isSessionCompacting(session: Session): boolean {
|
||||
const time = (session.time as (Session["time"] & { compacting?: number }) | undefined)
|
||||
const compactingFlag = time?.compacting
|
||||
if (typeof compactingFlag === "number") {
|
||||
return compactingFlag > 0
|
||||
}
|
||||
return Boolean(compactingFlag)
|
||||
}
|
||||
|
||||
function getMessageTimestamp(session: Session, message?: Message): number {
|
||||
if (!message) return Number.NEGATIVE_INFINITY
|
||||
if (typeof message.timestamp === "number" && Number.isFinite(message.timestamp)) {
|
||||
return message.timestamp
|
||||
}
|
||||
const info = session.messagesInfo.get(message.id)
|
||||
return info?.time?.created ?? Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
function getLastMessage(session: Session): Message | undefined {
|
||||
let latest: Message | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const message of session.messages) {
|
||||
if (!message) continue
|
||||
const timestamp = getMessageTimestamp(session, message)
|
||||
if (timestamp >= latestTimestamp) {
|
||||
latest = message
|
||||
latestTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
function getLastMessageInfo(session: Session, role?: MessageInfo["role"]): MessageInfo | undefined {
|
||||
if (session.messagesInfo.size === 0) {
|
||||
return undefined
|
||||
}
|
||||
let latest: MessageInfo | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const info of session.messagesInfo.values()) {
|
||||
if (!info) continue
|
||||
if (role && info.role !== role) continue
|
||||
const timestamp = info.time?.created ?? 0
|
||||
if (timestamp >= latestTimestamp) {
|
||||
latest = info
|
||||
latestTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
function getInfoCreatedTimestamp(info?: MessageInfo): number {
|
||||
if (!info) {
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
const created = info.time?.created
|
||||
if (typeof created === "number" && Number.isFinite(created)) {
|
||||
return created
|
||||
}
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
function getAssistantCompletionTimestamp(info?: MessageInfo): number {
|
||||
if (!info) {
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
const completed = (info.time as { completed?: number } | undefined)?.completed
|
||||
if (typeof completed === "number" && Number.isFinite(completed)) {
|
||||
return completed
|
||||
}
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
function isAssistantInfoPending(info?: MessageInfo): boolean {
|
||||
if (!info) {
|
||||
return false
|
||||
}
|
||||
const completed = (info.time as { completed?: number } | undefined)?.completed
|
||||
if (completed === undefined || completed === null) {
|
||||
return true
|
||||
}
|
||||
const created = getInfoCreatedTimestamp(info)
|
||||
return completed < created
|
||||
}
|
||||
|
||||
function isAssistantStillGenerating(message: Message, info?: MessageInfo): boolean {
|
||||
if (message.type !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (message.status === "error") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (message.status === "streaming" || message.status === "sending") {
|
||||
return true
|
||||
}
|
||||
|
||||
const completedAt = (info?.time as { completed?: number } | undefined)?.completed
|
||||
if (completedAt !== undefined && completedAt !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !(message.status === "complete" || message.status === "sent")
|
||||
}
|
||||
|
||||
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus {
|
||||
const session = getSession(instanceId, sessionId)
|
||||
if (!session) {
|
||||
return "idle"
|
||||
}
|
||||
|
||||
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
|
||||
return "compacting"
|
||||
}
|
||||
|
||||
const latestUserInfo = getLastMessageInfo(session, "user")
|
||||
const latestAssistantInfo = getLastMessageInfo(session, "assistant")
|
||||
const lastMessage = getLastMessage(session)
|
||||
if (!lastMessage) {
|
||||
const latestInfo = getLastMessageInfo(session)
|
||||
if (!latestInfo) {
|
||||
return "idle"
|
||||
}
|
||||
if (latestInfo.role === "user") {
|
||||
return "working"
|
||||
}
|
||||
const infoCompleted = latestInfo.time?.completed
|
||||
return infoCompleted ? "idle" : "working"
|
||||
}
|
||||
|
||||
if (lastMessage.type === "user") {
|
||||
return "working"
|
||||
}
|
||||
|
||||
const infoForMessage = session.messagesInfo.get(lastMessage.id) ?? latestAssistantInfo
|
||||
if (isAssistantStillGenerating(lastMessage, infoForMessage)) {
|
||||
return "working"
|
||||
}
|
||||
|
||||
if (isAssistantInfoPending(latestAssistantInfo)) {
|
||||
return "working"
|
||||
}
|
||||
|
||||
const userTimestamp = getInfoCreatedTimestamp(latestUserInfo)
|
||||
const assistantCompletedAt = getAssistantCompletionTimestamp(latestAssistantInfo)
|
||||
if (userTimestamp > assistantCompletedAt) {
|
||||
return "working"
|
||||
}
|
||||
|
||||
return "idle"
|
||||
}
|
||||
|
||||
export function isSessionBusy(instanceId: string, sessionId: string): boolean {
|
||||
const status = getSessionStatus(instanceId, sessionId)
|
||||
return status === "working" || status === "compacting"
|
||||
}
|
||||
115
packages/ui/src/stores/sessions.ts
Normal file
115
packages/ui/src/stores/sessions.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { SessionInfo } from "./session-state"
|
||||
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
|
||||
import {
|
||||
activeParentSessionId,
|
||||
activeSessionId,
|
||||
agents,
|
||||
clearActiveParentSession,
|
||||
clearInstanceDraftPrompts,
|
||||
clearSessionDraftPrompt,
|
||||
getActiveParentSession,
|
||||
getActiveSession,
|
||||
getChildSessions,
|
||||
getParentSessions,
|
||||
getSessionDraftPrompt,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessions,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
loading,
|
||||
providers,
|
||||
sessionInfoByInstance,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
setSessionDraftPrompt,
|
||||
} from "./session-state"
|
||||
import { getDefaultModel } from "./session-models"
|
||||
import { computeDisplayParts, removeSessionIndexes } from "./session-messages"
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
fetchSessions,
|
||||
forkSession,
|
||||
loadMessages,
|
||||
} from "./session-api"
|
||||
import {
|
||||
abortSession,
|
||||
executeCustomCommand,
|
||||
runShellCommand,
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
} from "./session-actions"
|
||||
import {
|
||||
handleMessagePartRemoved,
|
||||
handleMessageRemoved,
|
||||
handleMessageUpdate,
|
||||
handlePermissionReplied,
|
||||
handlePermissionUpdated,
|
||||
handleSessionCompacted,
|
||||
handleSessionError,
|
||||
handleSessionIdle,
|
||||
handleSessionUpdate,
|
||||
handleTuiToast,
|
||||
} from "./session-events"
|
||||
|
||||
sseManager.onMessageUpdate = handleMessageUpdate
|
||||
sseManager.onMessagePartUpdated = handleMessageUpdate
|
||||
sseManager.onMessageRemoved = handleMessageRemoved
|
||||
sseManager.onMessagePartRemoved = handleMessagePartRemoved
|
||||
sseManager.onSessionUpdate = handleSessionUpdate
|
||||
sseManager.onSessionCompacted = handleSessionCompacted
|
||||
sseManager.onSessionError = handleSessionError
|
||||
sseManager.onSessionIdle = handleSessionIdle
|
||||
sseManager.onTuiToast = handleTuiToast
|
||||
sseManager.onPermissionUpdated = handlePermissionUpdated
|
||||
sseManager.onPermissionReplied = handlePermissionReplied
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
activeParentSessionId,
|
||||
activeSessionId,
|
||||
agents,
|
||||
clearActiveParentSession,
|
||||
clearInstanceDraftPrompts,
|
||||
clearSessionDraftPrompt,
|
||||
computeDisplayParts,
|
||||
createSession,
|
||||
deleteSession,
|
||||
executeCustomCommand,
|
||||
runShellCommand,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
fetchSessions,
|
||||
forkSession,
|
||||
getActiveParentSession,
|
||||
getActiveSession,
|
||||
getChildSessions,
|
||||
getDefaultModel,
|
||||
getParentSessions,
|
||||
getSessionDraftPrompt,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessions,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
loadMessages,
|
||||
loading,
|
||||
providers,
|
||||
removeSessionIndexes,
|
||||
sendMessage,
|
||||
sessionInfoByInstance,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
setSessionDraftPrompt,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
}
|
||||
export type { SessionInfo }
|
||||
36
packages/ui/src/stores/tool-call-state.ts
Normal file
36
packages/ui/src/stores/tool-call-state.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const [expandedItems, setExpandedItems] = createSignal<Set<string>>(new Set())
|
||||
|
||||
export function isItemExpanded(itemId: string): boolean {
|
||||
return expandedItems().has(itemId)
|
||||
}
|
||||
|
||||
export function toggleItemExpanded(itemId: string): void {
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(itemId)) {
|
||||
next.delete(itemId)
|
||||
} else {
|
||||
next.add(itemId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function setItemExpanded(itemId: string, expanded: boolean): void {
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (expanded) {
|
||||
next.add(itemId)
|
||||
} else {
|
||||
next.delete(itemId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Backward compatibility aliases
|
||||
export const isToolCallExpanded = isItemExpanded
|
||||
export const toggleToolCallExpanded = toggleItemExpanded
|
||||
export const setToolCallExpanded = setItemExpanded
|
||||
38
packages/ui/src/stores/ui.ts
Normal file
38
packages/ui/src/stores/ui.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const [hasInstances, setHasInstances] = createSignal(false)
|
||||
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
|
||||
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
|
||||
const [showFolderSelection, setShowFolderSelection] = createSignal(false)
|
||||
|
||||
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
|
||||
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
|
||||
|
||||
function reorderInstanceTabs(newOrder: string[]) {
|
||||
setInstanceTabOrder(newOrder)
|
||||
}
|
||||
|
||||
function reorderSessionTabs(instanceId: string, newOrder: string[]) {
|
||||
setSessionTabOrder((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, newOrder)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
hasInstances,
|
||||
setHasInstances,
|
||||
selectedFolder,
|
||||
setSelectedFolder,
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
instanceTabOrder,
|
||||
setInstanceTabOrder,
|
||||
sessionTabOrder,
|
||||
setSessionTabOrder,
|
||||
reorderInstanceTabs,
|
||||
reorderSessionTabs,
|
||||
}
|
||||
46
packages/ui/src/styles/components/badges.css
Normal file
46
packages/ui/src/styles/components/badges.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/* Badge + status utilities */
|
||||
.neutral-badge {
|
||||
@apply inline-flex items-center rounded px-1.5 py-0.5 text-xs font-normal;
|
||||
background-color: var(--badge-neutral-bg);
|
||||
color: var(--badge-neutral-text);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@apply inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-badge.ready {
|
||||
background-color: var(--status-ready-bg);
|
||||
color: var(--status-ready-fg);
|
||||
}
|
||||
|
||||
.status-badge.starting {
|
||||
background-color: var(--status-starting-bg);
|
||||
color: var(--status-starting-fg);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background-color: var(--status-error-bg);
|
||||
color: var(--status-error-fg);
|
||||
}
|
||||
|
||||
.status-badge.stopped {
|
||||
background-color: var(--status-stopped-bg);
|
||||
color: var(--status-stopped-fg);
|
||||
}
|
||||
|
||||
.status-dot.ready {
|
||||
background-color: var(--status-ready-fg);
|
||||
}
|
||||
|
||||
.status-dot.starting {
|
||||
background-color: var(--status-starting-fg);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background-color: var(--status-error-fg);
|
||||
}
|
||||
|
||||
.status-dot.stopped {
|
||||
background-color: var(--status-stopped-fg);
|
||||
}
|
||||
56
packages/ui/src/styles/components/buttons.css
Normal file
56
packages/ui/src/styles/components/buttons.css
Normal file
@@ -0,0 +1,56 @@
|
||||
/* Button component styles */
|
||||
.button-primary,
|
||||
button.button-primary {
|
||||
@apply px-6 py-3 text-base rounded-lg;
|
||||
background-color: var(--button-primary-bg);
|
||||
color: var(--button-primary-text);
|
||||
border-color: var(--button-primary-bg);
|
||||
}
|
||||
|
||||
.button-primary:hover:not(:disabled),
|
||||
button.button-primary:hover:not(:disabled) {
|
||||
background-color: var(--button-primary-hover-bg);
|
||||
border-color: var(--button-primary-hover-bg);
|
||||
}
|
||||
|
||||
.button-primary:focus-visible,
|
||||
button.button-primary:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
.button-secondary,
|
||||
button.button-secondary {
|
||||
@apply px-6 py-3 text-base rounded-lg;
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.button-secondary:hover:not(:disabled),
|
||||
button.button-secondary:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.button-secondary:focus-visible,
|
||||
button.button-secondary:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
.button-tertiary,
|
||||
button.button-tertiary {
|
||||
@apply px-4 py-2 text-sm rounded-lg;
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.button-tertiary:hover:not(:disabled),
|
||||
button.button-tertiary:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.button-tertiary:focus-visible,
|
||||
button.button-tertiary:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
72
packages/ui/src/styles/components/dropdown.css
Normal file
72
packages/ui/src/styles/components/dropdown.css
Normal file
@@ -0,0 +1,72 @@
|
||||
/* Dropdown utilities */
|
||||
.dropdown-surface {
|
||||
@apply absolute w-full rounded-md shadow-lg z-50;
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
@apply px-3 py-2 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.dropdown-header-title {
|
||||
@apply text-xs font-medium;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-section-header {
|
||||
@apply px-3 py-1.5 text-xs font-semibold;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
@apply overflow-y-auto;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@apply cursor-pointer px-3 py-2 transition-colors;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.dropdown-item-highlight {
|
||||
background-color: var(--dropdown-highlight-bg);
|
||||
color: var(--dropdown-highlight-text);
|
||||
}
|
||||
|
||||
.dropdown-empty {
|
||||
@apply px-3 py-4 text-center text-sm;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-loading {
|
||||
@apply p-4 text-center text-sm;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-footer {
|
||||
@apply border-t px-3 py-2 text-xs;
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-badge {
|
||||
@apply rounded px-1.5 py-0.5 text-xs font-normal;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
@apply flex-shrink-0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-icon-accent {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
29
packages/ui/src/styles/components/env-vars.css
Normal file
29
packages/ui/src/styles/components/env-vars.css
Normal file
@@ -0,0 +1,29 @@
|
||||
/* Environment variables display */
|
||||
.env-vars-container {
|
||||
@apply px-4 py-3 border-b;
|
||||
background-color: var(--env-vars-bg);
|
||||
border-color: var(--env-vars-border);
|
||||
}
|
||||
|
||||
.env-vars-title {
|
||||
@apply text-xs font-medium mb-2;
|
||||
color: var(--env-vars-text);
|
||||
}
|
||||
|
||||
.env-var-item {
|
||||
@apply flex items-center gap-2 text-xs;
|
||||
}
|
||||
|
||||
.env-var-key {
|
||||
@apply font-mono font-medium min-w-0 flex-1;
|
||||
color: var(--env-vars-text);
|
||||
}
|
||||
|
||||
.env-var-separator {
|
||||
color: var(--env-vars-text);
|
||||
}
|
||||
|
||||
.env-var-value {
|
||||
@apply font-mono min-w-0 flex-1;
|
||||
color: var(--env-vars-text);
|
||||
}
|
||||
32
packages/ui/src/styles/components/folder-loading.css
Normal file
32
packages/ui/src/styles/components/folder-loading.css
Normal file
@@ -0,0 +1,32 @@
|
||||
/* Folder loading overlay */
|
||||
.folder-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--folder-overlay-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.folder-loading-indicator {
|
||||
@apply flex flex-col items-center gap-3 text-center;
|
||||
padding: 24px 32px;
|
||||
border-radius: var(--folder-card-radius);
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
box-shadow: var(--folder-card-shadow);
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.folder-loading-text {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.folder-loading-subtext {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
252
packages/ui/src/styles/components/selector.css
Normal file
252
packages/ui/src/styles/components/selector.css
Normal file
@@ -0,0 +1,252 @@
|
||||
/* Selector component utilities */
|
||||
.selector-trigger {
|
||||
@apply inline-flex items-center justify-between gap-2 px-2 py-1 border rounded outline-none transition-colors text-xs min-w-[180px];
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-trigger:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.selector-trigger:focus {
|
||||
@apply ring-2;
|
||||
ring-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.selector-trigger-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.selector-trigger-label {
|
||||
@apply flex flex-col min-w-0;
|
||||
}
|
||||
|
||||
.selector-trigger-label--stacked {
|
||||
@apply items-start;
|
||||
}
|
||||
|
||||
.selector-trigger-primary {
|
||||
@apply text-sm font-medium truncate;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-trigger-primary--align-left {
|
||||
@apply text-left w-full;
|
||||
}
|
||||
|
||||
.selector-trigger-secondary {
|
||||
@apply text-xs text-left truncate;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-trigger-icon {
|
||||
@apply flex-shrink-0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-popover {
|
||||
@apply rounded-md shadow-lg overflow-hidden z-50 min-w-[300px];
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.selector-search-container {
|
||||
@apply p-2 border-b;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.selector-search-input {
|
||||
@apply w-full px-3 py-1.5 text-xs border rounded outline-none transition-colors;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-search-input:focus {
|
||||
@apply ring-2;
|
||||
ring-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.selector-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-listbox {
|
||||
@apply max-h-64 overflow-auto p-1;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.selector-option {
|
||||
@apply px-3 py-2 cursor-pointer rounded outline-none transition-colors flex items-start gap-2 w-full;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-option:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.selector-option[data-highlighted],
|
||||
.selector-option[data-focused] {
|
||||
background-color: var(--selection-highlight-bg);
|
||||
}
|
||||
|
||||
.selector-option[data-selected],
|
||||
.selector-option-selected {
|
||||
background-color: var(--selection-highlight-strong-bg);
|
||||
}
|
||||
|
||||
.selector-option-content {
|
||||
@apply flex flex-col flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.selector-option-label {
|
||||
@apply font-medium text-sm;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-option-description {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-option .remove-binary-button {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.selector-option:hover .remove-binary-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.remove-binary-button {
|
||||
@apply p-1 rounded transition-all;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.remove-binary-button:hover {
|
||||
background-color: var(--danger-soft-bg);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.selector-option-indicator {
|
||||
@apply flex-shrink-0 mt-0.5;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.selector-section {
|
||||
@apply px-3 py-2 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.selector-section-title {
|
||||
@apply text-xs font-medium;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-badge {
|
||||
@apply rounded px-1.5 py-0.5 text-xs font-normal;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.selector-badge-version {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-badge-time {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-validation-error {
|
||||
@apply p-2 rounded border;
|
||||
background-color: var(--message-error-bg);
|
||||
border-color: var(--status-error);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.selector-validation-error-content {
|
||||
@apply flex items-start gap-2;
|
||||
}
|
||||
|
||||
.selector-validation-error-icon {
|
||||
@apply w-4 h-4 mt-0.5 flex-shrink-0;
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.selector-validation-error-text {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.selector-input-group {
|
||||
@apply flex gap-2;
|
||||
}
|
||||
|
||||
.selector-input {
|
||||
@apply flex-1 px-2 py-1.5 text-sm border rounded outline-none transition-colors;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-input:focus {
|
||||
@apply ring-1;
|
||||
ring-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.selector-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-button {
|
||||
@apply px-3 py-1.5 text-sm rounded transition-colors cursor-pointer w-full inline-flex items-center justify-center font-medium;
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.selector-button-primary {
|
||||
background-color: var(--accent-primary) !important;
|
||||
color: var(--text-inverted) !important;
|
||||
border: 1px solid var(--accent-primary) !important;
|
||||
}
|
||||
|
||||
.selector-button-primary:hover:not(:disabled) {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.selector-button-primary:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
background-color: var(--surface-muted);
|
||||
}
|
||||
|
||||
.selector-button-secondary {
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-button-secondary:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.selector-button-secondary:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.selector-empty-state {
|
||||
@apply p-4 text-center text-sm;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-loading {
|
||||
@apply flex items-center gap-2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-loading-spinner {
|
||||
@apply w-4 h-4 animate-spin;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
6
packages/ui/src/styles/controls.css
Normal file
6
packages/ui/src/styles/controls.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@import "./components/buttons.css";
|
||||
@import "./components/badges.css";
|
||||
@import "./components/folder-loading.css";
|
||||
@import "./components/dropdown.css";
|
||||
@import "./components/selector.css";
|
||||
@import "./components/env-vars.css";
|
||||
246
packages/ui/src/styles/markdown.css
Normal file
246
packages/ui/src/styles/markdown.css
Normal file
@@ -0,0 +1,246 @@
|
||||
@import "github-markdown-css/github-markdown-dark.css";
|
||||
|
||||
@layer components {
|
||||
.markdown-body {
|
||||
max-width: none;
|
||||
background-color: transparent;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-normal);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.markdown-body p,
|
||||
.markdown-body ul,
|
||||
.markdown-body ol,
|
||||
.markdown-body blockquote,
|
||||
.markdown-body table {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.3;
|
||||
margin-top: 0.9em;
|
||||
margin-bottom: 0.55em;
|
||||
}
|
||||
|
||||
.markdown-body h6 {
|
||||
font-size: calc(var(--font-size-base) + 0.5px);
|
||||
}
|
||||
|
||||
.markdown-body h5 {
|
||||
font-size: calc(var(--font-size-base) + 1px);
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
font-size: calc(var(--font-size-base) + 1.5px);
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: calc(var(--font-size-base) + 2px);
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: calc(var(--font-size-base) + 2.5px);
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: calc(var(--font-size-base) + 3px);
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2 {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.markdown-body strong {
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--message-assistant-border);
|
||||
}
|
||||
|
||||
.markdown-body em {
|
||||
font-style: italic;
|
||||
border-bottom: none;
|
||||
font-size: calc(var(--font-size-base) + 1.5px);
|
||||
margin-top: 0.3em;
|
||||
margin-bottom: 0.3em;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body a:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
background-color: var(--surface-muted);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.35em;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-normal);
|
||||
background-color: var(--surface-code);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid var(--border-base);
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--surface-muted);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.markdown-body ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.markdown-body ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-body ul li,
|
||||
.markdown-body ol li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
background-color: transparent;
|
||||
display: block;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-body thead,
|
||||
.markdown-body tbody,
|
||||
.markdown-body tfoot {
|
||||
width: 100%;
|
||||
display: table;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
border: 1px solid var(--border-base);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.markdown-code-block {
|
||||
position: relative;
|
||||
margin: 10px 0;
|
||||
border-radius: 6px;
|
||||
background-color: var(--surface-muted);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
/* border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px; */
|
||||
}
|
||||
|
||||
.code-block-language {
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.code-block-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.code-block-copy:hover {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.code-block-copy .copy-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.code-block-copy .copy-text {
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.markdown-code-block pre {
|
||||
margin: 0 !important;
|
||||
padding: 12px !important;
|
||||
overflow-x: auto;
|
||||
background-color: transparent !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.markdown-code-block code {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
166
packages/ui/src/styles/messaging.css
Normal file
166
packages/ui/src/styles/messaging.css
Normal file
@@ -0,0 +1,166 @@
|
||||
@import "./messaging/message-base.css";
|
||||
@import "./messaging/prompt-input.css";
|
||||
@import "./messaging/message-stream.css";
|
||||
@import "./messaging/tool-call.css";
|
||||
@import "./messaging/log-view.css";
|
||||
|
||||
/* Message item base styles */
|
||||
|
||||
.message-item-base {
|
||||
@apply flex flex-col gap-2 p-3 w-full;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
/* gap: 0.25rem; */
|
||||
padding: 0.6rem 0.65rem;
|
||||
}
|
||||
|
||||
/* Message state badges */
|
||||
.message-queued-badge {
|
||||
@apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
/* Message error block */
|
||||
.message-error-block {
|
||||
@apply text-sm p-3 rounded border-l-[3px] my-2;
|
||||
color: var(--status-error);
|
||||
background-color: var(--message-error-bg);
|
||||
border-color: var(--status-error);
|
||||
}
|
||||
|
||||
/* Message state indicators */
|
||||
.message-generating {
|
||||
@apply text-sm italic py-2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-sending {
|
||||
@apply text-xs italic mt-1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-error {
|
||||
@apply text-xs mt-1;
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.generating-spinner {
|
||||
@apply inline-block;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Message stream component utilities */
|
||||
.message-stream-container {
|
||||
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
@apply grid items-center px-4 py-2 gap-4;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.message-stream {
|
||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-scroll-button-wrapper {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-scroll-button {
|
||||
@apply inline-flex items-center justify-center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--scroll-elevation-shadow);
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.message-scroll-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.message-scroll-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-scroll-icon {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-normal);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-text-assistant {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.message-text pre {
|
||||
overflow-x: auto;
|
||||
padding: 8px;
|
||||
background-color: var(--surface-code);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Message error part */
|
||||
.message-error-part {
|
||||
color: var(--status-error);
|
||||
font-size: var(--font-size-base);
|
||||
padding: 8px;
|
||||
background-color: var(--message-error-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Message reasoning */
|
||||
.message-reasoning {
|
||||
@apply my-2 border rounded;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.reasoning-container {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
.reasoning-header {
|
||||
@apply flex items-center gap-1.5 text-xs cursor-pointer select-none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reasoning-header:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.reasoning-icon {
|
||||
@apply text-xs;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.reasoning-label {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
74
packages/ui/src/styles/messaging/log-view.css
Normal file
74
packages/ui/src/styles/messaging/log-view.css
Normal file
@@ -0,0 +1,74 @@
|
||||
/* Log view utilities */
|
||||
.log-container {
|
||||
@apply flex flex-col h-full;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.log-header {
|
||||
@apply flex items-center justify-between px-4 py-3 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.log-content {
|
||||
@apply flex-1 overflow-y-auto p-4 font-mono text-xs leading-relaxed;
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
@apply flex gap-3 py-0.5 px-2 -mx-2 rounded transition-colors;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
@apply select-none shrink-0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
@apply break-all;
|
||||
}
|
||||
|
||||
.log-level-error {
|
||||
color: var(--log-level-error);
|
||||
}
|
||||
|
||||
.log-level-warn {
|
||||
color: var(--log-level-warn);
|
||||
}
|
||||
|
||||
.log-level-debug {
|
||||
color: var(--log-level-debug);
|
||||
}
|
||||
|
||||
.log-level-default {
|
||||
color: var(--log-level-default);
|
||||
}
|
||||
|
||||
.log-empty-state {
|
||||
@apply text-center py-8;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.log-paused-state {
|
||||
@apply flex flex-col items-center justify-center gap-3 text-center py-10 px-6;
|
||||
border: 1px dashed var(--border-base);
|
||||
border-radius: 12px;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.log-paused-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.log-paused-description {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
max-width: 320px;
|
||||
}
|
||||
98
packages/ui/src/styles/messaging/message-base.css
Normal file
98
packages/ui/src/styles/messaging/message-base.css
Normal file
@@ -0,0 +1,98 @@
|
||||
/* Message item base styles */
|
||||
.message-item-base {
|
||||
@apply flex flex-col gap-2 p-3 w-full;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
/* gap: 0.25rem; */
|
||||
padding: 0.6rem 0.65rem;
|
||||
}
|
||||
|
||||
.message-queued-badge {
|
||||
@apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.message-error-block {
|
||||
@apply text-sm p-3 rounded border-l-[3px] my-2;
|
||||
color: var(--status-error);
|
||||
background-color: var(--message-error-bg);
|
||||
border-color: var(--status-error);
|
||||
}
|
||||
|
||||
.message-generating {
|
||||
@apply text-sm italic py-2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-sending {
|
||||
@apply text-xs italic mt-1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-error {
|
||||
@apply text-xs mt-1;
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.generating-spinner {
|
||||
@apply inline-block;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-normal);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-text-assistant {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.message-text pre {
|
||||
overflow-x: auto;
|
||||
padding: 8px;
|
||||
background-color: var(--surface-code);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-error-part {
|
||||
color: var(--status-error);
|
||||
font-size: var(--font-size-base);
|
||||
padding: 8px;
|
||||
background-color: var(--message-error-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-reasoning {
|
||||
@apply my-2 border rounded;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.reasoning-container {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
.reasoning-header {
|
||||
@apply flex items-center gap-1.5 text-xs cursor-pointer select-none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reasoning-header:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.reasoning-icon {
|
||||
@apply text-xs;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.reasoning-label {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
71
packages/ui/src/styles/messaging/message-stream.css
Normal file
71
packages/ui/src/styles/messaging/message-stream.css
Normal file
@@ -0,0 +1,71 @@
|
||||
/* Message stream container + status */
|
||||
.message-stream-container {
|
||||
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
@apply grid items-center px-4 py-2 gap-4;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.connection-status-info {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.connection-status-shortcut {
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connection-status-meta {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.connection-status-text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-stream {
|
||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-scroll-button-wrapper {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-scroll-button {
|
||||
@apply inline-flex items-center justify-center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--scroll-elevation-shadow);
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.message-scroll-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.message-scroll-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-scroll-icon {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
112
packages/ui/src/styles/messaging/prompt-input.css
Normal file
112
packages/ui/src/styles/messaging/prompt-input.css
Normal file
@@ -0,0 +1,112 @@
|
||||
/* Prompt input & attachment styles */
|
||||
.prompt-input-container {
|
||||
@apply flex flex-col border-t;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.prompt-input-wrapper {
|
||||
@apply flex items-end gap-2 p-3;
|
||||
}
|
||||
|
||||
|
||||
.prompt-input {
|
||||
@apply flex-1 min-h-[96px] max-h-[200px] p-2.5 border rounded-md text-sm resize-none outline-none transition-colors;
|
||||
font-family: inherit;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
border-color: var(--border-base);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.prompt-input.shell-mode {
|
||||
border-color: var(--status-success);
|
||||
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.prompt-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.prompt-input.shell-mode:focus {
|
||||
border-color: var(--status-success);
|
||||
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.prompt-input:disabled {
|
||||
@apply opacity-60 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.prompt-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.send-button.shell-mode {
|
||||
background-color: var(--status-success);
|
||||
}
|
||||
|
||||
.send-button.shell-mode:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.send-button.shell-mode:active:not(:disabled) {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
@apply opacity-90 scale-105;
|
||||
}
|
||||
|
||||
.send-button:active:not(:disabled) {
|
||||
@apply scale-95;
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
@apply opacity-40 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.send-icon {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.shell-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.prompt-input-hints {
|
||||
@apply px-4 pb-2 flex justify-between items-center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hint kbd {
|
||||
@apply inline-block px-1.5 py-0.5 text-xs font-mono rounded mx-0.5;
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.attachment-chip {
|
||||
@apply px-2.5 py-1 text-xs font-medium ring-1 ring-inset;
|
||||
background-color: var(--attachment-chip-bg);
|
||||
color: var(--attachment-chip-text);
|
||||
ring-color: var(--attachment-chip-ring);
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
@apply ml-0.5 flex h-4 w-4 items-center justify-center rounded transition-colors;
|
||||
}
|
||||
|
||||
.attachment-remove:hover {
|
||||
background-color: var(--attachment-chip-ring);
|
||||
}
|
||||
789
packages/ui/src/styles/messaging/tool-call.css
Normal file
789
packages/ui/src/styles/messaging/tool-call.css
Normal file
@@ -0,0 +1,789 @@
|
||||
/* Tool call rendering */
|
||||
.tool-call-message {
|
||||
@apply flex flex-col gap-2 p-3 w-full;
|
||||
background-color: var(--message-tool-bg);
|
||||
border-left: 4px solid var(--message-tool-border);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tool-call-header-label {
|
||||
@apply flex items-center justify-between gap-2 font-semibold text-sm;
|
||||
color: var(--message-tool-border);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.tool-call-header-meta {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.tool-call-header-button {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-base);
|
||||
color: var(--message-tool-border);
|
||||
padding: 0.15rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 1.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-call-header-button:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tool-call-header-button:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.tool-call-header-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tool-call-header-label .tool-call-icon {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.tool-call-header-label .tool-name {
|
||||
font-family: var(--font-family-mono);
|
||||
color: inherit;
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
@apply border overflow-hidden;
|
||||
border-color: var(--border-base);
|
||||
color: inherit;
|
||||
--tool-call-line-unit: 1.4em;
|
||||
--tool-call-lines-compact: 15;
|
||||
--tool-call-lines-large: 30;
|
||||
--tool-call-max-height-compact: calc(var(--tool-call-lines-compact) * var(--tool-call-line-unit));
|
||||
--tool-call-max-height-large: calc(var(--tool-call-lines-large) * var(--tool-call-line-unit));
|
||||
}
|
||||
|
||||
.tool-call-message .tool-call {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
@apply flex items-center gap-2 p-2 w-full bg-transparent border-none cursor-pointer text-left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tool-call-header:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tool-call-icon {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.tool-call-summary {
|
||||
@apply flex-1 text-left;
|
||||
}
|
||||
|
||||
.tool-call-status {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.tool-call-status-success {
|
||||
border-left: 3px solid var(--status-success);
|
||||
}
|
||||
|
||||
.tool-call-status-error {
|
||||
border-left: 3px solid var(--status-error);
|
||||
}
|
||||
|
||||
.tool-call-status-running {
|
||||
border-left: 3px solid var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-status-running .tool-call-status {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tool-call-status-pending {
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.tool-call-status-pending .tool-call-summary {
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tool-call-preview {
|
||||
@apply p-2 flex flex-col gap-1.5;
|
||||
background-color: var(--surface-code);
|
||||
border-top: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.tool-call-preview-label {
|
||||
@apply text-xs font-semibold uppercase tracking-wide;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tool-call-preview-text {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.tool-call-details {
|
||||
@apply flex flex-col;
|
||||
background-color: var(--surface-code);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.tool-call-markdown {
|
||||
background-color: var(--surface-code);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-base) transparent;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-call-markdown-large {
|
||||
max-height: var(--tool-call-max-height-large, calc(48 * 1.4em));
|
||||
}
|
||||
|
||||
.tool-call-diff-shell {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool-call-diff-viewer {
|
||||
max-height: var(--tool-call-max-height-large, calc(48 * 1.4em));
|
||||
overflow: auto;
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-diff-toolbar {
|
||||
@apply flex items-center justify-between gap-3 px-3 py-2;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.tool-call-diff-toolbar-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.tool-call-diff-toggle {
|
||||
@apply inline-flex items-center gap-1;
|
||||
}
|
||||
|
||||
.tool-call-diff-mode-button {
|
||||
@apply border text-xs font-semibold px-3 py-1 rounded transition-all duration-150;
|
||||
border-color: var(--border-base);
|
||||
background-color: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-diff-mode-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-diff-mode-button.active {
|
||||
background-color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.tool-call-diff-viewer .diff-tailwindcss-wrapper {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tool-call-diff-viewer .diff-view-wrapper {
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.tool-call-diff-fallback {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--surface-code);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.tool-call-awaiting-permission {
|
||||
border-left-color: var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-permission {
|
||||
@apply flex flex-col gap-3;
|
||||
border: 2px solid var(--status-warning);
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
background-color: var(--message-tool-bg);
|
||||
}
|
||||
|
||||
.tool-call-permission-header {
|
||||
@apply flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.tool-call-permission-label {
|
||||
@apply font-semibold text-sm;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-permission-type {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-permission-title code {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface-code);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-call-permission-actions {
|
||||
@apply flex items-center justify-between gap-3 flex-wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-buttons {
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.tool-call-permission-button {
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--status-warning);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.4rem 1.05rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.15;
|
||||
transition: transform 0.15s ease, color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-button:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--status-warning);
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-permission-button:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.tool-call-permission-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tool-call-permission-shortcuts {
|
||||
@apply flex items-center gap-2 text-xs text-muted;
|
||||
}
|
||||
|
||||
.tool-call-permission-shortcuts .kbd {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-queued-text {
|
||||
@apply text-sm text-muted;
|
||||
}
|
||||
|
||||
.tool-call-permission-error {
|
||||
@apply text-sm;
|
||||
color: var(--status-error);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-diff {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-diff .tool-call-diff-shell {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-diff-viewer .diff-line-old-num,
|
||||
.tool-call-diff-viewer .diff-line-new-num,
|
||||
.tool-call-diff-viewer .diff-line-num {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.tool-call-markdown .markdown-code-block {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
||||
.tool-call-markdown .markdown-code-block {
|
||||
margin: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tool-call-markdown .code-block-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: auto;
|
||||
box-shadow: 0 1px 0 var(--border-base);
|
||||
}
|
||||
|
||||
.tool-call-markdown .markdown-code-block pre {
|
||||
margin: 0 !important;
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.tool-call-markdown::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.tool-call-markdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tool-call-markdown::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-base);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.tool-call-section h4 {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-top: 1px solid var(--border-base);
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-wrapper {
|
||||
border-top: 1px solid var(--border-base);
|
||||
background-color: var(--surface-base);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-heading {
|
||||
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--message-tool-border);
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-heading:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-file {
|
||||
@apply inline-flex items-center;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
.tool-call-diagnostics-heading {
|
||||
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--message-tool-border);
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-heading:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-base);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-file {
|
||||
@apply inline-flex items-center;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-caret {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md) var(--space-sm) var(--space-md);
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
max-height: calc(4 * var(--tool-call-line-unit, 1.4em));
|
||||
overflow-y: scroll;
|
||||
padding-right: 0;
|
||||
margin-right: 0;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 var(--space-sm);
|
||||
min-height: 20px;
|
||||
border-radius: var(--pill-radius);
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-error {
|
||||
background-color: var(--status-error-bg);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-warning {
|
||||
background-color: var(--status-starting-bg);
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-info {
|
||||
background-color: var(--badge-neutral-bg);
|
||||
color: var(--badge-neutral-text);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-path {
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-coords {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-message {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-section pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--surface-base);
|
||||
border-radius: 0px;
|
||||
overflow-x: auto;
|
||||
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.tool-call-section code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar-track {
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar-thumb {
|
||||
background: var(--border-base);
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-pending-message {
|
||||
@apply flex items-center gap-2 p-3 text-xs italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-emoji {
|
||||
@apply text-base mr-1;
|
||||
}
|
||||
|
||||
.tool-call-action-button {
|
||||
@apply border text-xs font-semibold px-3 py-1 rounded transition-colors h-8 flex items-center;
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-muted);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tool-call-action-button:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-action-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tool-call-bash,
|
||||
.tool-call-diff {
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
.tool-call-content {
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 0;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-content code {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.tool-call-todos {
|
||||
@apply my-2 flex flex-col gap-2;
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.tool-call-todo-item {
|
||||
@apply flex items-start gap-3;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.tool-call-todo-item-completed {
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-todo-item-active {
|
||||
border-color: var(--accent-primary);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tool-call-todo-item-cancelled {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid var(--border-base);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-muted);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox::after {
|
||||
content: "";
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox[data-status="completed"] {
|
||||
background-color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox[data-status="completed"]::after {
|
||||
content: "✓";
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox[data-status="in_progress"]::after {
|
||||
content: "…";
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox[data-status="cancelled"]::after {
|
||||
content: "×";
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.tool-call-todo-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-call-todo-text {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-todo-item-cancelled .tool-call-todo-text {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-todo-tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-radius: 9999px;
|
||||
padding: 2px 8px;
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-todo-item-active .tool-call-todo-tag {
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.tool-call-task-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tool-call-task-summary {
|
||||
@apply my-2 flex flex-col gap-1.5;
|
||||
}
|
||||
|
||||
.tool-call-task-item {
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-normal);
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--border-base);
|
||||
}
|
||||
|
||||
.tool-call-task-item::before {
|
||||
content: "∟ ";
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-error-content {
|
||||
background-color: var(--message-error-bg);
|
||||
border-left: 3px solid var(--status-error);
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 4px;
|
||||
color: var(--status-error);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.tool-call-error-content strong {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.dropdown-diff-added {
|
||||
@apply text-xs;
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.dropdown-diff-removed {
|
||||
@apply text-xs;
|
||||
color: var(--status-error);
|
||||
}
|
||||
676
packages/ui/src/styles/panels.css
Normal file
676
packages/ui/src/styles/panels.css
Normal file
@@ -0,0 +1,676 @@
|
||||
@import "./panels/tabs.css";
|
||||
@import "./panels/empty-loading.css";
|
||||
@import "./panels/modal.css";
|
||||
@import "./panels/panel-shell.css";
|
||||
@import "./panels/session-layout.css";
|
||||
|
||||
|
||||
.tab-bar-instance {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.tab-bar-session {
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
@apply flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto;
|
||||
}
|
||||
|
||||
.tab-base {
|
||||
@apply inline-flex items-center gap-2 px-3 py-2 rounded-t-md max-w-[200px] transition-colors text-sm font-medium;
|
||||
font-family: var(--font-family-sans);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tab-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
background-color: var(--tab-active-bg);
|
||||
color: var(--tab-active-text);
|
||||
}
|
||||
|
||||
.tab-inactive {
|
||||
background-color: var(--tab-inactive-bg);
|
||||
color: var(--tab-inactive-text);
|
||||
}
|
||||
|
||||
.tab-inactive:hover {
|
||||
background-color: var(--tab-inactive-hover-bg);
|
||||
}
|
||||
|
||||
.tab-active:hover {
|
||||
background-color: var(--tab-active-hover-bg);
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
@apply opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-all cursor-pointer;
|
||||
}
|
||||
|
||||
.tab-close:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: inherit;
|
||||
}
|
||||
|
||||
.new-tab-button {
|
||||
@apply inline-flex items-center justify-center w-8 h-8 rounded-md transition-colors;
|
||||
background-color: var(--new-tab-bg);
|
||||
color: var(--new-tab-text);
|
||||
}
|
||||
|
||||
.new-tab-button:hover {
|
||||
background-color: var(--new-tab-hover-bg);
|
||||
}
|
||||
|
||||
.new-tab-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
/* Session tab specific styles */
|
||||
.session-tab-base {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm;
|
||||
font-family: var(--font-family-sans);
|
||||
outline: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.session-tab-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.session-tab-active {
|
||||
background-color: var(--session-tab-active-bg);
|
||||
border-bottom-color: var(--accent-primary);
|
||||
color: var(--session-tab-active-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.session-tab-inactive {
|
||||
color: var(--session-tab-inactive-text);
|
||||
}
|
||||
|
||||
.session-tab-inactive:hover {
|
||||
background-color: var(--session-tab-hover-bg);
|
||||
}
|
||||
|
||||
.session-tab-special {
|
||||
color: var(--session-tab-inactive-text);
|
||||
}
|
||||
|
||||
.connection-status-info {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.connection-status-shortcut {
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connection-status-meta {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.connection-status-text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-selector-hint {
|
||||
@apply flex justify-center text-xs w-full;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-header-hints {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.session-sidebar-controls .selector-trigger,
|
||||
.session-sidebar-controls [data-model-selector-control],
|
||||
.session-sidebar-controls .selector-trigger-label,
|
||||
.session-sidebar-controls .selector-trigger-primary {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.sidebar-selector {
|
||||
@apply flex flex-col gap-1 w-full;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@apply flex items-center gap-1.5 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator .status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-indicator.connected .status-dot {
|
||||
background-color: var(--status-success);
|
||||
}
|
||||
|
||||
.status-indicator.connecting .status-dot {
|
||||
background-color: var(--status-warning);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected .status-dot {
|
||||
background-color: var(--status-error);
|
||||
}
|
||||
|
||||
.status-indicator.session-status {
|
||||
--session-status-dot: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working,
|
||||
.status-indicator.session-status.session-compacting,
|
||||
.status-indicator.session-status.session-idle {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working {
|
||||
color: var(--session-status-working-fg);
|
||||
--session-status-dot: var(--session-status-working-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting {
|
||||
color: var(--session-status-compacting-fg);
|
||||
--session-status-dot: var(--session-status-compacting-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-idle {
|
||||
color: var(--session-status-idle-fg);
|
||||
--session-status-dot: var(--session-status-idle-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-permission {
|
||||
color: var(--session-status-permission-fg);
|
||||
--session-status-dot: var(--session-status-permission-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status .status-dot {
|
||||
background-color: var(--session-status-dot);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working .status-dot,
|
||||
.status-indicator.session-status.session-compacting .status-dot {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working.session-status-list {
|
||||
background-color: var(--session-status-working-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting.session-status-list {
|
||||
background-color: var(--session-status-compacting-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-idle.session-status-list {
|
||||
background-color: var(--session-status-idle-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-permission.session-status-list {
|
||||
background-color: var(--session-status-permission-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status-list {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
@apply flex-1 flex items-center justify-center p-12;
|
||||
}
|
||||
|
||||
.empty-state-content {
|
||||
@apply text-center max-w-sm;
|
||||
}
|
||||
|
||||
.empty-state-content h3 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state-content p {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state-content ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.empty-state-content li {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-content code {
|
||||
background-color: var(--surface-code);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading-state {
|
||||
@apply flex-1 flex flex-col items-center justify-center gap-4 p-12;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply w-8 h-8 border-2 border-t-transparent rounded-full;
|
||||
border-color: var(--border-base);
|
||||
border-top-color: var(--accent-primary);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Modal utilities */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-50;
|
||||
background-color: var(--overlay-scrim);
|
||||
}
|
||||
|
||||
.modal-surface {
|
||||
@apply rounded-lg shadow-2xl flex flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-search-container {
|
||||
@apply p-4 border-b;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.modal-search-input {
|
||||
@apply flex-1 bg-transparent outline-none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-search-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-list-container {
|
||||
@apply flex-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
.modal-section-header {
|
||||
@apply px-4 py-2 text-xs font-semibold uppercase tracking-wide;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-item {
|
||||
@apply w-full px-4 py-3 flex items-start gap-3 transition-colors cursor-pointer border-none text-left;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.modal-list-container[data-pointer-mode="keyboard"] .modal-item:hover:not(.modal-item-highlight) {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.modal-item-highlight {
|
||||
background-color: var(--selection-highlight-bg);
|
||||
}
|
||||
|
||||
.modal-item-label {
|
||||
@apply font-medium;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-item-description {
|
||||
@apply text-sm mt-0.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-empty-state {
|
||||
@apply p-8 text-center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Panel component utilities */
|
||||
.panel {
|
||||
@apply rounded-lg shadow-sm border overflow-hidden;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-footer-hints {
|
||||
@apply flex items-center justify-center flex-wrap gap-3 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply px-4 py-3 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@apply text-base font-semibold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
@apply text-xs mt-0.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
@apply p-4;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
@apply border-t;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.panel-section-header {
|
||||
@apply w-full px-4 py-3 flex items-center justify-center transition-colors cursor-pointer gap-2;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-section-header:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.panel-section-content {
|
||||
@apply px-4 py-3 border-t overflow-visible space-y-4 w-full;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
@apply max-h-[400px] overflow-y-auto;
|
||||
}
|
||||
|
||||
.panel-list--fill {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-list-item {
|
||||
@apply border-b last:border-b-0 transition-colors w-full;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.panel-list-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.panel-list-item-highlight {
|
||||
background-color: var(--list-item-highlight-bg) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.panel-list-item-content {
|
||||
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
|
||||
}
|
||||
|
||||
.panel-list-item-content:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.panel-list-item-content:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.panel-list-item button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-list-item-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.panel-empty-state {
|
||||
@apply p-6 text-center;
|
||||
}
|
||||
|
||||
.panel-empty-state-icon {
|
||||
@apply text-gray-400 dark:text-gray-600 mb-2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-empty-state-title {
|
||||
@apply font-medium text-sm mb-1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-empty-state-description {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
@apply px-4 py-3 border-t;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
/* Session view utility */
|
||||
.session-view {
|
||||
@apply flex flex-1 min-h-0 flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Session list component */
|
||||
.session-list-container {
|
||||
@apply flex flex-col flex-1 min-h-0 relative;
|
||||
background-color: var(--surface-secondary);
|
||||
min-width: 200px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.session-sidebar {
|
||||
@apply flex flex-col min-h-0;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-header {
|
||||
@apply flex flex-col gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-sidebar-shortcuts {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.session-sidebar-new {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-controls {
|
||||
@apply flex flex-col gap-3;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-controls > * {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-separator {
|
||||
background-color: var(--border-base);
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.session-resize-handle {
|
||||
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.session-resize-handle:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.session-resize-handle::before {
|
||||
content: "";
|
||||
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
@apply border-b relative;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-list-header h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.session-list {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.session-list-item {
|
||||
@apply border-b last:border-b-0;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-item-base {
|
||||
@apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.session-item-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-item-row {
|
||||
@apply flex items-center gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-item-header {
|
||||
@apply justify-between;
|
||||
}
|
||||
|
||||
.session-item-title-row {
|
||||
@apply flex items-center gap-2 min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
@apply justify-between items-center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.session-item-active .session-item-meta {
|
||||
color: var(--text-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-item-actions {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.session-item-active {
|
||||
background-color: var(--list-item-highlight-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.session-item-inactive {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-item-inactive:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-active .session-item-close:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
@apply flex-1 min-w-0;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.session-item-close {
|
||||
@apply flex-shrink-0 p-0.5 rounded transition-all;
|
||||
}
|
||||
|
||||
.session-item-close:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: inherit;
|
||||
}
|
||||
|
||||
.session-list-footer {
|
||||
@apply border-t;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-new-button {
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.session-new-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.session-new-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
/* Responsive behavior for session list */
|
||||
@media (max-width: 768px) {
|
||||
.session-list-container {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.session-item-base {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
}
|
||||
49
packages/ui/src/styles/panels/empty-loading.css
Normal file
49
packages/ui/src/styles/panels/empty-loading.css
Normal file
@@ -0,0 +1,49 @@
|
||||
/* Empty + loading panels */
|
||||
.empty-state {
|
||||
@apply flex-1 flex items-center justify-center p-12;
|
||||
}
|
||||
|
||||
.empty-state-content {
|
||||
@apply text-center max-w-sm;
|
||||
}
|
||||
|
||||
.empty-state-content h3 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state-content p {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state-content ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.empty-state-content li {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-content code {
|
||||
background-color: var(--surface-code);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
@apply flex-1 flex flex-col items-center justify-center gap-4 p-12;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply w-8 h-8 border-2 border-t-transparent rounded-full;
|
||||
border-color: var(--border-base);
|
||||
border-top-color: var(--accent-primary);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
70
packages/ui/src/styles/panels/modal.css
Normal file
70
packages/ui/src/styles/panels/modal.css
Normal file
@@ -0,0 +1,70 @@
|
||||
/* Modal utilities */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-50;
|
||||
background-color: var(--overlay-scrim);
|
||||
}
|
||||
|
||||
.modal-surface {
|
||||
@apply rounded-lg shadow-2xl flex flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-search-container {
|
||||
@apply p-4 border-b;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.modal-search-input {
|
||||
@apply flex-1 bg-transparent outline-none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-search-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-list-container {
|
||||
@apply flex-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
.modal-section-header {
|
||||
@apply px-4 py-2 text-xs font-semibold uppercase tracking-wide;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-item {
|
||||
@apply w-full px-4 py-3 flex items-start gap-3 transition-colors cursor-pointer border-none text-left;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.modal-list-container[data-pointer-mode="keyboard"] .modal-item:hover:not(.modal-item-highlight) {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.modal-item-highlight {
|
||||
background-color: var(--selection-highlight-bg);
|
||||
}
|
||||
|
||||
.modal-item-label {
|
||||
@apply font-medium;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-item-description {
|
||||
@apply text-sm mt-0.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-empty-state {
|
||||
@apply p-8 text-center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
121
packages/ui/src/styles/panels/panel-shell.css
Normal file
121
packages/ui/src/styles/panels/panel-shell.css
Normal file
@@ -0,0 +1,121 @@
|
||||
/* Panel component shells */
|
||||
.panel {
|
||||
@apply rounded-lg shadow-sm border overflow-hidden;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-footer-hints {
|
||||
@apply flex items-center justify-center flex-wrap gap-3 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply px-4 py-3 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@apply text-base font-semibold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
@apply text-xs mt-0.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
@apply p-4;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
@apply border-t;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.panel-section-header {
|
||||
@apply w-full px-4 py-3 flex items-center justify-center transition-colors cursor-pointer gap-2;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-section-header:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.panel-section-content {
|
||||
@apply px-4 py-3 border-t overflow-visible space-y-4 w-full;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
@apply max-h-[400px] overflow-y-auto;
|
||||
}
|
||||
|
||||
.panel-list--fill {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-list-item {
|
||||
@apply border-b last:border-b-0 transition-colors w-full;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.panel-list-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.panel-list-item-highlight {
|
||||
background-color: var(--list-item-highlight-bg) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.panel-list-item-content {
|
||||
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
|
||||
}
|
||||
|
||||
.panel-list-item-content:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.panel-list-item-content:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.panel-list-item button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-list-item-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.panel-empty-state {
|
||||
@apply p-6 text-center;
|
||||
}
|
||||
|
||||
.panel-empty-state-icon {
|
||||
@apply text-gray-400 dark:text-gray-600 mb-2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-empty-state-title {
|
||||
@apply font-medium text-sm mb-1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-empty-state-description {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
@apply px-4 py-3 border-t;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
301
packages/ui/src/styles/panels/session-layout.css
Normal file
301
packages/ui/src/styles/panels/session-layout.css
Normal file
@@ -0,0 +1,301 @@
|
||||
/* Session view + sidebar */
|
||||
.session-view {
|
||||
@apply flex flex-1 min-h-0 flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.session-list-container {
|
||||
@apply flex flex-col flex-1 min-h-0 relative;
|
||||
background-color: var(--surface-secondary);
|
||||
min-width: 200px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.session-sidebar {
|
||||
@apply flex flex-col min-h-0;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-header {
|
||||
@apply flex flex-col gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-sidebar-shortcuts {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.session-sidebar-new {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-controls {
|
||||
@apply flex flex-col gap-3;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-controls > * {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-controls .selector-trigger,
|
||||
.session-sidebar-controls [data-model-selector-control],
|
||||
.session-sidebar-controls .selector-trigger-label,
|
||||
session-sidebar-controls .selector-trigger-primary {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.sidebar-selector {
|
||||
@apply flex flex-col gap-1 w-full;
|
||||
}
|
||||
|
||||
.sidebar-selector-hint {
|
||||
@apply flex justify-center text-xs w-full;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-header-hints {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.session-sidebar-separator {
|
||||
background-color: var(--border-base);
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.session-resize-handle {
|
||||
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.session-resize-handle:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.session-resize-handle::before {
|
||||
content: "";
|
||||
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
@apply border-b relative;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-list-header h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.session-list {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.session-list-item {
|
||||
@apply border-b last:border-b-0;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-item-base {
|
||||
@apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.session-item-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-item-row {
|
||||
@apply flex items-center gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-item-header {
|
||||
@apply justify-between;
|
||||
}
|
||||
|
||||
.session-item-title-row {
|
||||
@apply flex items-center gap-2 min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
@apply justify-between items-center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.session-item-active .session-item-meta {
|
||||
color: var(--text-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-item-actions {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.session-item-active {
|
||||
background-color: var(--list-item-highlight-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.session-item-inactive {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-item-inactive:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-active .session-item-close:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
@apply flex-1 min-w-0;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.session-item-close {
|
||||
@apply flex-shrink-0 p-0.5 rounded transition-all;
|
||||
}
|
||||
|
||||
.session-item-close:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: inherit;
|
||||
}
|
||||
|
||||
.session-list-footer {
|
||||
@apply border-t;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-new-button {
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.session-new-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.session-new-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@apply flex items-center gap-1.5 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator .status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-indicator.connected .status-dot {
|
||||
background-color: var(--status-success);
|
||||
}
|
||||
|
||||
.status-indicator.connecting .status-dot {
|
||||
background-color: var(--status-warning);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected .status-dot {
|
||||
background-color: var(--status-error);
|
||||
}
|
||||
|
||||
.status-indicator.session-status {
|
||||
--session-status-dot: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working,
|
||||
.status-indicator.session-status.session-compacting,
|
||||
.status-indicator.session-status.session-idle {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working {
|
||||
color: var(--session-status-working-fg);
|
||||
--session-status-dot: var(--session-status-working-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting {
|
||||
color: var(--session-status-compacting-fg);
|
||||
--session-status-dot: var(--session-status-compacting-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-idle {
|
||||
color: var(--session-status-idle-fg);
|
||||
--session-status-dot: var(--session-status-idle-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-permission {
|
||||
color: var(--session-status-permission-fg);
|
||||
--session-status-dot: var(--session-status-permission-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status .status-dot {
|
||||
background-color: var(--session-status-dot);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working .status-dot,
|
||||
.status-indicator.session-status.session-compacting .status-dot {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working.session-status-list {
|
||||
background-color: var(--session-status-working-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting.session-status-list {
|
||||
background-color: var(--session-status-compacting-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-idle.session-status-list {
|
||||
background-color: var(--session-status-idle-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-permission.session-status-list {
|
||||
background-color: var(--session-status-permission-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status-list {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.session-list-container {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.session-item-base {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
}
|
||||
110
packages/ui/src/styles/panels/tabs.css
Normal file
110
packages/ui/src/styles/panels/tabs.css
Normal file
@@ -0,0 +1,110 @@
|
||||
/* Primary tab strip */
|
||||
.tab-bar {
|
||||
@apply border-b;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.tab-bar-instance {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.tab-bar-session {
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
@apply flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto;
|
||||
}
|
||||
|
||||
.tab-base {
|
||||
@apply inline-flex items-center gap-2 px-3 py-2 rounded-t-md max-w-[200px] transition-colors text-sm font-medium;
|
||||
font-family: var(--font-family-sans);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tab-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
background-color: var(--tab-active-bg);
|
||||
color: var(--tab-active-text);
|
||||
}
|
||||
|
||||
.tab-inactive {
|
||||
background-color: var(--tab-inactive-bg);
|
||||
color: var(--tab-inactive-text);
|
||||
}
|
||||
|
||||
.tab-inactive:hover {
|
||||
background-color: var(--tab-inactive-hover-bg);
|
||||
}
|
||||
|
||||
.tab-active:hover {
|
||||
background-color: var(--tab-active-hover-bg);
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
@apply opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-all cursor-pointer;
|
||||
}
|
||||
|
||||
.tab-close:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: inherit;
|
||||
}
|
||||
|
||||
.new-tab-button {
|
||||
@apply inline-flex items-center justify-center w-8 h-8 rounded-md transition-colors;
|
||||
background-color: var(--new-tab-bg);
|
||||
color: var(--new-tab-text);
|
||||
}
|
||||
|
||||
.new-tab-button:hover {
|
||||
background-color: var(--new-tab-hover-bg);
|
||||
}
|
||||
|
||||
.new-tab-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
/* Session tabs */
|
||||
.session-tab-base {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm;
|
||||
font-family: var(--font-family-sans);
|
||||
outline: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.session-tab-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.session-tab-active {
|
||||
background-color: var(--session-tab-active-bg);
|
||||
border-bottom-color: var(--accent-primary);
|
||||
color: var(--session-tab-active-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.session-tab-inactive {
|
||||
color: var(--session-tab-inactive-text);
|
||||
}
|
||||
|
||||
.session-tab-inactive:hover {
|
||||
background-color: var(--session-tab-hover-bg);
|
||||
}
|
||||
|
||||
.session-tab-special {
|
||||
color: var(--session-tab-inactive-text);
|
||||
}
|
||||
431
packages/ui/src/styles/tokens.css
Normal file
431
packages/ui/src/styles/tokens.css
Normal file
@@ -0,0 +1,431 @@
|
||||
:root {
|
||||
/* Surface tokens */
|
||||
--surface-base: #ffffff;
|
||||
--surface-secondary: #f5f5f5;
|
||||
--surface-muted: #f8f9fa;
|
||||
--surface-code: #f8f8f8;
|
||||
--surface-hover: #e0e0e0;
|
||||
|
||||
/* Border tokens */
|
||||
--border-base: #e0e0e0;
|
||||
--border-secondary: #e0e0e0;
|
||||
--border-muted: #e0e0e0;
|
||||
|
||||
/* Text tokens */
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #666666;
|
||||
--text-muted: #666666;
|
||||
--text-inverted: #ffffff;
|
||||
|
||||
/* Accent tokens */
|
||||
--accent-primary: #0066ff;
|
||||
--accent-hover: #0052cc;
|
||||
|
||||
/* Status tokens */
|
||||
--status-success: #4caf50;
|
||||
--status-error: #f44336;
|
||||
--status-warning: #ff9800;
|
||||
|
||||
/* Message-specific tokens */
|
||||
--message-user-bg: var(--surface-secondary);
|
||||
--message-user-border: #2196f3;
|
||||
--message-assistant-bg: var(--message-tool-bg);
|
||||
--message-assistant-border: #f59e0b;
|
||||
|
||||
--message-tool-bg: #f8f9fa;
|
||||
--message-tool-border: #6c757d;
|
||||
|
||||
/* Semantic component colors */
|
||||
--session-status-working-fg: #b45309;
|
||||
--session-status-working-bg: rgba(245, 158, 11, 0.16);
|
||||
--session-status-compacting-fg: #6d28d9;
|
||||
--session-status-compacting-bg: rgba(109, 40, 217, 0.18);
|
||||
--session-status-idle-fg: #15803d;
|
||||
--session-status-idle-bg: rgba(22, 163, 74, 0.16);
|
||||
--session-status-permission-fg: #c2410c;
|
||||
--session-status-permission-bg: rgba(251, 191, 36, 0.25);
|
||||
--list-item-highlight-bg: rgba(0, 102, 255, 0.1);
|
||||
--list-item-highlight-border: rgba(0, 102, 255, 0.25);
|
||||
--attachment-chip-bg: rgba(0, 102, 255, 0.1);
|
||||
--attachment-chip-text: #0066ff;
|
||||
--attachment-chip-ring: rgba(0, 102, 255, 0.1);
|
||||
--badge-neutral-bg: rgba(0, 102, 255, 0.05);
|
||||
--badge-neutral-text: #0066ff;
|
||||
--status-ready-fg: #16a34a;
|
||||
--status-ready-bg: rgba(34, 197, 94, 0.1);
|
||||
--status-starting-fg: #ca8a04;
|
||||
--status-starting-bg: rgba(250, 204, 21, 0.1);
|
||||
--status-error-fg: #dc2626;
|
||||
--status-error-bg: rgba(239, 68, 68, 0.1);
|
||||
--status-stopped-fg: #6b7280;
|
||||
--status-stopped-bg: rgba(107, 114, 128, 0.1);
|
||||
--env-vars-bg: rgba(0, 102, 255, 0.1);
|
||||
--env-vars-border: rgba(0, 102, 255, 0.2);
|
||||
--env-vars-text: #0066ff;
|
||||
--folder-overlay-bg: rgba(0, 0, 0, 0.35);
|
||||
--folder-card-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
--folder-card-radius: 16px;
|
||||
--dropdown-highlight-bg: rgba(0, 102, 255, 0.1);
|
||||
--dropdown-highlight-text: var(--text-inverted);
|
||||
--selection-highlight-bg: rgba(0, 102, 255, 0.12);
|
||||
--selection-highlight-strong-bg: rgba(0, 102, 255, 0.18);
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.5);
|
||||
--scroll-elevation-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||
--message-error-bg: rgba(244, 67, 54, 0.1);
|
||||
--message-error-bg-strong: rgba(244, 67, 54, 0.15);
|
||||
--danger-soft-bg: rgba(239, 68, 68, 0.1);
|
||||
--danger-soft-bg-strong: rgba(244, 67, 54, 0.15);
|
||||
--log-level-error: var(--status-error);
|
||||
--log-level-warn: var(--status-warning);
|
||||
--log-level-debug: var(--text-muted);
|
||||
--log-level-default: var(--text-primary);
|
||||
--focus-ring-color: var(--accent-primary);
|
||||
--focus-ring-offset: var(--surface-base);
|
||||
--kbd-bg: var(--surface-secondary);
|
||||
--kbd-border: var(--border-base);
|
||||
--kbd-text: var(--text-primary);
|
||||
--button-primary-bg: var(--accent-primary);
|
||||
--button-primary-hover-bg: var(--accent-hover);
|
||||
--button-primary-text: var(--text-inverted);
|
||||
--tab-active-bg: var(--accent-primary);
|
||||
--tab-active-hover-bg: var(--accent-hover);
|
||||
--tab-active-text: var(--text-inverted);
|
||||
--tab-inactive-bg: var(--surface-muted);
|
||||
--tab-inactive-hover-bg: var(--surface-hover);
|
||||
--tab-inactive-text: var(--text-secondary);
|
||||
--new-tab-bg: var(--surface-secondary);
|
||||
--new-tab-hover-bg: var(--surface-hover);
|
||||
--new-tab-text: var(--text-muted);
|
||||
--session-tab-active-bg: var(--surface-base);
|
||||
--session-tab-active-text: var(--text-primary);
|
||||
--session-tab-inactive-text: var(--text-muted);
|
||||
--session-tab-hover-bg: var(--surface-hover);
|
||||
|
||||
/* Layout & spacing tokens */
|
||||
--space-2xs: 2px;
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 24px;
|
||||
--radius-xs: 3px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
--button-padding-y: 0.75rem;
|
||||
--button-padding-x: 1.25rem;
|
||||
--button-radius: 0.5rem;
|
||||
--chip-radius: 0.375rem;
|
||||
--pill-radius: 9999px;
|
||||
|
||||
/* Typography tokens */
|
||||
--font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
|
||||
/* Font weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Font sizes */
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-lg: 16px;
|
||||
--font-size-xl: 18px;
|
||||
--font-size-2xl: 20px;
|
||||
|
||||
/* Line heights */
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.6;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* Surface tokens */
|
||||
--surface-base: #1a1a1a;
|
||||
--surface-secondary: #2a2a2a;
|
||||
--surface-muted: #212529;
|
||||
--surface-code: #1a1a1a;
|
||||
--surface-hover: #3a3a3a;
|
||||
|
||||
/* Border tokens */
|
||||
--border-base: #3a3a3a;
|
||||
--border-secondary: #3a3a3a;
|
||||
--border-muted: #3a3a3a;
|
||||
|
||||
/* Text tokens */
|
||||
--text-primary: #cfd4dc;
|
||||
--text-secondary: #999999;
|
||||
--text-muted: #999999;
|
||||
--text-inverted: #1a1a1a;
|
||||
|
||||
/* Accent tokens */
|
||||
--accent-primary: #0080ff;
|
||||
--accent-hover: #0066cc;
|
||||
|
||||
/* Status tokens */
|
||||
--status-success: #4caf50;
|
||||
--status-error: #f44336;
|
||||
--status-warning: #ff9800;
|
||||
|
||||
/* Message-specific tokens */
|
||||
--message-user-bg: #202734;
|
||||
--message-user-border: #42a5f5;
|
||||
--message-assistant-bg: var(--message-tool-bg);
|
||||
--message-assistant-border: #d97706;
|
||||
|
||||
--message-tool-bg: #212529;
|
||||
--message-tool-border: #adb5bd;
|
||||
|
||||
/* Semantic component colors */
|
||||
--session-status-working-fg: #facc15;
|
||||
--session-status-working-bg: rgba(250, 204, 21, 0.25);
|
||||
--session-status-compacting-fg: #c084fc;
|
||||
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
|
||||
--session-status-idle-fg: #4ade80;
|
||||
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
||||
--session-status-permission-fg: #fbbf24;
|
||||
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
||||
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
||||
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
||||
--attachment-chip-text: #0080ff;
|
||||
--attachment-chip-ring: rgba(0, 128, 255, 0.2);
|
||||
--badge-neutral-bg: rgba(0, 128, 255, 0.15);
|
||||
--badge-neutral-text: #0080ff;
|
||||
--status-ready-fg: #22c55e;
|
||||
--status-ready-bg: rgba(34, 197, 94, 0.2);
|
||||
--status-starting-fg: #facc15;
|
||||
--status-starting-bg: rgba(250, 204, 21, 0.2);
|
||||
--status-error-fg: #ef4444;
|
||||
--status-error-bg: rgba(239, 68, 68, 0.2);
|
||||
--status-stopped-fg: #9ca3af;
|
||||
--status-stopped-bg: rgba(107, 114, 128, 0.2);
|
||||
--env-vars-bg: rgba(0, 128, 255, 0.2);
|
||||
--env-vars-border: rgba(0, 128, 255, 0.3);
|
||||
--env-vars-text: #0080ff;
|
||||
--folder-overlay-bg: rgba(0, 0, 0, 0.45);
|
||||
--folder-card-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
--folder-card-radius: 16px;
|
||||
--dropdown-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||
--dropdown-highlight-text: var(--text-primary);
|
||||
--kbd-bg: var(--surface-secondary);
|
||||
--kbd-border: var(--border-base);
|
||||
--kbd-text: var(--text-primary);
|
||||
--button-primary-bg: #3f3f46;
|
||||
--button-primary-hover-bg: #52525b;
|
||||
--button-primary-text: #f5f6f8;
|
||||
--tab-active-bg: #3f3f46;
|
||||
--tab-active-hover-bg: #52525b;
|
||||
--tab-active-text: #f5f6f8;
|
||||
--tab-inactive-bg: #2a2a31;
|
||||
--tab-inactive-hover-bg: #3f3f46;
|
||||
--tab-inactive-text: #d4d4d8;
|
||||
--new-tab-bg: #3f3f46;
|
||||
--new-tab-hover-bg: #52525b;
|
||||
--new-tab-text: #f5f6f8;
|
||||
--session-tab-active-bg: var(--surface-muted);
|
||||
--session-tab-active-text: var(--text-primary);
|
||||
--session-tab-inactive-text: var(--text-muted);
|
||||
--session-tab-hover-bg: #3f3f46;
|
||||
|
||||
--button-primary-bg: #3f3f46;
|
||||
--button-primary-hover-bg: #52525b;
|
||||
--button-primary-text: #f5f6f8;
|
||||
--tab-active-bg: #3f3f46;
|
||||
--tab-active-hover-bg: #52525b;
|
||||
--tab-active-text: #f5f6f8;
|
||||
--tab-inactive-bg: #2f2f36;
|
||||
--tab-inactive-hover-bg: #3d3d45;
|
||||
--tab-inactive-text: #d4d4d8;
|
||||
--new-tab-bg: #3f3f46;
|
||||
--new-tab-hover-bg: #52525b;
|
||||
--new-tab-text: #f5f6f8;
|
||||
--session-tab-active-bg: var(--surface-muted);
|
||||
--session-tab-active-text: var(--text-primary);
|
||||
--session-tab-inactive-text: var(--text-muted);
|
||||
--session-tab-hover-bg: #3f3f46;
|
||||
|
||||
/* Layout & spacing tokens */
|
||||
--space-2xs: 2px;
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 24px;
|
||||
--radius-xs: 3px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
--button-padding-y: 0.75rem;
|
||||
--button-padding-x: 1.25rem;
|
||||
--button-radius: 0.5rem;
|
||||
--chip-radius: 0.375rem;
|
||||
--pill-radius: 9999px;
|
||||
|
||||
/* Typography tokens (same as light theme) */
|
||||
--font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
|
||||
/* Font weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Font sizes */
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-lg: 16px;
|
||||
--font-size-xl: 18px;
|
||||
--font-size-2xl: 20px;
|
||||
|
||||
/* Line heights */
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.6;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Surface tokens */
|
||||
--surface-base: #1a1a1a;
|
||||
--surface-secondary: #2a2a2a;
|
||||
--surface-muted: #212529;
|
||||
--surface-code: #1a1a1a;
|
||||
--surface-hover: #3a3a3a;
|
||||
|
||||
/* Border tokens */
|
||||
--border-base: #3a3a3a;
|
||||
--border-secondary: #3a3a3a;
|
||||
--border-muted: #3a3a3a;
|
||||
|
||||
/* Text tokens */
|
||||
--text-primary: #cfd4dc;
|
||||
--text-secondary: #999999;
|
||||
--text-muted: #999999;
|
||||
--text-inverted: #1a1a1a;
|
||||
|
||||
/* Accent tokens */
|
||||
--accent-primary: #0080ff;
|
||||
--accent-hover: #0066cc;
|
||||
|
||||
/* Status tokens */
|
||||
--status-success: #4caf50;
|
||||
--status-error: #f44336;
|
||||
--status-warning: #ff9800;
|
||||
|
||||
/* Message-specific tokens */
|
||||
--message-user-bg: #202734;
|
||||
--message-user-border: #2196f3;
|
||||
--message-assistant-bg: var(--message-tool-bg);
|
||||
--message-assistant-border: #d97706;
|
||||
|
||||
--message-tool-bg: #212529;
|
||||
--message-tool-border: #adb5bd;
|
||||
|
||||
/* Semantic component colors */
|
||||
--session-status-working-fg: #facc15;
|
||||
--session-status-working-bg: rgba(250, 204, 21, 0.25);
|
||||
--session-status-compacting-fg: #c084fc;
|
||||
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
|
||||
--session-status-idle-fg: #4ade80;
|
||||
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
||||
--session-status-permission-fg: #fbbf24;
|
||||
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
||||
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
||||
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
||||
--attachment-chip-text: #0080ff;
|
||||
--attachment-chip-ring: rgba(0, 128, 255, 0.2);
|
||||
--badge-neutral-bg: rgba(0, 128, 255, 0.15);
|
||||
--badge-neutral-text: #0080ff;
|
||||
--status-ready-fg: #22c55e;
|
||||
--status-ready-bg: rgba(34, 197, 94, 0.2);
|
||||
--status-starting-fg: #facc15;
|
||||
--status-starting-bg: rgba(250, 204, 21, 0.2);
|
||||
--status-error-fg: #ef4444;
|
||||
--status-error-bg: rgba(239, 68, 68, 0.2);
|
||||
--status-stopped-fg: #9ca3af;
|
||||
--status-stopped-bg: rgba(107, 114, 128, 0.2);
|
||||
--env-vars-bg: rgba(0, 128, 255, 0.2);
|
||||
--env-vars-border: rgba(0, 128, 255, 0.3);
|
||||
--env-vars-text: #0080ff;
|
||||
--folder-overlay-bg: rgba(0, 0, 0, 0.45);
|
||||
--folder-card-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
--folder-card-radius: 16px;
|
||||
--dropdown-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||
--dropdown-highlight-text: var(--text-primary);
|
||||
--selection-highlight-bg: rgba(0, 128, 255, 0.18);
|
||||
--selection-highlight-strong-bg: rgba(0, 128, 255, 0.28);
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.6);
|
||||
--scroll-elevation-shadow: 0 10px 25px rgba(0, 0, 0, 0.35);
|
||||
--message-error-bg: rgba(244, 67, 54, 0.12);
|
||||
--message-error-bg-strong: rgba(244, 67, 54, 0.2);
|
||||
--danger-soft-bg: rgba(244, 67, 54, 0.16);
|
||||
--danger-soft-bg-strong: rgba(244, 67, 54, 0.28);
|
||||
--log-level-error: var(--status-error);
|
||||
--log-level-warn: var(--status-warning);
|
||||
--log-level-debug: var(--text-secondary);
|
||||
--log-level-default: var(--text-primary);
|
||||
--focus-ring-color: var(--accent-primary);
|
||||
--focus-ring-offset: var(--surface-base);
|
||||
--kbd-bg: var(--surface-secondary);
|
||||
--kbd-border: var(--border-base);
|
||||
--kbd-text: var(--text-primary);
|
||||
|
||||
/* Layout & spacing tokens */
|
||||
--space-2xs: 2px;
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 24px;
|
||||
--radius-xs: 3px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
--button-padding-y: 0.75rem;
|
||||
--button-padding-x: 1.25rem;
|
||||
--button-radius: 0.5rem;
|
||||
--chip-radius: 0.375rem;
|
||||
--pill-radius: 9999px;
|
||||
|
||||
/* Typography tokens (same as light theme) */
|
||||
--font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
|
||||
/* Font weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Font sizes */
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-lg: 16px;
|
||||
--font-size-xl: 18px;
|
||||
--font-size-2xl: 20px;
|
||||
|
||||
/* Line heights */
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.6;
|
||||
|
||||
}
|
||||
135
packages/ui/src/styles/utilities.css
Normal file
135
packages/ui/src/styles/utilities.css
Normal file
@@ -0,0 +1,135 @@
|
||||
@import "./tokens.css";
|
||||
|
||||
/* Reusable component utilities using tokens */
|
||||
|
||||
/* Base token utility helpers */
|
||||
.text-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-inverted {
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.bg-surface-base {
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.bg-surface-secondary {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.bg-surface-muted {
|
||||
background-color: var(--surface-muted);
|
||||
}
|
||||
|
||||
.border-base {
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.icon-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.icon-accent {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.icon-danger-hover:hover {
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.icon-accent-hover:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.ring-accent-inset {
|
||||
box-shadow: inset 0 0 0 2px var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Shared button + chip helpers */
|
||||
:is(.button-primary,
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-tertiary,
|
||||
button.button-tertiary) {
|
||||
@apply inline-flex items-center justify-center gap-2 font-medium transition-colors rounded-md;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
:is(.button-primary,
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-tertiary,
|
||||
button.button-tertiary):focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
:is(.button-primary,
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-tertiary,
|
||||
button.button-tertiary):disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
:is(.attachment-chip,
|
||||
.neutral-badge,
|
||||
.status-badge) {
|
||||
@apply inline-flex items-center gap-1;
|
||||
border-radius: var(--chip-radius);
|
||||
}
|
||||
|
||||
/* Focus helpers */
|
||||
.focus-ring-accent:focus {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 2px var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Shared animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Shared layout helpers */
|
||||
.status-dot {
|
||||
@apply w-1 h-1 rounded-full;
|
||||
}
|
||||
|
||||
.kbd {
|
||||
@apply inline-flex items-center gap-0.5 font-mono text-xs px-1.5 py-0.5 rounded;
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.kbd-separator {
|
||||
@apply opacity-50;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user