Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 132 additions & 71 deletions apps/gateway-admin/components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { ACPAgent, ACPModelOption } from './types'
import {
createLocalAttachmentDraft,
fileToSerializableAttachment,
revokeLocalAttachmentPreview,
validateLocalFiles,
} from '@/lib/chat/local-attachments'
import { groupModels } from '@/lib/chat/model-grouping'
import { nextNavIndex, useListKeyboard } from '@/lib/chat/use-list-keyboard'
import type { AttachmentRef, LocalAttachmentDraft, PromptAttachmentRef } from '@/lib/fs/types'
import { isInlineImageMime, previewWorkspaceFile } from '@/lib/fs/client'
import { WorkspacePicker } from './workspace-picker'
Expand Down Expand Up @@ -89,10 +92,15 @@ export function ChatInput({
const modelPickerRef = React.useRef<HTMLDivElement>(null)
const modelTriggerRef = React.useRef<HTMLButtonElement>(null)
const modelOptionRefs = React.useRef<Array<HTMLButtonElement | null>>([])
const [activeAgentIndex, setActiveAgentIndex] = React.useState(0)
const [activeModelIndex, setActiveModelIndex] = React.useState(0)
const agentNav = useListKeyboard({ count: agents.length })
const modelNav = useListKeyboard({ count: modelOptions.length })
const activeAgentIndex = agentNav.activeIndex
const setActiveAgentIndex = agentNav.setActiveIndex
const activeModelIndex = modelNav.activeIndex
const setActiveModelIndex = modelNav.setActiveIndex
const pickerId = React.useId()
const modelPickerId = React.useId()
const groupedModels = React.useMemo(() => groupModels(modelOptions), [modelOptions])

const value = draftText ?? uncontrolledValue
const setValue = React.useCallback(
Expand Down Expand Up @@ -255,6 +263,16 @@ export function ChatInput({
const selectedIndex = Math.max(modelOptions.findIndex((model) => model.id === selectedModel?.id), 0)
setActiveModelIndex(selectedIndex)
const frame = window.requestAnimationFrame(() => {
if (groupedModels.kind === 'grouped') {
// Flat-mode refs don't exist in grouped mode; pull focus onto the
// currently-selected toggle (or the first toggle as a fallback) so
// keyboard navigation immediately enters the picker.
const pickerEl = document.getElementById(modelPickerId)
const selectedToggle = pickerEl?.querySelector<HTMLButtonElement>('[data-state="on"]')
const firstToggle = pickerEl?.querySelector<HTMLButtonElement>('button')
;(selectedToggle ?? firstToggle)?.focus()
return
}
modelOptionRefs.current[selectedIndex]?.focus()
})

Expand All @@ -270,7 +288,7 @@ export function ChatInput({
window.cancelAnimationFrame(frame)
document.removeEventListener('mousedown', handlePointerDown)
}
}, [modelPickerOpen, modelOptions, selectedModel?.id])
}, [modelPickerOpen, modelOptions, selectedModel?.id, groupedModels.kind, modelPickerId])

const selectAgent = (agentId: string) => {
onSelectAgent(agentId)
Expand Down Expand Up @@ -300,26 +318,17 @@ export function ChatInput({

if (agents.length === 0) return

const moveTo = (nextIndex: number) => {
setActiveAgentIndex(nextIndex)
optionRefs.current[nextIndex]?.focus()
if ((event.key === 'Enter' || event.key === ' ') && agents[activeAgentIndex]) {
event.preventDefault()
selectAgent(agents[activeAgentIndex].id)
return
}

if (event.key === 'ArrowDown') {
event.preventDefault()
moveTo((activeAgentIndex + 1) % agents.length)
} else if (event.key === 'ArrowUp') {
const next = nextNavIndex(activeAgentIndex, event.key, agents.length)
if (next !== null) {
event.preventDefault()
moveTo((activeAgentIndex - 1 + agents.length) % agents.length)
} else if (event.key === 'Home') {
event.preventDefault()
moveTo(0)
} else if (event.key === 'End') {
event.preventDefault()
moveTo(agents.length - 1)
} else if ((event.key === 'Enter' || event.key === ' ') && agents[activeAgentIndex]) {
event.preventDefault()
selectAgent(agents[activeAgentIndex].id)
setActiveAgentIndex(next)
optionRefs.current[next]?.focus()
}
}

Expand Down Expand Up @@ -363,26 +372,21 @@ export function ChatInput({

if (modelOptions.length === 0) return

const moveTo = (nextIndex: number) => {
setActiveModelIndex(nextIndex)
modelOptionRefs.current[nextIndex]?.focus()
}
// In grouped mode each row owns a ToggleGroup with its own keyboard nav —
// bow out so we don't double-handle arrow keys.
if (groupedModels.kind === 'grouped') return

if (event.key === 'ArrowDown') {
event.preventDefault()
moveTo((activeModelIndex + 1) % modelOptions.length)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
moveTo((activeModelIndex - 1 + modelOptions.length) % modelOptions.length)
} else if (event.key === 'Home') {
event.preventDefault()
moveTo(0)
} else if (event.key === 'End') {
event.preventDefault()
moveTo(modelOptions.length - 1)
} else if ((event.key === 'Enter' || event.key === ' ') && modelOptions[activeModelIndex]) {
if ((event.key === 'Enter' || event.key === ' ') && modelOptions[activeModelIndex]) {
event.preventDefault()
selectModel(modelOptions[activeModelIndex].id)
return
}

const next = nextNavIndex(activeModelIndex, event.key, modelOptions.length)
if (next !== null) {
event.preventDefault()
setActiveModelIndex(next)
modelOptionRefs.current[next]?.focus()
}
}

Expand Down Expand Up @@ -516,9 +520,13 @@ export function ChatInput({
{modelPickerOpen && (
<div
id={modelPickerId}
role="listbox"
role={groupedModels.kind === 'flat' ? 'listbox' : 'group'}
aria-label="Model picker"
aria-activedescendant={modelOptions[activeModelIndex] ? `${modelPickerId}-${modelOptions[activeModelIndex].id}` : undefined}
aria-activedescendant={
groupedModels.kind === 'flat' && modelOptions[activeModelIndex]
? `${modelPickerId}-${modelOptions[activeModelIndex].id}`
: undefined
}
onKeyDown={handleModelListKeyDown}
className={cn(
'absolute bottom-full right-0 z-50 mb-1.5 min-w-[180px] overflow-hidden',
Expand All @@ -527,38 +535,91 @@ export function ChatInput({
)}
>
<TooltipProvider delayDuration={400}>
{modelOptions.map((model, index) => {
const optionButton = (
<button
key={model.id}
id={`${modelPickerId}-${model.id}`}
ref={(node) => {
modelOptionRefs.current[index] = node
}}
type="button"
role="option"
aria-selected={selectedModel?.id === model.id}
tabIndex={index === activeModelIndex ? 0 : -1}
onFocus={() => setActiveModelIndex(index)}
onClick={() => selectModel(model.id)}
className={cn(
'flex w-full items-center px-3 py-1.5 text-left text-[13px] font-medium text-aurora-text-primary transition-colors hover:bg-aurora-hover-bg',
selectedModel?.id === model.id && 'bg-aurora-panel-medium',
)}
>
<span className="truncate">{model.name}</span>
</button>
)
if (!model.description) return optionButton
return (
<Tooltip key={model.id}>
<TooltipTrigger asChild>{optionButton}</TooltipTrigger>
<TooltipContent side="right" className="max-w-[260px] text-xs">
{model.description}
</TooltipContent>
</Tooltip>
)
})}
{groupedModels.kind === 'flat' ? (
modelOptions.map((model, index) => {
const optionButton = (
<button
key={model.id}
id={`${modelPickerId}-${model.id}`}
ref={(node) => {
modelOptionRefs.current[index] = node
}}
type="button"
role="option"
aria-selected={selectedModel?.id === model.id}
tabIndex={index === activeModelIndex ? 0 : -1}
onFocus={() => setActiveModelIndex(index)}
onClick={() => selectModel(model.id)}
className={cn(
'flex w-full items-center px-3 py-1.5 text-left text-[13px] font-medium text-aurora-text-primary transition-colors hover:bg-aurora-hover-bg',
selectedModel?.id === model.id && 'bg-aurora-panel-medium',
)}
>
<span className="truncate">{model.name}</span>
</button>
)
if (!model.description) return optionButton
return (
<Tooltip key={model.id}>
<TooltipTrigger asChild>{optionButton}</TooltipTrigger>
<TooltipContent side="right" className="max-w-[260px] text-xs">
{model.description}
</TooltipContent>
</Tooltip>
)
})
) : (
groupedModels.groups.map((group) => {
const selectedVariant = group.variants.find(
(v) => v.option.id === selectedModel?.id,
)
return (
<div
key={group.base}
className="flex items-center justify-between gap-2 px-3 py-1.5"
>
<span className="truncate text-[13px] font-medium text-aurora-text-primary">
{group.base}
</span>
<ToggleGroup
type="single"
value={selectedVariant?.effort ?? ''}
onValueChange={(effort) => {
if (!effort) return
const variant = group.variants.find((v) => v.effort === effort)
if (variant) selectModel(variant.option.id)
}}
className="h-7 shrink-0"
>
{group.variants.map((variant) => {
const item = (
<ToggleGroupItem
key={variant.effort}
value={variant.effort}
aria-label={`${group.base} ${variant.effort}`}
className="h-7 px-2 text-[11px]"
>
{variant.effort}
</ToggleGroupItem>
)
if (!variant.option.description) return item
return (
<Tooltip key={variant.effort}>
<TooltipTrigger asChild>{item}</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-[260px] text-xs"
>
{variant.option.description}
</TooltipContent>
</Tooltip>
)
})}
</ToggleGroup>
</div>
)
})
)}
</TooltipProvider>
</div>
)}
Expand Down
40 changes: 35 additions & 5 deletions apps/gateway-admin/components/chat/chat-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,38 @@ export function ChatShell() {
const [maxTokens, setMaxTokens] = React.useState(8192)
const [draftText, setDraftText] = React.useState('')
const [attachmentsResetToken, setAttachmentsResetToken] = React.useState(0)
const { runs, selectedRun, selectedRunId, providerHealth, selectedAgent, selectedModel, agents, projects } =
useChatSessionData()
const { selectRun, createSession, sendPrompt, selectAgent, selectModel } = useChatSessionActions()
const {
visibleRuns,
hiddenRunCount,
includeHiddenRuns,
dominantModelId,
lastDispatchError,
selectedRun,
selectedRunId,
providerHealth,
selectedAgent,
selectedModel,
agents,
projects,
} = useChatSessionData()
const {
selectRun,
createSession,
sendPrompt,
selectAgent,
selectModel,
setIncludeHiddenRuns,
bulkCloseHiddenSessions,
} = useChatSessionActions()
const { messages } = useChatSessionStream()
const { connectionState } = useChatSessionConnection()
const providerReady = Boolean(providerHealth?.ready)
const providerUnavailableMessage = providerReady ? null : providerHealth?.message?.trim() || null
// Union the actual provider-down message with the transient dispatch-error
// surface so the user sees a banner for ANY actionable failure — but the
// input itself only disables when the provider is genuinely down.
const providerUnavailableMessage = providerReady
? lastDispatchError?.message ?? null
: providerHealth?.message?.trim() || lastDispatchError?.message || null

const createRun = React.useCallback(async () => {
try {
Expand Down Expand Up @@ -228,11 +253,16 @@ export function ChatShell() {
<SessionSidebar
className="shadow-[var(--aurora-shadow-strong),var(--aurora-highlight-strong)] md:shadow-none"
projects={projects}
runs={runs}
runs={visibleRuns}
selectedRunId={selectedRunId}
selectedProjectId="workspace"
onSelectRun={selectRun}
onNewRun={() => void createRun()}
hiddenRunCount={hiddenRunCount}
includeHiddenRuns={includeHiddenRuns}
onToggleIncludeHidden={() => setIncludeHiddenRuns((v) => !v)}
onBulkCloseHidden={bulkCloseHiddenSessions}
dominantModelId={dominantModelId}
/>
</div>
</>
Expand Down
Loading
Loading