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:
20
src/App.tsx
20
src/App.tsx
@@ -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>
|
||||
|
||||
101
src/components/agent-selector.tsx
Normal file
101
src/components/agent-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"}>
|
||||
|
||||
108
src/components/model-selector.tsx
Normal file
108
src/components/model-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
527
tasks/done/011-agent-model-selectors.md
Normal file
527
tasks/done/011-agent-model-selectors.md
Normal 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
|
||||
Reference in New Issue
Block a user