diff --git a/packages/web-ui/CHANGELOG.md b/packages/web-ui/CHANGELOG.md index 9e9cb658..a58f38b5 100644 --- a/packages/web-ui/CHANGELOG.md +++ b/packages/web-ui/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +### Added + +- `onModelSelect` callback on `AgentInterface` and `ChatPanel.setAgent` config +- `allowedProviders` filter on `ModelSelector.open()` to restrict visible models +- `onClose` callback on `SettingsDialog.open()` +- `state_change` event emitted by Agent on `setModel()` and `setThinkingLevel()` +- Subsequence-based fuzzy search in model selector (replaces substring matching) +- `openai-codex` and `github-copilot` to `shouldUseProxyForProvider` + +### Changed + +- Anthropic test model updated from `claude-3-5-haiku-20241022` to `claude-haiku-4-5` + +### Fixed + +- `AgentInterface` clears streaming container on `message_end` to prevent duplicate tool rendering + ## [0.58.3] - 2026-03-15 ### Fixed diff --git a/packages/web-ui/src/ChatPanel.ts b/packages/web-ui/src/ChatPanel.ts index e055c776..2c76c391 100644 --- a/packages/web-ui/src/ChatPanel.ts +++ b/packages/web-ui/src/ChatPanel.ts @@ -59,6 +59,7 @@ export class ChatPanel extends LitElement { onApiKeyRequired?: (provider: string) => Promise; onBeforeSend?: () => void | Promise; onCostClick?: () => void; + onModelSelect?: () => void; sandboxUrlProvider?: () => string; toolsFactory?: ( agent: Agent, @@ -78,6 +79,7 @@ export class ChatPanel extends LitElement { this.agentInterface.enableThinkingSelector = true; this.agentInterface.showThemeToggle = false; this.agentInterface.onApiKeyRequired = config?.onApiKeyRequired; + this.agentInterface.onModelSelect = config?.onModelSelect; this.agentInterface.onBeforeSend = config?.onBeforeSend; this.agentInterface.onCostClick = config?.onCostClick; diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index fd4872a5..b8f6550e 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -32,6 +32,8 @@ export class AgentInterface extends LitElement { @property({ attribute: false }) onBeforeToolCall?: (toolName: string, args: any) => boolean | Promise; // Optional callback called when cost display is clicked @property({ attribute: false }) onCostClick?: () => void; + // Optional callback to override model selector behavior + @property({ attribute: false }) onModelSelect?: () => void; // References @query("message-editor") private _messageEditor!: MessageEditor; @@ -151,12 +153,19 @@ export class AgentInterface extends LitElement { this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => { switch (ev.type) { case "message_start": - case "message_end": case "turn_start": case "turn_end": case "agent_start": this.requestUpdate(); break; + case "message_end": + // Clear streaming container when a message completes + // to prevent duplicate rendering (stable list now has this message) + if (this._streamingContainer) { + this._streamingContainer.setMessage(null, true); + } + this.requestUpdate(); + break; case "agent_end": // Clear streaming container when agent finishes if (this._streamingContainer) { @@ -364,7 +373,11 @@ export class AgentInterface extends LitElement { }} .onAbort=${() => session.abort()} .onModelSelect=${() => { - ModelSelector.open(state.model, (model) => session.setModel(model)); + if (this.onModelSelect) { + this.onModelSelect(); + } else { + ModelSelector.open(state.model, (model) => session.setModel(model)); + } }} .onThinkingChange=${ this.enableThinkingSelector diff --git a/packages/web-ui/src/components/ProviderKeyInput.ts b/packages/web-ui/src/components/ProviderKeyInput.ts index 011af200..61dac606 100644 --- a/packages/web-ui/src/components/ProviderKeyInput.ts +++ b/packages/web-ui/src/components/ProviderKeyInput.ts @@ -10,7 +10,7 @@ import { Input } from "./Input.js"; // Test models for each provider const TEST_MODELS: Record = { - anthropic: "claude-3-5-haiku-20241022", + anthropic: "claude-haiku-4-5", openai: "gpt-4o-mini", google: "gemini-2.5-flash", groq: "openai/gpt-oss-20b", diff --git a/packages/web-ui/src/dialogs/ModelSelector.ts b/packages/web-ui/src/dialogs/ModelSelector.ts index 2ef59ab9..192c3c53 100644 --- a/packages/web-ui/src/dialogs/ModelSelector.ts +++ b/packages/web-ui/src/dialogs/ModelSelector.ts @@ -15,6 +15,37 @@ import { formatModelCost } from "../utils/format.js"; import { i18n } from "../utils/i18n.js"; import { discoverModels } from "../utils/model-discovery.js"; +/** + * Score a query against a text using subsequence matching. + * All query characters must appear in order in the text. + * Higher score = tighter match (fewer gaps between matched characters). + * Returns 0 if no match. + */ +function subsequenceScore(query: string, text: string): number { + let qi = 0; + let ti = 0; + let gaps = 0; + let lastMatchIndex = -1; + + while (qi < query.length && ti < text.length) { + if (query[qi] === text[ti]) { + if (lastMatchIndex >= 0) { + gaps += ti - lastMatchIndex - 1; + } + lastMatchIndex = ti; + qi++; + } + ti++; + } + + // All query chars must match + if (qi < query.length) return 0; + + // Score: longer query match = better, fewer gaps = better + // Normalize so exact substring gets highest score + return query.length / (query.length + gaps); +} + @customElement("agent-model-selector") export class ModelSelector extends DialogBase { @state() currentModel: Model | null = null; @@ -27,16 +58,24 @@ export class ModelSelector extends DialogBase { @state() private customProviderModels: Model[] = []; private onSelectCallback?: (model: Model) => void; + private allowedProviders?: Set; private scrollContainerRef = createRef(); private searchInputRef = createRef(); private lastMousePosition = { x: 0, y: 0 }; protected override modalWidth = "min(400px, 90vw)"; - static async open(currentModel: Model | null, onSelect: (model: Model) => void) { + static async open( + currentModel: Model | null, + onSelect: (model: Model) => void, + allowedProviders?: string[], + ) { const selector = new ModelSelector(); selector.currentModel = currentModel; selector.onSelectCallback = onSelect; + if (allowedProviders) { + selector.allowedProviders = new Set(allowedProviders); + } selector.open(); selector.loadCustomProviders(); } @@ -173,19 +212,30 @@ export class ModelSelector extends DialogBase { allModels.push({ provider: model.provider, id: model.id, model }); } + // Filter by allowed providers if set + if (this.allowedProviders) { + const allowed = this.allowedProviders; + allModels.splice(0, allModels.length, ...allModels.filter(({ provider }) => allowed.has(provider))); + } + // Filter models based on search and capability filters let filteredModels = allModels; - // Apply search filter + // Apply search filter (subsequence match: characters must appear in order) if (this.searchQuery) { - filteredModels = filteredModels.filter(({ provider, id, model }) => { - const searchTokens = this.searchQuery - .toLowerCase() - .split(/\s+/) - .filter((t) => t); - const searchText = `${provider} ${id} ${model.name}`.toLowerCase(); - return searchTokens.every((token) => searchText.includes(token)); - }); + const query = this.searchQuery.toLowerCase().replace(/\s+/g, ""); + if (query) { + const scored: Array<{ item: (typeof allModels)[0]; score: number }> = []; + for (const entry of filteredModels) { + const searchText = `${entry.provider} ${entry.id} ${entry.model.name}`.toLowerCase(); + const score = subsequenceScore(query, searchText); + if (score > 0) { + scored.push({ item: entry, score }); + } + } + scored.sort((a, b) => b.score - a.score); + filteredModels = scored.map((s) => s.item); + } } // Apply capability filters @@ -196,14 +246,18 @@ export class ModelSelector extends DialogBase { filteredModels = filteredModels.filter(({ model }) => model.input.includes("image")); } - // Sort: current model first, then by provider - filteredModels.sort((a, b) => { - const aIsCurrent = modelsAreEqual(this.currentModel, a.model); - const bIsCurrent = modelsAreEqual(this.currentModel, b.model); - if (aIsCurrent && !bIsCurrent) return -1; - if (!aIsCurrent && bIsCurrent) return 1; - return a.provider.localeCompare(b.provider); - }); + // Sort: when not searching, current model first then by provider. + // When searching, preserve the score-based order from above, + // but still float the current model to the top. + if (!this.searchQuery) { + filteredModels.sort((a, b) => { + const aIsCurrent = modelsAreEqual(this.currentModel, a.model); + const bIsCurrent = modelsAreEqual(this.currentModel, b.model); + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + return a.provider.localeCompare(b.provider); + }); + } return filteredModels; } diff --git a/packages/web-ui/src/dialogs/SettingsDialog.ts b/packages/web-ui/src/dialogs/SettingsDialog.ts index 2a3515a3..71620552 100644 --- a/packages/web-ui/src/dialogs/SettingsDialog.ts +++ b/packages/web-ui/src/dialogs/SettingsDialog.ts @@ -122,9 +122,12 @@ export class SettingsDialog extends LitElement { return this; } - static async open(tabs: SettingsTab[]) { + private onCloseCallback?: () => void; + + static async open(tabs: SettingsTab[], onClose?: () => void) { const dialog = new SettingsDialog(); dialog.tabs = tabs; + dialog.onCloseCallback = onClose; dialog.isOpen = true; document.body.appendChild(dialog); } @@ -173,6 +176,7 @@ export class SettingsDialog extends LitElement { onClose: () => { this.isOpen = false; this.remove(); + this.onCloseCallback?.(); }, width: "min(1000px, 90vw)", height: "min(800px, 90vh)", diff --git a/packages/web-ui/src/utils/proxy-utils.ts b/packages/web-ui/src/utils/proxy-utils.ts index 27816b18..602820c9 100644 --- a/packages/web-ui/src/utils/proxy-utils.ts +++ b/packages/web-ui/src/utils/proxy-utils.ts @@ -25,7 +25,11 @@ export function shouldUseProxyForProvider(provider: string, apiKey: string): boo case "anthropic": // Anthropic OAuth tokens (sk-ant-oat-*) require proxy // Regular API keys (sk-ant-api-*) do NOT require proxy - return apiKey.startsWith("sk-ant-oat"); + return apiKey.startsWith("sk-ant-oat") || apiKey.startsWith("{"); + + case "openai-codex": + // Codex uses chatgpt.com/backend-api which has no CORS + return true; // These providers work without proxy case "openai": @@ -36,6 +40,7 @@ export function shouldUseProxyForProvider(provider: string, apiKey: string): boo case "xai": case "ollama": case "lmstudio": + case "github-copilot": return false; // Unknown providers - assume no proxy needed