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