Add keyboard shortcuts system with reusable hint components
- Implement centralized keyboard registry with 20+ shortcuts - Add instance navigation (Cmd+[1-9], Cmd+[/]) - Add session navigation (Cmd+Shift+[1-9], Cmd+Shift+[/]) - Add agent/model cycling (Tab, Cmd+Shift+M) - Add input shortcuts (Cmd+P focus, Cmd+K clear, ↑↓ history) - Add command palette (Cmd+Shift+P) with 8 MVP commands - Implement message history per folder in IndexedDB (max 100) - Create reusable Kbd and HintRow components - Replace all keyboard hint rendering with consistent components - Use text-based shortcuts (Cmd+Shift+M) for clarity
This commit is contained in:
259
src/App.tsx
259
src/App.tsx
@@ -1,13 +1,16 @@
|
||||
import { Component, onMount, Show, createMemo, createEffect } from "solid-js"
|
||||
import { Component, onMount, onCleanup, Show, createMemo, createEffect } from "solid-js"
|
||||
import type { Session } from "./types/session"
|
||||
import EmptyState from "./components/empty-state"
|
||||
import SessionPicker from "./components/session-picker"
|
||||
import CommandPalette from "./components/command-palette"
|
||||
import InstanceTabs from "./components/instance-tabs"
|
||||
import SessionTabs from "./components/session-tabs"
|
||||
import MessageStream from "./components/message-stream"
|
||||
import PromptInput from "./components/prompt-input"
|
||||
import LogsView from "./components/logs-view"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { createCommandRegistry } from "./lib/commands"
|
||||
import type { Command } from "./lib/commands"
|
||||
import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
@@ -42,13 +45,21 @@ import {
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
agents,
|
||||
} from "./stores/sessions"
|
||||
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
|
||||
import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette"
|
||||
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
|
||||
import { registerInputShortcuts } from "./lib/shortcuts/input"
|
||||
import { registerAgentShortcuts } from "./lib/shortcuts/agent"
|
||||
import { registerEscapeShortcut } from "./lib/shortcuts/escape"
|
||||
import { keyboardRegistry } from "./lib/keyboard-registry"
|
||||
|
||||
const SessionView: Component<{
|
||||
sessionId: string
|
||||
activeSessions: Map<string, Session>
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
}> = (props) => {
|
||||
const session = () => props.activeSessions.get(props.sessionId)
|
||||
|
||||
@@ -90,6 +101,7 @@ const SessionView: Component<{
|
||||
/>
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={s().id}
|
||||
onSend={handleSendMessage}
|
||||
agent={s().agent}
|
||||
@@ -104,6 +116,8 @@ const SessionView: Component<{
|
||||
}
|
||||
|
||||
const App: Component = () => {
|
||||
const commandRegistry = createCommandRegistry()
|
||||
|
||||
const activeInstance = createMemo(() => getActiveInstance())
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
@@ -175,10 +189,243 @@ const App: Component = () => {
|
||||
showSessionPicker(instanceId)
|
||||
}
|
||||
|
||||
function setupCommands() {
|
||||
commandRegistry.register({
|
||||
id: "init",
|
||||
label: "Initialize AGENTS.md",
|
||||
description: "Create or update AGENTS.md file",
|
||||
keywords: ["/init", "agents", "initialize"],
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!instance || !instance.client || !sessionId || sessionId === "logs") return
|
||||
|
||||
try {
|
||||
await instance.client.session.init({ path: { id: sessionId } })
|
||||
console.log("Initialized AGENTS.md")
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize AGENTS.md:", error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "compact",
|
||||
label: "Compact Session",
|
||||
description: "Summarize and compact the current session",
|
||||
keywords: ["/compact", "summarize", "compress"],
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!instance || !instance.client || !sessionId || sessionId === "logs") return
|
||||
|
||||
try {
|
||||
await instance.client.session.summarize({ path: { id: sessionId } })
|
||||
console.log("Session compacted")
|
||||
} catch (error) {
|
||||
console.error("Failed to compact session:", error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "undo",
|
||||
label: "Undo Last Message",
|
||||
description: "Revert the last message",
|
||||
keywords: ["/undo", "revert", "undo"],
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!instance || !instance.client || !sessionId || sessionId === "logs") return
|
||||
|
||||
try {
|
||||
await instance.client.session.revert({ path: { id: sessionId } })
|
||||
console.log("Last message reverted")
|
||||
} catch (error) {
|
||||
console.error("Failed to revert message:", error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "thinking",
|
||||
label: "Toggle Thinking Blocks",
|
||||
description: "Show/hide AI thinking process",
|
||||
keywords: ["/thinking", "toggle", "show", "hide"],
|
||||
action: () => {
|
||||
console.log("Toggle thinking blocks (not implemented)")
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "help",
|
||||
label: "Show Help",
|
||||
description: "Display keyboard shortcuts and help",
|
||||
keywords: ["/help", "shortcuts", "help"],
|
||||
action: () => {
|
||||
console.log("Show help modal (not implemented)")
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "new-session",
|
||||
label: "New Session",
|
||||
description: "Create a new session",
|
||||
shortcut: { key: "N", meta: true, shift: true },
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
await handleNewSession(instance.id)
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-model-selector",
|
||||
label: "Open Model Selector",
|
||||
description: "Choose a different model",
|
||||
shortcut: { key: "M", meta: true, shift: true },
|
||||
action: () => {
|
||||
const modelInput = document.querySelector("[data-model-selector] input") as HTMLInputElement
|
||||
modelInput?.focus()
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "focus-prompt",
|
||||
label: "Focus Prompt Input",
|
||||
description: "Jump to the message input",
|
||||
shortcut: { key: "P", meta: true },
|
||||
action: () => {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
textarea?.focus()
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-agent-selector",
|
||||
label: "Open Agent Selector",
|
||||
description: "Choose a different agent",
|
||||
action: () => {
|
||||
const agentButton = document.querySelector("[data-agent-selector]") as HTMLElement
|
||||
agentButton?.click()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleExecuteCommand(commandId: string) {
|
||||
commandRegistry.execute(commandId)
|
||||
}
|
||||
|
||||
function handleCycleAgent() {
|
||||
const instance = activeInstance()
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!instance || !sessionId || sessionId === "logs") return
|
||||
|
||||
const sessions = getSessions(instance.id)
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return
|
||||
|
||||
const instanceAgents = agents().get(instance.id) || []
|
||||
const availableAgents =
|
||||
session.parentId === null ? instanceAgents.filter((a) => a.mode !== "subagent") : instanceAgents
|
||||
|
||||
if (availableAgents.length === 0) return
|
||||
|
||||
const currentIndex = availableAgents.findIndex((a) => a.name === session.agent)
|
||||
const nextIndex = (currentIndex + 1) % availableAgents.length
|
||||
const nextAgent = availableAgents[nextIndex]
|
||||
|
||||
if (nextAgent) {
|
||||
updateSessionAgent(instance.id, sessionId, nextAgent.name).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCycleAgentReverse() {
|
||||
const instance = activeInstance()
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!instance || !sessionId || sessionId === "logs") return
|
||||
|
||||
const sessions = getSessions(instance.id)
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return
|
||||
|
||||
const instanceAgents = agents().get(instance.id) || []
|
||||
const availableAgents =
|
||||
session.parentId === null ? instanceAgents.filter((a) => a.mode !== "subagent") : instanceAgents
|
||||
|
||||
if (availableAgents.length === 0) return
|
||||
|
||||
const currentIndex = availableAgents.findIndex((a) => a.name === session.agent)
|
||||
const prevIndex = currentIndex <= 0 ? availableAgents.length - 1 : currentIndex - 1
|
||||
const prevAgent = availableAgents[prevIndex]
|
||||
|
||||
if (prevAgent) {
|
||||
updateSessionAgent(instance.id, sessionId, prevAgent.name).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initMarkdown(false).catch(console.error)
|
||||
|
||||
setupTabKeyboardShortcuts(handleSelectFolder, handleNewSession, handleCloseSession)
|
||||
setupCommands()
|
||||
|
||||
setupTabKeyboardShortcuts(
|
||||
handleSelectFolder,
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
showCommandPalette,
|
||||
)
|
||||
|
||||
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(handleCycleAgent, handleCycleAgentReverse, () => {
|
||||
const modelInput = document.querySelector("[data-model-selector] input") as HTMLInputElement
|
||||
modelInput?.focus()
|
||||
})
|
||||
registerEscapeShortcut(
|
||||
() => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return false
|
||||
const sessions = getSessions(instance.id)
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return false
|
||||
const lastMessage = session.messages[session.messages.length - 1]
|
||||
return lastMessage?.status === "streaming"
|
||||
},
|
||||
() => {
|
||||
console.log("Interrupt session (not implemented)")
|
||||
},
|
||||
() => {
|
||||
const active = document.activeElement as HTMLElement
|
||||
active?.blur()
|
||||
},
|
||||
hideCommandPalette,
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const shortcut = keyboardRegistry.findMatch(e)
|
||||
if (shortcut) {
|
||||
e.preventDefault()
|
||||
shortcut.handler()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
window.electronAPI.onNewInstance(() => {
|
||||
handleSelectFolder()
|
||||
@@ -260,6 +507,7 @@ const App: Component = () => {
|
||||
sessionId={activeSessionIdForInstance()!}
|
||||
activeSessions={activeSessions()}
|
||||
instanceId={activeInstance()!.id}
|
||||
instanceFolder={activeInstance()!.folder}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
@@ -280,6 +528,13 @@ const App: Component = () => {
|
||||
<Show when={sessionPickerInstance()}>
|
||||
{(instanceId) => <SessionPicker instanceId={instanceId()} open={true} onClose={hideSessionPicker} />}
|
||||
</Show>
|
||||
|
||||
<CommandPalette
|
||||
open={isCommandPaletteOpen()}
|
||||
onClose={hideCommandPalette}
|
||||
commands={commandRegistry.getAll()}
|
||||
onExecute={handleExecuteCommand}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -52,50 +53,60 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<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="px-3 py-2 cursor-pointer hover:bg-gray-100 rounded outline-none focus:bg-gray-100"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Select.ItemLabel class="font-medium text-sm text-gray-900 flex items-center gap-2">
|
||||
<span>{itemProps.item.rawValue.name}</span>
|
||||
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
||||
<span class="text-xs font-normal text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">subagent</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<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="px-3 py-2 cursor-pointer hover:bg-gray-100 rounded outline-none focus:bg-gray-100"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Select.ItemLabel class="font-medium text-sm text-gray-900 flex items-center gap-2">
|
||||
<span>{itemProps.item.rawValue.name}</span>
|
||||
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
||||
<span class="text-xs font-normal text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">subagent</span>
|
||||
</Show>
|
||||
</Select.ItemLabel>
|
||||
<Show when={itemProps.item.rawValue.description}>
|
||||
<Select.ItemDescription class="text-xs text-gray-600">
|
||||
{itemProps.item.rawValue.description.length > 50
|
||||
? itemProps.item.rawValue.description.slice(0, 50) + "..."
|
||||
: itemProps.item.rawValue.description}
|
||||
</Select.ItemDescription>
|
||||
</Show>
|
||||
</Select.ItemLabel>
|
||||
<Show when={itemProps.item.rawValue.description}>
|
||||
<Select.ItemDescription class="text-xs text-gray-600">
|
||||
{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 class="inline-flex items-center justify-between gap-2 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus:ring-2 focus:ring-blue-500 text-xs min-w-[100px]">
|
||||
<Select.Value<Agent>>
|
||||
{(state) => <span class="text-gray-700">Agent: {state.selectedOption()?.name ?? "None"}</span>}
|
||||
</Select.Value>
|
||||
<Select.Icon>
|
||||
<ChevronDown class="w-3 h-3 text-gray-500" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
</div>
|
||||
</Select.Item>
|
||||
)}
|
||||
>
|
||||
<Select.Trigger
|
||||
data-agent-selector
|
||||
class="inline-flex items-center justify-between gap-2 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus:ring-2 focus:ring-blue-500 text-xs min-w-[100px]"
|
||||
>
|
||||
<Select.Value<Agent>>
|
||||
{(state) => <span class="text-gray-700">Agent: {state.selectedOption()?.name ?? "None"}</span>}
|
||||
</Select.Value>
|
||||
<Select.Icon>
|
||||
<ChevronDown class="w-3 h-3 text-gray-500" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-auto p-1 z-50">
|
||||
<Select.Listbox />
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
<Select.Portal>
|
||||
<Select.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-auto p-1 z-50">
|
||||
<Select.Listbox />
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
<Show when={availableAgents().length > 1}>
|
||||
<span class="text-xs text-gray-400">
|
||||
<Kbd>Tab</Kbd>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
157
src/components/command-palette.tsx
Normal file
157
src/components/command-palette.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Component, createSignal, For, Show, onMount, createEffect } 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: (commandId: string) => 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 [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
|
||||
const filteredCommands = () => {
|
||||
const q = query().toLowerCase()
|
||||
if (!q) return props.commands
|
||||
|
||||
return props.commands.filter((cmd) => {
|
||||
const labelMatch = cmd.label.toLowerCase().includes(q)
|
||||
const descMatch = cmd.description.toLowerCase().includes(q)
|
||||
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q))
|
||||
return labelMatch || descMatch || keywordMatch
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
setQuery("")
|
||||
setSelectedIndex(0)
|
||||
setTimeout(() => inputRef?.focus(), 100)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const max = Math.max(0, filteredCommands().length - 1)
|
||||
if (selectedIndex() > max) {
|
||||
setSelectedIndex(max)
|
||||
}
|
||||
})
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const filtered = filteredCommands()
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const selected = filtered[selectedIndex()]
|
||||
if (selected) {
|
||||
props.onExecute(selected.id)
|
||||
props.onClose()
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommandClick(commandId: string) {
|
||||
props.onExecute(commandId)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 bg-black/50 z-50" />
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
||||
<Dialog.Content
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-2xl max-h-[60vh] flex flex-col"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
|
||||
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-gray-400" 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)
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
placeholder="Type a command or search..."
|
||||
class="flex-1 bg-transparent outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<Show
|
||||
when={filteredCommands().length > 0}
|
||||
fallback={<div class="p-8 text-center text-gray-500">No commands found for "{query()}"</div>}
|
||||
>
|
||||
<For each={filteredCommands()}>
|
||||
{(command, index) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCommandClick(command.id)}
|
||||
class={`w-full px-4 py-3 flex items-start gap-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer border-none text-left ${
|
||||
index() === selectedIndex() ? "bg-blue-50 dark:bg-blue-900/20" : ""
|
||||
}`}
|
||||
onMouseEnter={() => setSelectedIndex(index())}
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{command.label}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">{command.description}</div>
|
||||
</div>
|
||||
<Show when={command.shortcut}>
|
||||
<div class="mt-1">
|
||||
<Kbd shortcut={buildShortcutString(command.shortcut)} />
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandPalette
|
||||
12
src/components/hint-row.tsx
Normal file
12
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-gray-500 dark:text-gray-400 ${props.class || ""}`}>{props.children}</span>
|
||||
}
|
||||
|
||||
export default HintRow
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Component, For } from "solid-js"
|
||||
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>
|
||||
@@ -14,25 +16,36 @@ interface InstanceTabsProps {
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
return (
|
||||
<div class="instance-tabs bg-gray-50 border-b border-gray-200">
|
||||
<div class="tabs-container flex items-center gap-1 px-2 py-1 overflow-x-auto" role="tablist">
|
||||
<For each={Array.from(props.instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === props.activeInstanceId}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={() => props.onClose(id)}
|
||||
<div class="tabs-container flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto" 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 inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-200 transition-colors"
|
||||
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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-200 transition-colors"
|
||||
onClick={props.onNew}
|
||||
title="New instance (Cmd/Ctrl+N)"
|
||||
aria-label="New instance"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
54
src/components/kbd.tsx
Normal file
54
src/components/kbd.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Component, JSX, For } from "solid-js"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
|
||||
interface KbdProps {
|
||||
children?: JSX.Element
|
||||
shortcut?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
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, i) => {
|
||||
const trimmed = token.trim()
|
||||
|
||||
if (trimmed === "cmd" || trimmed === "command") {
|
||||
result.push({ text: isMac() ? "Cmd" : "Ctrl", isModifier: false })
|
||||
} else if (trimmed === "shift") {
|
||||
result.push({ text: "Shift", isModifier: false })
|
||||
} else if (trimmed === "alt" || trimmed === "option") {
|
||||
result.push({ text: isMac() ? "Option" : "Alt", isModifier: false })
|
||||
} else if (trimmed === "ctrl") {
|
||||
result.push({ text: "Ctrl", isModifier: false })
|
||||
} else {
|
||||
result.push({ text: trimmed.toUpperCase(), isModifier: false })
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return (
|
||||
<kbd
|
||||
class={`font-mono bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs inline-flex items-center gap-0.5 ${props.class || ""}`}
|
||||
>
|
||||
<For each={parts()}>
|
||||
{(part, index) => (
|
||||
<>
|
||||
{index() > 0 && <span class="opacity-50">+</span>}
|
||||
<span>{part.text}</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
export default Kbd
|
||||
43
src/components/keyboard-hint.tsx
Normal file
43
src/components/keyboard-hint.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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
|
||||
}> = (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>}
|
||||
<span class="mr-1">{shortcut.description}</span>
|
||||
<Kbd shortcut={buildShortcutString(shortcut)} />
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</HintRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default KeyboardHint
|
||||
@@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Provider, Model } from "../types/session"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
instanceId: string
|
||||
@@ -53,56 +54,65 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
value={currentModelValue()}
|
||||
onChange={handleChange}
|
||||
options={allModels()}
|
||||
optionValue={(m) => `${m.providerId}/${m.id}`}
|
||||
optionTextValue={(m) => `${m.name} ${m.providerName} ${m.providerId}/${m.id}`}
|
||||
optionLabel="name"
|
||||
placeholder="Search models..."
|
||||
defaultFilter="contains"
|
||||
triggerMode="focus"
|
||||
allowsEmptyCollection={false}
|
||||
itemComponent={(itemProps) => (
|
||||
<Combobox.Item
|
||||
item={itemProps.item}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-gray-100 rounded outline-none focus:bg-gray-100 flex items-start gap-2"
|
||||
<div class="flex items-center gap-2">
|
||||
<Combobox
|
||||
value={currentModelValue()}
|
||||
onChange={handleChange}
|
||||
options={allModels()}
|
||||
optionValue={(m) => `${m.providerId}/${m.id}`}
|
||||
optionTextValue={(m) => `${m.name} ${m.providerName} ${m.providerId}/${m.id}`}
|
||||
optionLabel="name"
|
||||
placeholder="Search models..."
|
||||
defaultFilter="contains"
|
||||
triggerMode="focus"
|
||||
allowsEmptyCollection={false}
|
||||
itemComponent={(itemProps) => (
|
||||
<Combobox.Item
|
||||
item={itemProps.item}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-gray-100 rounded outline-none focus:bg-gray-100 flex items-start gap-2"
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<Combobox.ItemLabel class="font-medium text-sm text-gray-900">
|
||||
{itemProps.item.rawValue.name}
|
||||
</Combobox.ItemLabel>
|
||||
<Combobox.ItemDescription class="text-xs text-gray-600">
|
||||
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/
|
||||
{itemProps.item.rawValue.id}
|
||||
</Combobox.ItemDescription>
|
||||
</div>
|
||||
<Combobox.ItemIndicator class="flex-shrink-0 mt-0.5">
|
||||
<svg class="w-4 h-4 text-blue-600" 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
|
||||
data-model-selector
|
||||
class="inline-flex items-center justify-between gap-1 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus-within:ring-2 focus-within:ring-blue-500 text-xs min-w-[140px]"
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<Combobox.ItemLabel class="font-medium text-sm text-gray-900">
|
||||
{itemProps.item.rawValue.name}
|
||||
</Combobox.ItemLabel>
|
||||
<Combobox.ItemDescription class="text-xs text-gray-600">
|
||||
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id}
|
||||
</Combobox.ItemDescription>
|
||||
</div>
|
||||
<Combobox.ItemIndicator class="flex-shrink-0 mt-0.5">
|
||||
<svg class="w-4 h-4 text-blue-600" 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="inline-flex items-center justify-between gap-1 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus-within:ring-2 focus-within:ring-blue-500 text-xs min-w-[140px]">
|
||||
<Combobox.Input
|
||||
ref={inputRef}
|
||||
onFocus={handleFocus}
|
||||
class="bg-transparent border-none outline-none text-xs text-gray-700 placeholder:text-gray-500 w-full min-w-0 px-0"
|
||||
/>
|
||||
<Combobox.Trigger class="flex items-center justify-center">
|
||||
<Combobox.Icon>
|
||||
<ChevronDown class="w-3 h-3 text-gray-500" />
|
||||
</Combobox.Icon>
|
||||
</Combobox.Trigger>
|
||||
</Combobox.Control>
|
||||
<Combobox.Input
|
||||
ref={inputRef}
|
||||
onFocus={handleFocus}
|
||||
class="bg-transparent border-none outline-none text-xs text-gray-700 placeholder:text-gray-500 w-full min-w-0 px-0"
|
||||
/>
|
||||
<Combobox.Trigger class="flex items-center justify-center">
|
||||
<Combobox.Icon>
|
||||
<ChevronDown class="w-3 h-3 text-gray-500" />
|
||||
</Combobox.Icon>
|
||||
</Combobox.Trigger>
|
||||
</Combobox.Control>
|
||||
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-hidden p-1 z-50 min-w-[300px]">
|
||||
<Combobox.Listbox ref={listboxRef} scrollRef={() => listboxRef} class="max-h-80 overflow-auto" />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-hidden p-1 z-50 min-w-[300px]">
|
||||
<Combobox.Listbox ref={listboxRef} scrollRef={() => listboxRef} class="max-h-80 overflow-auto" />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
<span class="text-xs text-gray-400">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createSignal, Show, onMount, createEffect } from "solid-js"
|
||||
import AgentSelector from "./agent-selector"
|
||||
import ModelSelector from "./model-selector"
|
||||
import { addToHistory, getHistory } from "../stores/message-history"
|
||||
import Kbd from "./kbd"
|
||||
import HintRow from "./hint-row"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
|
||||
interface PromptInputProps {
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
sessionId: string
|
||||
onSend: (prompt: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
@@ -16,12 +21,56 @@ interface PromptInputProps {
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const [prompt, setPrompt] = createSignal("")
|
||||
const [sending, setSending] = createSignal(false)
|
||||
const [history, setHistory] = createSignal<string[]>([])
|
||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||
const [isFocused, setIsFocused] = createSignal(false)
|
||||
let textareaRef: HTMLTextAreaElement | undefined
|
||||
|
||||
onMount(async () => {
|
||||
const loaded = await getHistory(props.instanceFolder)
|
||||
setHistory(loaded)
|
||||
})
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = textareaRef
|
||||
if (!textarea) return
|
||||
|
||||
const atStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
const currentHistory = history()
|
||||
|
||||
if (e.key === "ArrowUp" && atStart && currentHistory.length > 0) {
|
||||
e.preventDefault()
|
||||
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, currentHistory.length - 1)
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(currentHistory[newIndex])
|
||||
setTimeout(() => {
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px"
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown" && historyIndex() >= 0) {
|
||||
e.preventDefault()
|
||||
const newIndex = historyIndex() - 1
|
||||
if (newIndex >= 0) {
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(currentHistory[newIndex])
|
||||
} else {
|
||||
setHistoryIndex(-1)
|
||||
setPrompt("")
|
||||
}
|
||||
setTimeout(() => {
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px"
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +80,12 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
await addToHistory(props.instanceFolder, text)
|
||||
|
||||
const updated = await getHistory(props.instanceFolder)
|
||||
setHistory(updated)
|
||||
setHistoryIndex(-1)
|
||||
|
||||
await props.onSend(text)
|
||||
setPrompt("")
|
||||
|
||||
@@ -49,6 +104,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
setPrompt(target.value)
|
||||
setHistoryIndex(-1)
|
||||
|
||||
target.style.height = "auto"
|
||||
target.style.height = Math.min(target.scrollHeight, 200) + "px"
|
||||
@@ -66,6 +122,8 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={sending() || props.disabled}
|
||||
rows={1}
|
||||
/>
|
||||
@@ -76,9 +134,10 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-input-hints">
|
||||
<span class="hint">
|
||||
<kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for new line
|
||||
</span>
|
||||
<HintRow>
|
||||
<Kbd>Enter</Kbd> to send • <Kbd>Shift+Enter</Kbd> for new line • <Kbd>↑↓</Kbd> for history •{" "}
|
||||
<Kbd shortcut="cmd+p" /> to focus
|
||||
</HintRow>
|
||||
<div class="flex items-center gap-2">
|
||||
<AgentSelector
|
||||
instanceId={props.instanceId}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Component, For } from "solid-js"
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import type { Session } from "../types/session"
|
||||
import SessionTab from "./session-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
|
||||
interface SessionTabsProps {
|
||||
instanceId: string
|
||||
@@ -14,30 +16,44 @@ interface SessionTabsProps {
|
||||
|
||||
const SessionTabs: Component<SessionTabsProps> = (props) => {
|
||||
const sessionsList = () => Array.from(props.sessions.entries())
|
||||
const totalTabs = () => sessionsList().length + 1
|
||||
|
||||
return (
|
||||
<div class="session-tabs bg-white border-b border-gray-200">
|
||||
<div class="tabs-container flex items-center gap-1 px-2 py-1 overflow-x-auto" role="tablist">
|
||||
<For each={sessionsList()}>
|
||||
{([id, session]) => (
|
||||
<SessionTab
|
||||
session={session}
|
||||
active={id === props.activeSessionId}
|
||||
isParent={session.parentId === null}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={session.parentId === null ? () => props.onClose(id) : undefined}
|
||||
<div class="tabs-container flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto" role="tablist">
|
||||
<div class="flex items-center gap-1 overflow-x-auto">
|
||||
<For each={sessionsList()}>
|
||||
{([id, session]) => (
|
||||
<SessionTab
|
||||
session={session}
|
||||
active={id === props.activeSessionId}
|
||||
isParent={session.parentId === null}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={session.parentId === null ? () => props.onClose(id) : undefined}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<SessionTab
|
||||
special="logs"
|
||||
active={props.activeSessionId === "logs"}
|
||||
onSelect={() => props.onSelect("logs")}
|
||||
/>
|
||||
<button
|
||||
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
onClick={props.onNew}
|
||||
title="New parent session (Cmd/Ctrl+T)"
|
||||
aria-label="New parent session"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Show when={totalTabs() > 1}>
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<SessionTab special="logs" active={props.activeSessionId === "logs"} onSelect={() => props.onSelect("logs")} />
|
||||
<button
|
||||
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
onClick={props.onNew}
|
||||
title="New parent session (Cmd/Ctrl+T)"
|
||||
aria-label="New parent session"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
67
src/lib/commands.ts
Normal file
67
src/lib/commands.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export interface KeyboardShortcut {
|
||||
key: string
|
||||
meta?: boolean
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
alt?: boolean
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: string
|
||||
label: 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 labelMatch = cmd.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>
|
||||
68
src/lib/db.ts
Normal file
68
src/lib/db.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
const DB_NAME = "opencode-client"
|
||||
const DB_VERSION = 1
|
||||
const HISTORY_STORE = "message-history"
|
||||
|
||||
let db: IDBDatabase | null = null
|
||||
|
||||
async function getDB(): Promise<IDBDatabase> {
|
||||
if (db) return db
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
db = request.result
|
||||
resolve(db)
|
||||
}
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const database = (event.target as IDBOpenDBRequest).result
|
||||
|
||||
if (!database.objectStoreNames.contains(HISTORY_STORE)) {
|
||||
database.createObjectStore(HISTORY_STORE)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveHistory(instanceId: string, history: string[]): Promise<void> {
|
||||
const database = await getDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = database.transaction(HISTORY_STORE, "readwrite")
|
||||
const store = tx.objectStore(HISTORY_STORE)
|
||||
const request = store.put(history, instanceId)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadHistory(instanceId: string): Promise<string[]> {
|
||||
try {
|
||||
const database = await getDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = database.transaction(HISTORY_STORE, "readonly")
|
||||
const store = tx.objectStore(HISTORY_STORE)
|
||||
const request = store.get(instanceId)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result || [])
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Failed to load history from IndexedDB:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHistory(instanceId: string): Promise<void> {
|
||||
const database = await getDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = database.transaction(HISTORY_STORE, "readwrite")
|
||||
const store = tx.objectStore(HISTORY_STORE)
|
||||
const request = store.delete(instanceId)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
69
src/lib/keyboard-registry.ts
Normal file
69
src/lib/keyboard-registry.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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 keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
|
||||
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
src/lib/keyboard-utils.ts
Normal file
30
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("+")
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { instances, activeInstanceId, setActiveInstanceId } from "../stores/instances"
|
||||
import { activeSessionId, setActiveSession, getSessions } from "../stores/sessions"
|
||||
import { activeSessionId, setActiveSession, getSessions, activeParentSessionId } from "../stores/sessions"
|
||||
|
||||
export function setupTabKeyboardShortcuts(
|
||||
handleNewInstance: () => void,
|
||||
handleCloseInstance: (instanceId: string) => void,
|
||||
handleNewSession: (instanceId: string) => void,
|
||||
handleCloseSession: (instanceId: string, sessionId: string) => void,
|
||||
handleCommandPalette: () => void,
|
||||
) {
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
|
||||
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())
|
||||
@@ -16,12 +24,30 @@ export function setupTabKeyboardShortcuts(
|
||||
}
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "n") {
|
||||
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.key === "t") {
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
||||
e.preventDefault()
|
||||
const instanceId = activeInstanceId()
|
||||
if (instanceId) {
|
||||
@@ -29,7 +55,15 @@ export function setupTabKeyboardShortcuts(
|
||||
}
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "w") {
|
||||
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
|
||||
|
||||
44
src/lib/shortcuts/agent.ts
Normal file
44
src/lib/shortcuts/agent.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { keyboardRegistry } from "../keyboard-registry"
|
||||
|
||||
export function registerAgentShortcuts(
|
||||
cycleAgent: () => void,
|
||||
cycleAgentReverse: () => void,
|
||||
focusModelSelector: () => void,
|
||||
) {
|
||||
const isMac = () => navigator.platform.toLowerCase().includes("mac")
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "agent-next",
|
||||
key: "Tab",
|
||||
modifiers: {},
|
||||
handler: cycleAgent,
|
||||
description: "next agent",
|
||||
context: "global",
|
||||
condition: () => {
|
||||
const active = document.activeElement
|
||||
return !(active?.tagName === "TEXTAREA" || active?.tagName === "INPUT" || active?.hasAttribute("contenteditable"))
|
||||
},
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "agent-prev",
|
||||
key: "Tab",
|
||||
modifiers: { shift: true },
|
||||
handler: cycleAgentReverse,
|
||||
description: "previous agent",
|
||||
context: "global",
|
||||
condition: () => {
|
||||
const active = document.activeElement
|
||||
return !(active?.tagName === "TEXTAREA" || active?.tagName === "INPUT" || active?.hasAttribute("contenteditable"))
|
||||
},
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "focus-model",
|
||||
key: "M",
|
||||
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
|
||||
handler: focusModelSelector,
|
||||
description: "focus model",
|
||||
context: "global",
|
||||
})
|
||||
}
|
||||
31
src/lib/shortcuts/escape.ts
Normal file
31
src/lib/shortcuts/escape.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { keyboardRegistry } from "../keyboard-registry"
|
||||
|
||||
export function registerEscapeShortcut(
|
||||
isSessionBusy: () => boolean,
|
||||
interruptSession: () => void,
|
||||
blurInput: () => void,
|
||||
closeModal: () => void,
|
||||
) {
|
||||
keyboardRegistry.register({
|
||||
id: "escape",
|
||||
key: "Escape",
|
||||
modifiers: {},
|
||||
handler: () => {
|
||||
const hasOpenModal = document.querySelector('[role="dialog"]') !== null
|
||||
|
||||
if (hasOpenModal) {
|
||||
closeModal()
|
||||
return
|
||||
}
|
||||
|
||||
if (isSessionBusy()) {
|
||||
interruptSession()
|
||||
return
|
||||
}
|
||||
|
||||
blurInput()
|
||||
},
|
||||
description: "cancel/close",
|
||||
context: "global",
|
||||
})
|
||||
}
|
||||
23
src/lib/shortcuts/input.ts
Normal file
23
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",
|
||||
})
|
||||
}
|
||||
95
src/lib/shortcuts/navigation.ts
Normal file
95
src/lib/shortcuts/navigation.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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")
|
||||
|
||||
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 parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return
|
||||
|
||||
const familySessions = getSessionFamily(instanceId, parentId)
|
||||
const ids = familySessions.map((s) => s.id).concat(["logs"])
|
||||
if (ids.length <= 1) return
|
||||
|
||||
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
|
||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
||||
if (ids[prev]) setActiveSession(instanceId, ids[prev])
|
||||
},
|
||||
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 parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return
|
||||
|
||||
const familySessions = getSessionFamily(instanceId, parentId)
|
||||
const ids = familySessions.map((s) => s.id).concat(["logs"])
|
||||
if (ids.length <= 1) return
|
||||
|
||||
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
|
||||
const next = (current + 1) % ids.length
|
||||
if (ids[next]) setActiveSession(instanceId, ids[next])
|
||||
},
|
||||
description: "next session",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "switch-to-logs",
|
||||
key: "l",
|
||||
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
|
||||
handler: () => {
|
||||
const instanceId = activeInstanceId()
|
||||
if (instanceId) setActiveSession(instanceId, "logs")
|
||||
},
|
||||
description: "logs tab",
|
||||
context: "global",
|
||||
})
|
||||
}
|
||||
17
src/stores/command-palette.ts
Normal file
17
src/stores/command-palette.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
|
||||
export function showCommandPalette() {
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
export function hideCommandPalette() {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
export function toggleCommandPalette() {
|
||||
setIsOpen(!isOpen())
|
||||
}
|
||||
|
||||
export { isOpen }
|
||||
51
src/stores/message-history.ts
Normal file
51
src/stores/message-history.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { saveHistory, loadHistory, deleteHistory } from "../lib/db"
|
||||
|
||||
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)
|
||||
|
||||
saveHistory(instanceId, 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)
|
||||
await deleteHistory(instanceId)
|
||||
}
|
||||
|
||||
async function ensureHistoryLoaded(instanceId: string): Promise<void> {
|
||||
if (historyLoaded.has(instanceId)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const history = await loadHistory(instanceId)
|
||||
instanceHistories.set(instanceId, history)
|
||||
historyLoaded.add(instanceId)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load history:", error)
|
||||
instanceHistories.set(instanceId, [])
|
||||
historyLoaded.add(instanceId)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user