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:
Shantur Rathore
2025-10-23 20:18:45 +01:00
parent 3c5c4755b8
commit 4c98a3df06
21 changed files with 1302 additions and 143 deletions

View File

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

View 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

View File

@@ -0,0 +1,12 @@
import { Component, JSX } from "solid-js"
interface HintRowProps {
children: JSX.Element
class?: string
}
const HintRow: Component<HintRowProps> = (props) => {
return <span class={`text-xs text-gray-500 dark:text-gray-400 ${props.class || ""}`}>{props.children}</span>
}
export default HintRow

View File

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

View 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

View File

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

View File

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

View File

@@ -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>
)