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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user