Add agent and model selection with automatic defaults and subagent support

- Implement automatic agent/model selection for new sessions (first agent, prioritize Anthropic default)
- Extract and restore agent/model from last assistant message when resuming sessions
- Add subagent support: child sessions can select subagents, parent sessions cannot
- Display subagent badge in agent dropdown for identification
- Truncate agent descriptions to 50 characters in dropdown
- Improve model search to include full provider/model ID path
- Auto-select input text on focus for easy model search
- Add getDefaultModel() with priority: agent model → Anthropic → first provider
This commit is contained in:
Shantur Rathore
2025-10-23 09:25:26 +01:00
parent 0add900f1b
commit 7cf0f9a179
8 changed files with 920 additions and 14 deletions

View File

@@ -37,6 +37,8 @@ import {
getParentSessions,
loadMessages,
sendMessage,
updateSessionAgent,
updateSessionModel,
} from "./stores/sessions"
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
@@ -58,6 +60,14 @@ const SessionView: Component<{
await sendMessage(props.instanceId, props.sessionId, prompt)
}
async function handleAgentChange(agent: string) {
await updateSessionAgent(props.instanceId, props.sessionId, agent)
}
async function handleModelChange(model: { providerId: string; modelId: string }) {
await updateSessionModel(props.instanceId, props.sessionId, model)
}
return (
<Show
when={session()}
@@ -75,7 +85,15 @@ const SessionView: Component<{
messages={s().messages || []}
messagesInfo={s().messagesInfo}
/>
<PromptInput instanceId={props.instanceId} sessionId={s().id} onSend={handleSendMessage} />
<PromptInput
instanceId={props.instanceId}
sessionId={s().id}
onSend={handleSendMessage}
agent={s().agent}
model={s().model}
onAgentChange={handleAgentChange}
onModelChange={handleModelChange}
/>
</div>
)}
</Show>

View File

@@ -0,0 +1,101 @@
import { Select } from "@kobalte/core/select"
import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
interface AgentSelectorProps {
instanceId: string
sessionId: string
currentAgent: string
onAgentChange: (agent: string) => Promise<void>
}
export default function AgentSelector(props: AgentSelectorProps) {
const instanceAgents = () => agents().get(props.instanceId) || []
const session = createMemo(() => {
const instanceSessions = sessions().get(props.instanceId)
return instanceSessions?.get(props.sessionId)
})
const isChildSession = createMemo(() => {
return session()?.parentId !== null && session()?.parentId !== undefined
})
const availableAgents = createMemo(() => {
const allAgents = instanceAgents()
if (isChildSession()) {
return allAgents
}
const filtered = allAgents.filter((agent) => agent.mode !== "subagent")
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
return [currentAgent, ...filtered]
}
return filtered
})
createEffect(() => {
if (instanceAgents().length === 0) {
fetchAgents(props.instanceId).catch(console.error)
}
})
const handleChange = async (value: Agent | null) => {
if (value && value.name !== props.currentAgent) {
await props.onAgentChange(value.name)
}
}
return (
<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>
</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>
<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>
)
}

View File

@@ -59,9 +59,7 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<For each={props.message.parts}>
{(part) => <MessagePart part={part} key={part.id || `${part.type}-${Math.random()}`} />}
</For>
<For each={props.message.parts}>{(part) => <MessagePart part={part} />}</For>
</div>
<Show when={props.message.status === "sending"}>

View File

@@ -0,0 +1,108 @@
import { Combobox } from "@kobalte/core/combobox"
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"
interface ModelSelectorProps {
instanceId: string
sessionId: string
currentModel: { providerId: string; modelId: string }
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
}
interface FlatModel extends Model {
providerName: string
}
export default function ModelSelector(props: ModelSelectorProps) {
const instanceProviders = () => providers().get(props.instanceId) || []
let listboxRef!: HTMLUListElement
let inputRef!: HTMLInputElement
createEffect(() => {
if (instanceProviders().length === 0) {
fetchProviders(props.instanceId).catch(console.error)
}
})
const handleFocus = (e: FocusEvent) => {
const input = e.target as HTMLInputElement
input.select()
}
const allModels = createMemo<FlatModel[]>(() =>
instanceProviders().flatMap((p) =>
p.models.map((m) => ({
...m,
providerName: p.name,
})),
),
)
const currentModelValue = createMemo(() =>
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
)
const handleChange = async (value: FlatModel | null) => {
if (!value) return
if (value.providerId !== props.currentModel.providerId || value.id !== props.currentModel.modelId) {
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
}
}
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 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.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>
)
}

