Skip to content

Commit 83378aa

Browse files
committed
fix(web-ui): add model selector filter, onModelSelect callback, onClose callback, fuzzy search, fix streaming duplicates
1 parent aac0e0c commit 83378aa

File tree

7 files changed

+118
-23
lines changed

7 files changed

+118
-23
lines changed

packages/web-ui/CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- `onModelSelect` callback on `AgentInterface` and `ChatPanel.setAgent` config
8+
- `allowedProviders` filter on `ModelSelector.open()` to restrict visible models
9+
- `onClose` callback on `SettingsDialog.open()`
10+
- `state_change` event emitted by Agent on `setModel()` and `setThinkingLevel()`
11+
- Subsequence-based fuzzy search in model selector (replaces substring matching)
12+
- `openai-codex` and `github-copilot` to `shouldUseProxyForProvider`
13+
14+
### Changed
15+
16+
- Anthropic test model updated from `claude-3-5-haiku-20241022` to `claude-haiku-4-5`
17+
18+
### Fixed
19+
20+
- `AgentInterface` clears streaming container on `message_end` to prevent duplicate tool rendering
21+
522
## [0.58.3] - 2026-03-15
623

724
### Fixed

packages/web-ui/src/ChatPanel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class ChatPanel extends LitElement {
5959
onApiKeyRequired?: (provider: string) => Promise<boolean>;
6060
onBeforeSend?: () => void | Promise<void>;
6161
onCostClick?: () => void;
62+
onModelSelect?: () => void;
6263
sandboxUrlProvider?: () => string;
6364
toolsFactory?: (
6465
agent: Agent,
@@ -78,6 +79,7 @@ export class ChatPanel extends LitElement {
7879
this.agentInterface.enableThinkingSelector = true;
7980
this.agentInterface.showThemeToggle = false;
8081
this.agentInterface.onApiKeyRequired = config?.onApiKeyRequired;
82+
this.agentInterface.onModelSelect = config?.onModelSelect;
8183
this.agentInterface.onBeforeSend = config?.onBeforeSend;
8284
this.agentInterface.onCostClick = config?.onCostClick;
8385

packages/web-ui/src/components/AgentInterface.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export class AgentInterface extends LitElement {
3232
@property({ attribute: false }) onBeforeToolCall?: (toolName: string, args: any) => boolean | Promise<boolean>;
3333
// Optional callback called when cost display is clicked
3434
@property({ attribute: false }) onCostClick?: () => void;
35+
// Optional callback to override model selector behavior
36+
@property({ attribute: false }) onModelSelect?: () => void;
3537

3638
// References
3739
@query("message-editor") private _messageEditor!: MessageEditor;
@@ -151,12 +153,19 @@ export class AgentInterface extends LitElement {
151153
this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => {
152154
switch (ev.type) {
153155
case "message_start":
154-
case "message_end":
155156
case "turn_start":
156157
case "turn_end":
157158
case "agent_start":
158159
this.requestUpdate();
159160
break;
161+
case "message_end":
162+
// Clear streaming container when a message completes
163+
// to prevent duplicate rendering (stable list now has this message)
164+
if (this._streamingContainer) {
165+
this._streamingContainer.setMessage(null, true);
166+
}
167+
this.requestUpdate();
168+
break;
160169
case "agent_end":
161170
// Clear streaming container when agent finishes
162171
if (this._streamingContainer) {
@@ -364,7 +373,11 @@ export class AgentInterface extends LitElement {
364373
}}
365374
.onAbort=${() => session.abort()}
366375
.onModelSelect=${() => {
367-
ModelSelector.open(state.model, (model) => session.setModel(model));
376+
if (this.onModelSelect) {
377+
this.onModelSelect();
378+
} else {
379+
ModelSelector.open(state.model, (model) => session.setModel(model));
380+
}
368381
}}
369382
.onThinkingChange=${
370383
this.enableThinkingSelector

packages/web-ui/src/components/ProviderKeyInput.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Input } from "./Input.js";
1010

1111
// Test models for each provider
1212
const TEST_MODELS: Record<string, string> = {
13-
anthropic: "claude-3-5-haiku-20241022",
13+
anthropic: "claude-haiku-4-5",
1414
openai: "gpt-4o-mini",
1515
google: "gemini-2.5-flash",
1616
groq: "openai/gpt-oss-20b",

packages/web-ui/src/dialogs/ModelSelector.ts

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,37 @@ import { formatModelCost } from "../utils/format.js";
1515
import { i18n } from "../utils/i18n.js";
1616
import { discoverModels } from "../utils/model-discovery.js";
1717

18+
/**
19+
* Score a query against a text using subsequence matching.
20+
* All query characters must appear in order in the text.
21+
* Higher score = tighter match (fewer gaps between matched characters).
22+
* Returns 0 if no match.
23+
*/
24+
function subsequenceScore(query: string, text: string): number {
25+
let qi = 0;
26+
let ti = 0;
27+
let gaps = 0;
28+
let lastMatchIndex = -1;
29+
30+
while (qi < query.length && ti < text.length) {
31+
if (query[qi] === text[ti]) {
32+
if (lastMatchIndex >= 0) {
33+
gaps += ti - lastMatchIndex - 1;
34+
}
35+
lastMatchIndex = ti;
36+
qi++;
37+
}
38+
ti++;
39+
}
40+
41+
// All query chars must match
42+
if (qi < query.length) return 0;
43+
44+
// Score: longer query match = better, fewer gaps = better
45+
// Normalize so exact substring gets highest score
46+
return query.length / (query.length + gaps);
47+
}
48+
1849
@customElement("agent-model-selector")
1950
export class ModelSelector extends DialogBase {
2051
@state() currentModel: Model<any> | null = null;
@@ -27,16 +58,24 @@ export class ModelSelector extends DialogBase {
2758
@state() private customProviderModels: Model<any>[] = [];
2859

2960
private onSelectCallback?: (model: Model<any>) => void;
61+
private allowedProviders?: Set<string>;
3062
private scrollContainerRef = createRef<HTMLDivElement>();
3163
private searchInputRef = createRef<HTMLInputElement>();
3264
private lastMousePosition = { x: 0, y: 0 };
3365

3466
protected override modalWidth = "min(400px, 90vw)";
3567

36-
static async open(currentModel: Model<any> | null, onSelect: (model: Model<any>) => void) {
68+
static async open(
69+
currentModel: Model<any> | null,
70+
onSelect: (model: Model<any>) => void,
71+
allowedProviders?: string[],
72+
) {
3773
const selector = new ModelSelector();
3874
selector.currentModel = currentModel;
3975
selector.onSelectCallback = onSelect;
76+
if (allowedProviders) {
77+
selector.allowedProviders = new Set(allowedProviders);
78+
}
4079
selector.open();
4180
selector.loadCustomProviders();
4281
}
@@ -173,19 +212,30 @@ export class ModelSelector extends DialogBase {
173212
allModels.push({ provider: model.provider, id: model.id, model });
174213
}
175214

215+
// Filter by allowed providers if set
216+
if (this.allowedProviders) {
217+
const allowed = this.allowedProviders;
218+
allModels.splice(0, allModels.length, ...allModels.filter(({ provider }) => allowed.has(provider)));
219+
}
220+
176221
// Filter models based on search and capability filters
177222
let filteredModels = allModels;
178223

179-
// Apply search filter
224+
// Apply search filter (subsequence match: characters must appear in order)
180225
if (this.searchQuery) {
181-
filteredModels = filteredModels.filter(({ provider, id, model }) => {
182-
const searchTokens = this.searchQuery
183-
.toLowerCase()
184-
.split(/\s+/)
185-
.filter((t) => t);
186-
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
187-
return searchTokens.every((token) => searchText.includes(token));
188-
});
226+
const query = this.searchQuery.toLowerCase().replace(/\s+/g, "");
227+
if (query) {
228+
const scored: Array<{ item: (typeof allModels)[0]; score: number }> = [];
229+
for (const entry of filteredModels) {
230+
const searchText = `${entry.provider} ${entry.id} ${entry.model.name}`.toLowerCase();
231+
const score = subsequenceScore(query, searchText);
232+
if (score > 0) {
233+
scored.push({ item: entry, score });
234+
}
235+
}
236+
scored.sort((a, b) => b.score - a.score);
237+
filteredModels = scored.map((s) => s.item);
238+
}
189239
}
190240

191241
// Apply capability filters
@@ -196,14 +246,18 @@ export class ModelSelector extends DialogBase {
196246
filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
197247
}
198248

199-
// Sort: current model first, then by provider
200-
filteredModels.sort((a, b) => {
201-
const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
202-
const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
203-
if (aIsCurrent && !bIsCurrent) return -1;
204-
if (!aIsCurrent && bIsCurrent) return 1;
205-
return a.provider.localeCompare(b.provider);
206-
});
249+
// Sort: when not searching, current model first then by provider.
250+
// When searching, preserve the score-based order from above,
251+
// but still float the current model to the top.
252+
if (!this.searchQuery) {
253+
filteredModels.sort((a, b) => {
254+
const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
255+
const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
256+
if (aIsCurrent && !bIsCurrent) return -1;
257+
if (!aIsCurrent && bIsCurrent) return 1;
258+
return a.provider.localeCompare(b.provider);
259+
});
260+
}
207261

208262
return filteredModels;
209263
}

packages/web-ui/src/dialogs/SettingsDialog.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,12 @@ export class SettingsDialog extends LitElement {
122122
return this;
123123
}
124124

125-
static async open(tabs: SettingsTab[]) {
125+
private onCloseCallback?: () => void;
126+
127+
static async open(tabs: SettingsTab[], onClose?: () => void) {
126128
const dialog = new SettingsDialog();
127129
dialog.tabs = tabs;
130+
dialog.onCloseCallback = onClose;
128131
dialog.isOpen = true;
129132
document.body.appendChild(dialog);
130133
}
@@ -173,6 +176,7 @@ export class SettingsDialog extends LitElement {
173176
onClose: () => {
174177
this.isOpen = false;
175178
this.remove();
179+
this.onCloseCallback?.();
176180
},
177181
width: "min(1000px, 90vw)",
178182
height: "min(800px, 90vh)",

packages/web-ui/src/utils/proxy-utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ export function shouldUseProxyForProvider(provider: string, apiKey: string): boo
2525
case "anthropic":
2626
// Anthropic OAuth tokens (sk-ant-oat-*) require proxy
2727
// Regular API keys (sk-ant-api-*) do NOT require proxy
28-
return apiKey.startsWith("sk-ant-oat");
28+
return apiKey.startsWith("sk-ant-oat") || apiKey.startsWith("{");
29+
30+
case "openai-codex":
31+
// Codex uses chatgpt.com/backend-api which has no CORS
32+
return true;
2933

3034
// These providers work without proxy
3135
case "openai":
@@ -36,6 +40,7 @@ export function shouldUseProxyForProvider(provider: string, apiKey: string): boo
3640
case "xai":
3741
case "ollama":
3842
case "lmstudio":
43+
case "github-copilot":
3944
return false;
4045

4146
// Unknown providers - assume no proxy needed

0 commit comments

Comments
 (0)