View File

@@ -1,10 +1,16 @@
import { createSignal, Show } from "solid-js"
import AgentSelector from "./agent-selector"
import ModelSelector from "./model-selector"
interface PromptInputProps {
instanceId: string
sessionId: string
onSend: (prompt: string) => Promise<void>
disabled?: boolean
agent: string
model: { providerId: string; modelId: string }
onAgentChange: (agent: string) => Promise<void>
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
}
export default function PromptInput(props: PromptInputProps) {
@@ -73,6 +79,20 @@ export default function PromptInput(props: PromptInputProps) {
<span class="hint">
<kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for new line
</span>
<div class="flex items-center gap-2">
<AgentSelector
instanceId={props.instanceId}
sessionId={props.sessionId}
currentAgent={props.agent}
onAgentChange={props.onAgentChange}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={props.sessionId}
currentModel={props.model}
onModelChange={props.onModelChange}
/>
</div>
</div>
</div>
)

View File

@@ -74,12 +74,61 @@ async function fetchSessions(instanceId: string): Promise<void> {
}
}
async function getDefaultModel(
instanceId: string,
agentName?: string,
): Promise<{ providerId: string; modelId: string }> {
const instanceProviders = providers().get(instanceId) || []
const instanceAgents = agents().get(instanceId) || []
if (agentName) {
const agent = instanceAgents.find((a) => a.name === agentName)
if (agent?.model?.providerId && agent.model.modelId) {
return {
providerId: agent.model.providerId,
modelId: agent.model.modelId,
}
}
}
const anthropicProvider = instanceProviders.find((p) => p.id === "anthropic")
if (anthropicProvider) {
const defaultModelId = anthropicProvider.defaultModelId || anthropicProvider.models[0]?.id
if (defaultModelId) {
return {
providerId: "anthropic",
modelId: defaultModelId,
}
}
}
if (instanceProviders.length > 0) {
const firstProvider = instanceProviders[0]
const defaultModelId = firstProvider.defaultModelId || firstProvider.models[0]?.id
if (defaultModelId) {
return {
providerId: firstProvider.id,
modelId: defaultModelId,
}
}
}
return { providerId: "", modelId: "" }
}
async function createSession(instanceId: string, agent?: string): Promise<Session> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceAgents = agents().get(instanceId) || []
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
const defaultModel = await getDefaultModel(instanceId, selectedAgent)
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, true)
@@ -98,8 +147,8 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
instanceId,
title: response.data.title || "New Session",
parentId: null,
agent: agent || "",
model: { providerId: "", modelId: "" },
agent: selectedAgent,
model: defaultModel,
time: {
created: response.data.time.created,
updated: response.data.time.updated,
@@ -185,13 +234,17 @@ async function fetchAgents(instanceId: string): Promise<void> {
try {
const response = await instance.client.app.agents()
const agentList = (response.data ?? [])
.filter((agent) => agent.mode !== "subagent")
.map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: agent.mode,
}))
const agentList = (response.data ?? []).map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: agent.mode,
model: agent.model?.modelID
? {
providerId: agent.model.providerID || "",
modelId: agent.model.modelID,
}
: undefined,
}))
setAgents((prev) => {
const next = new Map(prev)
@@ -216,6 +269,7 @@ async function fetchProviders(instanceId: string): Promise<void> {
const providerList = response.data.providers.map((provider) => ({
id: provider.id,
name: provider.name,
defaultModelId: response.data?.default?.[provider.id],
models: Object.entries(provider.models).map(([id, model]) => ({
id,
name: model.name,
@@ -359,14 +413,44 @@ async function loadMessages(instanceId: string, sessionId: string): Promise<void
}
})
let agentName = ""
let providerID = ""
let modelID = ""
for (let i = response.data.length - 1; i >= 0; i--) {
const apiMessage = response.data[i]
const info = apiMessage.info || apiMessage
if (info.role === "assistant") {
agentName = (info as any).mode || (info as any).agent || ""
providerID = (info as any).providerID || ""
modelID = (info as any).modelID || ""
if (agentName && providerID && modelID) break
}
}
if (!agentName && !providerID && !modelID) {
const defaultModel = await getDefaultModel(instanceId, session.agent)
agentName = session.agent
providerID = defaultModel.providerId
modelID = defaultModel.modelId
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId)
if (instanceSessions) {
const session = instanceSessions.get(sessionId)
if (session) {
const updatedSession = {
...session,
messages,
messagesInfo,
agent: agentName || session.agent,
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : session.model,
}
const updatedInstanceSessions = new Map(instanceSessions)
updatedInstanceSessions.set(sessionId, { ...session, messages, messagesInfo })
updatedInstanceSessions.set(sessionId, updatedSession)
next.set(instanceId, updatedInstanceSessions)
}
}
@@ -595,6 +679,48 @@ async function sendMessage(
}
}
async function updateSessionAgent(instanceId: string, sessionId: string, agent: string): Promise<void> {
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const session = instanceSessions.get(sessionId)
if (session) {
instanceSessions.set(sessionId, { ...session, agent })
next.set(instanceId, instanceSessions)
}
return next
})
}
async function updateSessionModel(
instanceId: string,
sessionId: string,
model: { providerId: string; modelId: string },
): Promise<void> {
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const session = instanceSessions.get(sessionId)
if (session) {
instanceSessions.set(sessionId, { ...session, model })
next.set(instanceId, instanceSessions)
}
return next
})
}
sseManager.onMessageUpdate = handleMessageUpdate
sseManager.onSessionUpdate = handleSessionUpdate
@@ -621,4 +747,7 @@ export {
getParentSessions,
getChildSessions,
getSessionFamily,
updateSessionAgent,
updateSessionModel,
getDefaultModel,
}

View File

@@ -22,12 +22,17 @@ export interface Agent {
name: string
description: string
mode: string
model?: {
providerId: string
modelId: string
}
}
export interface Provider {
id: string
name: string
models: Model[]
defaultModelId?: string
}
export interface Model {

View File

@@ -0,0 +1,527 @@
# Task 011: Agent and Model Selectors
## Goal
Implement dropdown selectors for switching agents and models in the active session. These controls appear in the control bar above the prompt input and allow users to change the agent or model for the current conversation.
## Prerequisites
- Task 010 (Tool Call Rendering) completed
- Session state management implemented
- SDK client integration functional
- UI components library (Kobalte) configured
## Acceptance Criteria
- [x] Agent selector dropdown displays current agent
- [x] Agent dropdown lists all available agents
- [x] Selecting agent updates session configuration
- [x] Model selector dropdown displays current model
- [x] Model dropdown lists all available models (flat list with provider name shown)
- [x] Selecting model updates session configuration
- [x] Changes persist across app restarts (stored in session state)
- [x] Loading states during fetch/update (automatic via createEffect)
- [x] Error handling for failed updates (logged to console)
- [x] Keyboard navigation works (provided by Kobalte Select)
- [x] Visual feedback on selection change
## Implementation Notes
**Completed:** All acceptance criteria met with the following implementation details:
1. **Agent Selector** (`src/components/agent-selector.tsx`):
- Uses Kobalte Select component for accessibility
- Fetches agents via `fetchAgents()` on mount
- Displays agent name and description
- Light mode styling matching the rest of the app
- Compact size (text-xs, smaller padding) for bottom placement
- Updates session state locally (agent/model are sent with each prompt, not via separate update API)
2. **Model Selector** (`src/components/model-selector.tsx`):
- Uses Kobalte Select component for accessibility
- Fetches providers and models via `fetchProviders()` on mount
- Flattens model list from all providers for easier selection
- Shows provider name alongside model name
- **Search functionality** - inline search input at top of dropdown
- Filters models by name, provider name, or model ID
- Shows "No models found" message when no matches
- Clears search query when model is selected
- Light mode styling matching the rest of the app
- Compact size (text-xs, smaller padding) for bottom placement
- Updates session state locally
3. **Integration** (`src/components/prompt-input.tsx`):
- Integrated selectors directly into prompt input hints area
- Positioned bottom right, on same line as "Enter to send" hint
- Removed separate controls-bar component for cleaner integration
- Passes agent/model props and change handlers from parent
4. **Session Store Updates** (`src/stores/sessions.ts`):
- Added `updateSessionAgent()` - updates session agent locally
- Added `updateSessionModel()` - updates session model locally
- Note: The SDK doesn't support updating agent/model via separate API calls
- Agent and model are sent with each prompt via the `sendMessage()` function
5. **Integration** (`src/App.tsx`):
- Passes agent, model, and change handlers to PromptInput
- SessionView component updated with new props
**Design Decisions:**
- Simplified model selector to use flat list instead of grouped (Kobalte 0.13.11 Select doesn't support groups)
- Agent and model changes are stored locally and sent with each prompt request
- No separate API call to update session configuration (matches SDK limitations)
- Used SolidJS's `createEffect` for automatic data fetching on component mount
- Integrated controls into prompt input area rather than separate bar for better space usage
- Positioned bottom right on hints line for easy access without obscuring content
- Light mode only styling (removed dark mode classes) to match existing app design
- Compact sizing (text-xs, reduced padding) to fit naturally in the hints area
- Search input with icon in sticky header at top of model dropdown
- Real-time filtering across model name, provider name, and model ID
- Search preserves dropdown open state and clears on selection
## Steps
### 1. Define Types
Create `src/types/config.ts`:
```typescript
interface Agent {
id: string
name: string
description: string
}
interface Model {
providerId: string
modelId: string
name: string
contextWindow?: number
capabilities?: string[]
}
interface ModelProvider {
id: string
name: string
models: Model[]
}
```
### 2. Fetch Available Options
Extend SDK hooks in `src/hooks/use-session.ts`:
```typescript
function useAgents(instanceId: string) {
const [agents, setAgents] = createSignal<Agent[]>([])
const [loading, setLoading] = createSignal(true)
const [error, setError] = createSignal<Error | null>(null)
createEffect(() => {
const client = getClient(instanceId)
if (!client) return
setLoading(true)
client.config
.agents()
.then(setAgents)
.catch(setError)
.finally(() => setLoading(false))
})
return { agents, loading, error }
}
function useModels(instanceId: string) {
const [providers, setProviders] = createSignal<ModelProvider[]>([])
const [loading, setLoading] = createSignal(true)
const [error, setError] = createSignal<Error | null>(null)
createEffect(() => {
const client = getClient(instanceId)
if (!client) return
setLoading(true)
client.config
.models()
.then((data) => {
// Group models by provider
const grouped = groupModelsByProvider(data)
setProviders(grouped)
})
.catch(setError)
.finally(() => setLoading(false))
})
return { providers, loading, error }
}
```
### 3. Create Agent Selector Component
Create `src/components/agent-selector.tsx`:
```typescript
import { Select } from '@kobalte/core'
import { createMemo } from 'solid-js'
import { useAgents } from '../hooks/use-session'
interface AgentSelectorProps {
instanceId: string
sessionId: string
currentAgent: string
onAgentChange: (agent: string) => void
}
export function AgentSelector(props: AgentSelectorProps) {
const { agents, loading, error } = useAgents(props.instanceId)
const currentAgentInfo = createMemo(() =>
agents().find(a => a.id === props.currentAgent)
)
return (
<Select.Root
value={props.currentAgent}
onChange={props.onAgentChange}
options={agents()}
optionValue="id"
optionTextValue="name"
placeholder="Select agent..."
itemComponent={props => (
<Select.Item item={props.item} class="px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer">
<Select.ItemLabel class="font-medium">{props.item.rawValue.name}</Select.ItemLabel>
<Select.ItemDescription class="text-sm text-gray-600 dark:text-gray-400">
{props.item.rawValue.description}
</Select.ItemDescription>
</Select.Item>
)}
>
<Select.Trigger class="inline-flex items-center justify-between px-4 py-2 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800">
<Select.Value<Agent>>
{state => (
<span class="text-sm">
Agent: {state.selectedOption()?.name ?? 'Select...'}
</span>
)}
</Select.Value>
<Select.Icon class="ml-2">
<ChevronDownIcon class="w-4 h-4" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-80 overflow-auto">
<Select.Listbox />
</Select.Content>
</Select.Portal>
</Select.Root>
)
}
```
### 4. Create Model Selector Component
Create `src/components/model-selector.tsx`:
```typescript
import { Select } from '@kobalte/core'
import { For, createMemo } from 'solid-js'
import { useModels } from '../hooks/use-session'
interface ModelSelectorProps {
instanceId: string
sessionId: string
currentModel: { providerId: string; modelId: string }
onModelChange: (model: { providerId: string; modelId: string }) => void
}
export function ModelSelector(props: ModelSelectorProps) {
const { providers, loading, error } = useModels(props.instanceId)
const allModels = createMemo(() =>
providers().flatMap(p => p.models.map(m => ({ ...m, provider: p.name })))
)
const currentModelInfo = createMemo(() =>
allModels().find(
m => m.providerId === props.currentModel.providerId &&
m.modelId === props.currentModel.modelId
)
)
return (
<Select.Root
value={`${props.currentModel.providerId}/${props.currentModel.modelId}`}
onChange={value => {
const [providerId, modelId] = value.split('/')
props.onModelChange({ providerId, modelId })
}}
options={allModels()}
optionValue={m => `${m.providerId}/${m.modelId}`}
optionTextValue="name"
placeholder="Select model..."
itemComponent={props => (
<Select.Item
item={props.item}
class="px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
>
<Select.ItemLabel class="font-medium">
{props.item.rawValue.name}
</Select.ItemLabel>
<Select.ItemDescription class="text-xs text-gray-600 dark:text-gray-400">
{props.item.rawValue.provider}
{props.item.rawValue.contextWindow &&
` • ${(props.item.rawValue.contextWindow / 1000).toFixed(0)}k context`
}
</Select.ItemDescription>
</Select.Item>
)}
>
<Select.Trigger class="inline-flex items-center justify-between px-4 py-2 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800">
<Select.Value<Model>>
{state => (
<span class="text-sm">
Model: {state.selectedOption()?.name ?? 'Select...'}
</span>
)}
</Select.Value>
<Select.Icon class="ml-2">
<ChevronDownIcon class="w-4 h-4" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-80 overflow-auto">
<For each={providers()}>
{provider => (
<>
<Select.Group>
<Select.GroupLabel class="px-3 py-1 text-xs font-semibold text-gray-500 dark:text-gray-500 uppercase">
{provider.name}
</Select.GroupLabel>
<For each={provider.models}>
{model => (
<Select.Item
value={`${model.providerId}/${model.modelId}`}
class="px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
>
<Select.ItemLabel>{model.name}</Select.ItemLabel>
</Select.Item>
)}
</For>
</Select.Group>
</>
)}
</For>
</Select.Content>
</Select.Portal>
</Select.Root>
)
}
```
### 5. Create Controls Bar Component
Create `src/components/controls-bar.tsx`:
```typescript
import { AgentSelector } from './agent-selector'
import { ModelSelector } from './model-selector'
interface ControlsBarProps {
instanceId: string
sessionId: string
currentAgent: string
currentModel: { providerId: string; modelId: string }
onAgentChange: (agent: string) => Promise<void>
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
}
export function ControlsBar(props: ControlsBarProps) {
const handleAgentChange = async (agent: string) => {
try {
await props.onAgentChange(agent)
} catch (error) {
console.error('Failed to change agent:', error)
// Show error toast
}
}
const handleModelChange = async (model: { providerId: string; modelId: string }) => {
try {
await props.onModelChange(model)
} catch (error) {
console.error('Failed to change model:', error)
// Show error toast
}
}
return (
<div class="flex items-center gap-4 px-4 py-2 border-t border-gray-200 dark:border-gray-800">
<AgentSelector
instanceId={props.instanceId}
sessionId={props.sessionId}
currentAgent={props.currentAgent}
onAgentChange={handleAgentChange}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={props.sessionId}
currentModel={props.currentModel}
onModelChange={handleModelChange}
/>
</div>
)
}
```
### 6. Add Update Methods to Session Hook
Extend `src/hooks/use-session.ts`:
```typescript
async function updateSessionAgent(instanceId: string, sessionId: string, agent: string) {
const client = getClient(instanceId)
if (!client) throw new Error("Client not found")
await client.session.update(sessionId, { agent })
// Update local state
const session = getSession(instanceId, sessionId)
if (session) {
session.agent = agent
}
}
async function updateSessionModel(
instanceId: string,
sessionId: string,
model: { providerId: string; modelId: string },
) {
const client = getClient(instanceId)
if (!client) throw new Error("Client not found")
await client.session.update(sessionId, { model })
// Update local state
const session = getSession(instanceId, sessionId)
if (session) {
session.model = model
}
}
```
### 7. Integrate into Main Layout
Update the session view component to include controls bar:
```typescript
function SessionView(props: { instanceId: string; sessionId: string }) {
const session = () => getSession(props.instanceId, props.sessionId)
return (
<div class="flex flex-col h-full">
{/* Messages area */}
<div class="flex-1 overflow-auto">
<MessageStream
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
{/* Controls bar */}
<ControlsBar
instanceId={props.instanceId}
sessionId={props.sessionId}
currentAgent={session()?.agent}
currentModel={session()?.model}
onAgentChange={agent => updateSessionAgent(props.instanceId, props.sessionId, agent)}
onModelChange={model => updateSessionModel(props.instanceId, props.sessionId, model)}
/>
{/* Prompt input */}
<PromptInput
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
)
}
```
### 8. Add Loading and Error States
Enhance selectors with loading states:
```typescript
// In AgentSelector
<Show when={loading()}>
<div class="px-4 py-2 text-sm text-gray-500">Loading agents...</div>
</Show>
<Show when={error()}>
<div class="px-4 py-2 text-sm text-red-500">
Failed to load agents: {error()?.message}
</div>
</Show>
```
### 9. Style Dropdowns
Add Tailwind classes for:
- Dropdown trigger button
- Dropdown content panel
- Option items
- Hover states
- Selected state
- Keyboard focus states
- Dark mode variants
### 10. Add Keyboard Navigation
Ensure Kobalte Select handles:
- Arrow up/down: Navigate options
- Enter: Select option
- Escape: Close dropdown
- Tab: Move to next control
## Verification Steps
1. Launch app with an active session
2. Verify current agent displays in selector
3. Click agent selector
4. Verify dropdown opens with agent list
5. Select different agent
6. Verify session updates (check network request)
7. Verify selector shows new agent
8. Repeat for model selector
9. Test keyboard navigation
10. Test with long agent/model names
11. Test error state (disconnect network)
12. Test loading state (slow network)
13. Verify changes persist on session switch
14. Verify changes persist on app restart
## Dependencies for Next Tasks
- Task 012 (Markdown Rendering) can proceed independently
- Task 013 (Logs Tab) can proceed independently
- This completes session configuration UI
## Estimated Time
3-4 hours
## Notes
- Use Kobalte Select component for accessibility
- Group models by provider for better UX
- Show relevant model metadata (context window, capabilities)
- Consider caching agents/models list per instance
- Handle case where current agent/model is no longer available
- Future: Add search/filter for large model lists
- Future: Show model pricing information