From caca5c90390aa9175bd309f9a51d74d5344626ae Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:41:10 -0700 Subject: [PATCH 01/25] test: cover pending model catalogs in shared picker --- agent-chat/package.json | 2 +- agent-chat/test/model-picker-loading.test.ts | 56 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 agent-chat/test/model-picker-loading.test.ts diff --git a/agent-chat/package.json b/agent-chat/package.json index 8d33c687988..68fc7cd27ca 100644 --- a/agent-chat/package.json +++ b/agent-chat/package.json @@ -5,7 +5,7 @@ "scripts": { "start": "bun server.ts", "build": "bun run build.ts", - "check": "bun x tsc --noEmit && bun test/claude-environment.test.ts && bun test/model-label.test.ts && bun test/activity.test.ts && bun test/keymap.test.ts && bun test/options-store.test.ts && bun test/options-ui.test.ts && bun test/turns.test.ts && bun test/selection-css.test.ts && bun test/highlight.test.ts" + "check": "bun x tsc --noEmit && bun test/claude-environment.test.ts && bun test/model-label.test.ts && bun test/model-picker-loading.test.ts && bun test/activity.test.ts && bun test/keymap.test.ts && bun test/options-store.test.ts && bun test/options-ui.test.ts && bun test/turns.test.ts && bun test/selection-css.test.ts && bun test/highlight.test.ts" }, "dependencies": { "@base-ui-components/react": "^1.0.0-rc.0", diff --git a/agent-chat/test/model-picker-loading.test.ts b/agent-chat/test/model-picker-loading.test.ts new file mode 100644 index 00000000000..e2973d0b9c8 --- /dev/null +++ b/agent-chat/test/model-picker-loading.test.ts @@ -0,0 +1,56 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { HarnessModelPicker } from "../src/components/StatusRow"; +import type { Provider, SessionOption } from "../src/session"; + +Object.defineProperty(globalThis, "document", { + configurable: true, + value: { documentElement: {} }, +}); +Object.defineProperty(globalThis, "getComputedStyle", { + configurable: true, + value: () => ({ getPropertyValue: () => "" }), +}); + +const provider: Provider = { id: "codex", label: "Codex", installed: true }; + +function renderPicker(running: boolean, options: SessionOption[], loading: boolean): string { + return renderToStaticMarkup(React.createElement(HarnessModelPicker, { + provider: provider.id, + providers: [provider], + options, + allProviderOptions: options.length ? { [provider.id]: options } : {}, + loadingProviderIds: loading ? new Set([provider.id]) : new Set(), + open: false, + onOpenChange: () => {}, + onSelect: () => {}, + running, + })); +} + +for (const [surface, running] of [["task composer", false], ["running provider control", true]] as const) { + const markup = renderPicker(running, [], true); + if (!markup.includes('role="status"') || !markup.includes('aria-label="Loading models"')) { + throw new Error(`${surface}: expected an accessible model-loading indicator, got ${markup}`); + } + if (!markup.includes("pinwheel-spinner")) { + throw new Error(`${surface}: expected the model-loading spinner, got ${markup}`); + } +} + +const loadedOptions: SessionOption[] = [{ + id: "model", + label: "Model", + kind: "select", + value: "gpt-5.6-sol", + choices: [{ value: "gpt-5.6-sol", label: "GPT-5.6 Sol" }], +}]; +const loadedMarkup = renderPicker(false, loadedOptions, false); +if (loadedMarkup.includes('aria-label="Loading models"') || loadedMarkup.includes("pinwheel-spinner")) { + throw new Error(`loaded picker should remove its loading indicator, got ${loadedMarkup}`); +} +if (!loadedMarkup.includes("GPT-5.6 Sol")) { + throw new Error(`loaded picker should show the selected model, got ${loadedMarkup}`); +} + +console.log("model picker loading indicator: OK"); From b3923532319365792498b60bd2846543f660babe Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:59:49 -0700 Subject: [PATCH 02/25] Show model catalog loading in shared picker --- agent-chat/public/app.css | 6 +++ agent-chat/src/components/Chat.tsx | 7 ++- agent-chat/src/components/Composer.tsx | 6 +++ agent-chat/src/components/StatusRow.tsx | 53 +++++++++++++++++--- agent-chat/src/gallery.tsx | 28 ++++++++--- agent-chat/src/hooks/useCatalogs.ts | 11 ++++ agent-chat/test/model-picker-loading.test.ts | 11 ++++ 7 files changed, 107 insertions(+), 15 deletions(-) diff --git a/agent-chat/public/app.css b/agent-chat/public/app.css index 4ab274969a0..56ae48506c8 100644 --- a/agent-chat/public/app.css +++ b/agent-chat/public/app.css @@ -150,6 +150,7 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default .static-provider:hover { background: transparent; color: color-mix(in srgb, var(--fg) 70%, var(--bg)); } .select-trigger[data-popup-open] .chev { transform: rotate(180deg); } .row-control .chev { display: inline-flex; opacity: .55; margin-left: 1px; } +.model-picker-trigger-loading { display: inline-flex; color: var(--text-faint); } .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--text-faint); box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 12%, transparent); @@ -242,6 +243,11 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default flex: 1; min-height: 0; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 1px; mask-image: linear-gradient(to bottom, transparent 0, #000 10px, #000 calc(100% - 10px), transparent 100%); } +.model-picker-loading { + flex: none; display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 34px; + color: var(--text-faint); border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + font: 400 calc(var(--font-size-base) - 2px) var(--font-sans); +} .model-row { width: 100%; min-height: 42px; display: flex; align-items: center; gap: 9px; border: 0; border-radius: 8px; background: transparent; color: var(--text-dim); padding: 6px 8px; text-align: left; cursor: default; outline: none; diff --git a/agent-chat/src/components/Chat.tsx b/agent-chat/src/components/Chat.tsx index d4bff46a071..fb14b3cc3c5 100644 --- a/agent-chat/src/components/Chat.tsx +++ b/agent-chat/src/components/Chat.tsx @@ -9,7 +9,7 @@ import { StatusRow } from "./StatusRow"; import { Blocks } from "./Transcript"; import { ShortcutOverlay, useKeymap } from "../hooks/useKeymap"; import { useAutoGrow } from "../hooks/useAutoGrow"; -import { providerOptionMap, useFileCatalog, useProviderCatalogs, withFileTrigger } from "../hooks/useCatalogs"; +import { loadingProviderOptionIds, providerOptionMap, useFileCatalog, useProviderCatalogs, withFileTrigger } from "../hooks/useCatalogs"; function usePersistSessionOptions(provider: string | undefined, options: SessionOption[], skip = false) { useEffect(() => { @@ -66,6 +66,10 @@ export function Chat() { const commandGroups = useMemo(() => withFileTrigger(commands, filesByCwd[cwd] ?? []), [commands, cwd, filesByCwd]); const commandMenu = useCommandMenu(text, setText, commandGroups, taRef, ctrlJ); const allProviderOptions = providerOptionMap(providers, providerOptions, capabilities); + const loadingProviderIds = useMemo( + () => loadingProviderOptionIds(providers, providerOptions), + [providerOptions, providers], + ); const running = session?.status === "running"; useRestoreModelScopedOptions({ provider: session?.provider, options, setOption, pendingModelRestoreRef }); @@ -157,6 +161,7 @@ export function Chat() { provider={session?.provider ?? "agent"} providers={providers} allProviderOptions={allProviderOptions} + loadingProviderIds={loadingProviderIds} onProviderModelChange={switchHarnessModel} cwd={session?.cwd ?? ""} options={options} diff --git a/agent-chat/src/components/Composer.tsx b/agent-chat/src/components/Composer.tsx index 20660ad0def..91efb66d6fd 100644 --- a/agent-chat/src/components/Composer.tsx +++ b/agent-chat/src/components/Composer.tsx @@ -9,6 +9,7 @@ import { sanitizeStartOptions, withLocalValues } from "./options"; import { ShortcutOverlay, useKeymap } from "../hooks/useKeymap"; import { useAutoGrow } from "../hooks/useAutoGrow"; import { + loadingProviderOptionIds, providerOptionMap, useCwdErrorFallback, useCwdValidation, @@ -57,6 +58,10 @@ export function Composer() { const taRef = useAutoGrow(prompt, 300); const baseOptions = providerOptions[provider]?.length ? providerOptions[provider] : capabilities[provider]?.options ?? []; const allProviderOptions = providerOptionMap(providers, providerOptions, capabilities); + const loadingProviderIds = useMemo( + () => loadingProviderOptionIds(providers, providerOptions), + [providerOptions, providers], + ); const startOptions = startOptionsByProvider[provider] ?? {}; const options = withLocalValues(baseOptions, startOptions); const commandGroups = useMemo(() => withFileTrigger(providerCommands[provider] ?? [], filesByCwd[committedCwd] ?? []), [committedCwd, filesByCwd, provider, providerCommands]); @@ -152,6 +157,7 @@ export function Composer() { provider={provider} providers={providers} allProviderOptions={allProviderOptions} + loadingProviderIds={loadingProviderIds} onProviderModelChange={changeProviderModel} cwd={cwd} onCwdChange={changeCwd} diff --git a/agent-chat/src/components/StatusRow.tsx b/agent-chat/src/components/StatusRow.tsx index 801a7aece42..932080c71f9 100644 --- a/agent-chat/src/components/StatusRow.tsx +++ b/agent-chat/src/components/StatusRow.tsx @@ -2,7 +2,7 @@ import { Popover } from "@base-ui-components/react/popover"; import { useCallback, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type RefObject, type SetStateAction } from "react"; import { menuActionForKey } from "../keymap"; import type { OptionValue, Provider, SessionOption } from "../session"; -import { BarsIcon, BoltIcon, Check, Chevron, EllipsisIcon, FolderIcon, PlanIcon, ProviderIcon, SearchIcon, ShieldIcon, SparkIcon, basename } from "./icons"; +import { BarsIcon, BoltIcon, Check, Chevron, EllipsisIcon, FolderIcon, PinwheelSpinner, PlanIcon, ProviderIcon, SearchIcon, ShieldIcon, SparkIcon, basename } from "./icons"; import { CmdkMenu, type CmdkGroup } from "./CmdkMenu"; import { HintTooltip } from "./Tooltips"; import { currentChoice, cycleSelect, effortFill, isOffLikeValue, optionAction, optionTooltip, prettyValue, visibleChoices } from "./options"; @@ -213,10 +213,28 @@ interface PickerModelItem { search: string; } -function providerModelItems(p: Provider, currentProvider: string, options: SessionOption[]): PickerModelItem[] { +const modelLoadingMessages = { + en: "Loading models", + ja: "モデルを読み込み中", +} as const; +const noLoadingProviderIds: ReadonlySet = new Set(); + +function modelLoadingLabel(): string { + const browserNavigator = typeof navigator === "undefined" ? undefined : navigator; + const languages = browserNavigator?.languages?.length + ? browserNavigator.languages + : browserNavigator?.language ? [browserNavigator.language] : []; + const supportedLanguage = languages.find((language) => /^(en|ja)(-|$)/i.test(language)); + return supportedLanguage?.toLowerCase().startsWith("ja") + ? modelLoadingMessages.ja + : modelLoadingMessages.en; +} + +function providerModelItems(p: Provider, currentProvider: string, options: SessionOption[], isLoading: boolean): PickerModelItem[] { const model = modelOption(options); const choices = model?.choices?.length ? model.choices : []; if (!choices.length) { + if (isLoading) return []; return [{ id: `${p.id}:default`, provider: p, @@ -262,6 +280,7 @@ export function HarnessModelPicker({ providers, options, allProviderOptions, + loadingProviderIds, open, onOpenChange, onSelect, @@ -272,6 +291,7 @@ export function HarnessModelPicker({ providers: Provider[]; options: SessionOption[]; allProviderOptions: Record; + loadingProviderIds: ReadonlySet; open: boolean; onOpenChange: (open: boolean) => void; onSelect: (provider: string, model: string) => void; @@ -283,6 +303,8 @@ export function HarnessModelPicker({ const currentProvider = providers.find((p) => p.id === provider) ?? { id: provider, label: provider }; const currentModel = modelOption(options); const label = currentChoice(currentModel)?.label ?? String(currentModel?.value || currentProvider.label); + const loadingLabel = modelLoadingLabel(); + const isCurrentProviderLoading = loadingProviderIds.has(provider); const [railProvider, setRailProvider] = useState(provider); const [query, setQuery] = useState(initialQuery); const searchRef = useRef(null); @@ -294,8 +316,8 @@ export function HarnessModelPicker({ const activeProvider = installed.find((p) => p.id === railProvider) ?? installed.find((p) => p.id === provider) ?? installed[0]; const providerItems = useMemo(() => new Map(installed.map((p) => { const opts = p.id === provider ? options : (allProviderOptions[p.id] ?? []); - return [p.id, providerModelItems(p, provider, opts)]; - })), [allProviderOptions, installed, options, provider]); + return [p.id, providerModelItems(p, provider, opts, loadingProviderIds.has(p.id))]; + })), [allProviderOptions, installed, loadingProviderIds, options, provider]); const q = query.trim(); const listItems = useMemo(() => { if (q) { @@ -308,6 +330,9 @@ export function HarnessModelPicker({ return activeProvider ? providerItems.get(activeProvider.id) ?? [] : []; }, [activeProvider, providerItems, q]); const [activeIndex, setActiveIndex] = useBoundedActiveIndex(open, `${q}:${activeProvider?.id ?? ""}:${listItems.map((i) => i.id).join("|")}`, listItems.length); + const isListLoading = q + ? installed.some((candidate) => loadingProviderIds.has(candidate.id)) + : Boolean(activeProvider && loadingProviderIds.has(activeProvider.id)); useLayoutEffect(() => { if (!open) return; const id = listItems[activeIndex]?.id; @@ -360,6 +385,11 @@ export function HarnessModelPicker({ ); @@ -443,7 +473,13 @@ export function HarnessModelPicker({ spellCheck={false} /> -
+ {isListLoading ? ( +
+ + {loadingLabel} +
+ ) : null} +
{listItems.length ? listItems.map((item, i) => { const row = (
@@ -490,6 +526,7 @@ export function StatusRow({ provider, providers, allProviderOptions, + loadingProviderIds, onProviderModelChange, cwd, onCwdChange, @@ -504,6 +541,7 @@ export function StatusRow({ provider: string; providers?: Provider[]; allProviderOptions?: Record; + loadingProviderIds?: ReadonlySet; onProviderModelChange?: (provider: string, model: string) => void; cwd: string; onCwdChange?: (v: string) => void; @@ -532,6 +570,7 @@ export function StatusRow({ providers={providers} options={options} allProviderOptions={allProviderOptions ?? {}} + loadingProviderIds={loadingProviderIds ?? noLoadingProviderIds} open={openOptionId === "modelPicker"} onOpenChange={(open) => setOpenOptionId(open ? "modelPicker" : null)} onSelect={onProviderModelChange} diff --git a/agent-chat/src/gallery.tsx b/agent-chat/src/gallery.tsx index e42aa8defe7..12dd31d2e4b 100644 --- a/agent-chat/src/gallery.tsx +++ b/agent-chat/src/gallery.tsx @@ -20,6 +20,8 @@ import { import { groupTurns } from "./turns"; const cwd = "/Users/lawrence/fun/cmuxterm-hq/worktrees/feat-agent-chat-ui/agent-chat"; +const noLoadingProviders = new Set(); +const codexModelsLoading = new Set(["codex"]); function setOption(options: SessionOption[], id: string, value: OptionValue): SessionOption[] { return options.map((o) => o.id === id ? { ...o, value } : o); @@ -37,18 +39,21 @@ function Section({ id, title, children }: { id: string; title: string; children: function DemoStatusRow({ provider, running = false, + modelsLoading = false, }: { provider: string; running?: boolean; + modelsLoading?: boolean; }) { - const [options, setOptions] = useState(() => galleryOptions[provider] ?? []); + const [options, setOptions] = useState(() => modelsLoading ? [] : galleryOptions[provider] ?? []); const [openOptionId, setOpenOptionId] = useState(null); return (
console.log("gallery model", nextProvider, model)} cwd={cwd} options={options} @@ -61,8 +66,11 @@ function DemoStatusRow({ ); } -function ComposerMock({ state }: { state: "idle" | "starting" | "draft" | "error" }) { +function ComposerMock({ state }: { state: "idle" | "starting" | "draft" | "error" | "loading-models" }) { const provider = state === "error" ? "claude" : "codex"; + const modelsLoading = state === "loading-models"; + const options = modelsLoading ? [] : galleryOptions[provider]; + const [openOptionId, setOpenOptionId] = useState(null); return (
@@ -78,13 +86,14 @@ function ComposerMock({ state }: { state: "idle" | "starting" | "draft" | "error console.log("gallery composer pick", p, model)} cwd={cwd} - options={galleryOptions[provider]} + options={options} onChange={(id, value) => console.log("gallery composer option", id, value)} - openOptionId={null} - setOpenOptionId={() => {}} + openOptionId={openOptionId} + setOpenOptionId={setOpenOptionId} trailing={(
))} +
+
Codex models loading, running
+ +
@@ -247,6 +260,7 @@ export function GalleryApp() {
Idle
Draft
+
Models loading
Invalid cwd error
diff --git a/agent-chat/src/hooks/useCatalogs.ts b/agent-chat/src/hooks/useCatalogs.ts index cd5de2087f4..cb95107d1dc 100644 --- a/agent-chat/src/hooks/useCatalogs.ts +++ b/agent-chat/src/hooks/useCatalogs.ts @@ -69,6 +69,17 @@ export function providerOptionMap(providers: Provider[], providerOptions: Record ])); } +export function loadingProviderOptionIds( + providers: Provider[], + providerOptions: Record, +): ReadonlySet { + return new Set( + providers + .filter((provider) => provider.installed !== false && !Object.hasOwn(providerOptions, provider.id)) + .map((provider) => provider.id), + ); +} + export function useCwdValidation( ready: boolean, connectionEpoch: number, diff --git a/agent-chat/test/model-picker-loading.test.ts b/agent-chat/test/model-picker-loading.test.ts index e2973d0b9c8..85d5c8c0db2 100644 --- a/agent-chat/test/model-picker-loading.test.ts +++ b/agent-chat/test/model-picker-loading.test.ts @@ -1,6 +1,7 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { HarnessModelPicker } from "../src/components/StatusRow"; +import { loadingProviderOptionIds } from "../src/hooks/useCatalogs"; import type { Provider, SessionOption } from "../src/session"; Object.defineProperty(globalThis, "document", { @@ -13,6 +14,16 @@ Object.defineProperty(globalThis, "getComputedStyle", { }); const provider: Provider = { id: "codex", label: "Codex", installed: true }; +const unavailableProvider: Provider = { id: "missing", label: "Missing", installed: false }; + +const pendingProviderIds = loadingProviderOptionIds([provider, unavailableProvider], {}); +if (!pendingProviderIds.has(provider.id) || pendingProviderIds.has(unavailableProvider.id)) { + throw new Error(`only installed providers without a response should be loading, got ${[...pendingProviderIds]}`); +} +const emptyResponseProviderIds = loadingProviderOptionIds([provider], { [provider.id]: [] }); +if (emptyResponseProviderIds.has(provider.id)) { + throw new Error("an empty model response should be treated as loaded"); +} function renderPicker(running: boolean, options: SessionOption[], loading: boolean): string { return renderToStaticMarkup(React.createElement(HarnessModelPicker, { From f6c0fef6d7ec83773f3e591535a49d94991c0dc6 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:21:38 -0700 Subject: [PATCH 03/25] test: expose buried model loading state --- agent-chat/test/model-picker-loading.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/agent-chat/test/model-picker-loading.test.ts b/agent-chat/test/model-picker-loading.test.ts index 85d5c8c0db2..6a330160e2f 100644 --- a/agent-chat/test/model-picker-loading.test.ts +++ b/agent-chat/test/model-picker-loading.test.ts @@ -1,6 +1,6 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { HarnessModelPicker } from "../src/components/StatusRow"; +import { HarnessModelPicker, providerModelItemsForState } from "../src/components/StatusRow"; import { loadingProviderOptionIds } from "../src/hooks/useCatalogs"; import type { Provider, SessionOption } from "../src/session"; @@ -49,6 +49,18 @@ for (const [surface, running] of [["task composer", false], ["running provider c } } +const fallbackOptions: SessionOption[] = [{ + id: "model", + label: "Model", + kind: "select", + value: "fallback-model", + choices: [{ value: "fallback-model", label: "Fallback model" }], +}]; +const pendingItems = providerModelItemsForState(provider, provider.id, fallbackOptions, true); +if (pendingItems.length) { + throw new Error(`pending picker should not bury its loading state under fallback model rows, got ${JSON.stringify(pendingItems)}`); +} + const loadedOptions: SessionOption[] = [{ id: "model", label: "Model", From 9ac7d34a149b00f73182c3e2133ad94a3b098351 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:22:42 -0700 Subject: [PATCH 04/25] Make pending model picker unmistakable --- agent-chat/public/app.css | 20 +++++++++++++++++--- agent-chat/src/components/StatusRow.tsx | 23 +++++++++++++++-------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/agent-chat/public/app.css b/agent-chat/public/app.css index 56ae48506c8..6e2c1289ffc 100644 --- a/agent-chat/public/app.css +++ b/agent-chat/public/app.css @@ -150,7 +150,10 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default .static-provider:hover { background: transparent; color: color-mix(in srgb, var(--fg) 70%, var(--bg)); } .select-trigger[data-popup-open] .chev { transform: rotate(180deg); } .row-control .chev { display: inline-flex; opacity: .55; margin-left: 1px; } -.model-picker-trigger-loading { display: inline-flex; color: var(--text-faint); } +.model-picker-trigger-loading { + width: 18px; height: 18px; flex: none; display: inline-flex; align-items: center; justify-content: center; + color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); border-radius: 999px; +} .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--text-faint); box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 12%, transparent); @@ -219,6 +222,7 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default } .model-picker-rail-top, .model-picker-rail-bottom { display: flex; flex-direction: column; gap: 4px; align-items: center; } .rail-btn { + position: relative; width: 30px; height: 30px; border: 0; border-radius: 8px; background: transparent; color: var(--text-faint); display: inline-flex; align-items: center; justify-content: center; padding: 0; cursor: default; } @@ -226,6 +230,10 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default .rail-btn.active { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent); } .rail-btn.missing { opacity: .36; } .rail-btn.missing:hover { opacity: .7; } +.rail-loading-indicator { + position: absolute; right: 1px; bottom: 1px; width: 12px; height: 12px; display: inline-flex; align-items: center; justify-content: center; + color: var(--accent); background: var(--bg-raised); border-radius: 999px; box-shadow: 0 0 0 1px var(--bg-raised); +} .model-picker-main { min-width: 0; flex: 1; display: flex; flex-direction: column; border-left: 1px solid var(--border); @@ -245,8 +253,14 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default } .model-picker-loading { flex: none; display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 34px; - color: var(--text-faint); border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); - font: 400 calc(var(--font-size-base) - 2px) var(--font-sans); + color: var(--text-dim); background: color-mix(in srgb, var(--accent) 5%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + font: 500 calc(var(--font-size-base) - 1px) var(--font-sans); +} +.model-picker-loading .pinwheel-spinner { color: var(--accent); } +.model-picker-loading-full { + flex: 1; min-height: 0; flex-direction: column; gap: 11px; border-bottom: 0; + background: radial-gradient(circle at center, color-mix(in srgb, var(--accent) 9%, transparent), transparent 58%); } .model-row { width: 100%; min-height: 42px; display: flex; align-items: center; gap: 9px; border: 0; border-radius: 8px; diff --git a/agent-chat/src/components/StatusRow.tsx b/agent-chat/src/components/StatusRow.tsx index 932080c71f9..a9865c72e8c 100644 --- a/agent-chat/src/components/StatusRow.tsx +++ b/agent-chat/src/components/StatusRow.tsx @@ -230,11 +230,11 @@ function modelLoadingLabel(): string { : modelLoadingMessages.en; } -function providerModelItems(p: Provider, currentProvider: string, options: SessionOption[], isLoading: boolean): PickerModelItem[] { +export function providerModelItemsForState(p: Provider, currentProvider: string, options: SessionOption[], isLoading: boolean): PickerModelItem[] { + if (isLoading) return []; const model = modelOption(options); const choices = model?.choices?.length ? model.choices : []; if (!choices.length) { - if (isLoading) return []; return [{ id: `${p.id}:default`, provider: p, @@ -316,7 +316,7 @@ export function HarnessModelPicker({ const activeProvider = installed.find((p) => p.id === railProvider) ?? installed.find((p) => p.id === provider) ?? installed[0]; const providerItems = useMemo(() => new Map(installed.map((p) => { const opts = p.id === provider ? options : (allProviderOptions[p.id] ?? []); - return [p.id, providerModelItems(p, provider, opts, loadingProviderIds.has(p.id))]; + return [p.id, providerModelItemsForState(p, provider, opts, loadingProviderIds.has(p.id))]; })), [allProviderOptions, installed, loadingProviderIds, options, provider]); const q = query.trim(); const listItems = useMemo(() => { @@ -428,11 +428,16 @@ export function HarnessModelPicker({ type="button" role="tab" className={"rail-btn" + (p.id === activeProvider?.id ? " active" : "")} - aria-label={p.label} + aria-label={loadingProviderIds.has(p.id) ? `${p.label}: ${loadingLabel}` : p.label} aria-selected={p.id === activeProvider?.id} onClick={() => setRailProvider(p.id)} > + {loadingProviderIds.has(p.id) ? ( + + ) : null} ))} @@ -474,12 +479,13 @@ export function HarnessModelPicker({ />
{isListLoading ? ( -
- +
+ {loadingLabel}
) : null} -
+ {!isListLoading || listItems.length ? ( +
{listItems.length ? listItems.map((item, i) => { const row = (
) : null} -
+
+ ) : null} From b680bd1d37a160bf82e679a46eae853afbe97b08 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:29:19 -0700 Subject: [PATCH 05/25] test: require model-specific effort picker --- agent-chat/test/model-picker-loading.test.ts | 63 +++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/agent-chat/test/model-picker-loading.test.ts b/agent-chat/test/model-picker-loading.test.ts index 6a330160e2f..6f9f2a122fd 100644 --- a/agent-chat/test/model-picker-loading.test.ts +++ b/agent-chat/test/model-picker-loading.test.ts @@ -1,6 +1,7 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { HarnessModelPicker, providerModelItemsForState } from "../src/components/StatusRow"; +import { HarnessModelPicker, StatusRow, providerModelItemsForState } from "../src/components/StatusRow"; +import { withLocalValues } from "../src/components/options"; import { loadingProviderOptionIds } from "../src/hooks/useCatalogs"; import type { Provider, SessionOption } from "../src/session"; @@ -61,6 +62,66 @@ if (pendingItems.length) { throw new Error(`pending picker should not bury its loading state under fallback model rows, got ${JSON.stringify(pendingItems)}`); } +const modelSpecificOptions = [{ + id: "model", + label: "Model", + kind: "select", + value: "fast-model", + choices: [ + { + value: "fast-model", + label: "Fast model", + efforts: [{ value: "low", label: "Low" }], + defaultEffort: "low", + }, + { + value: "deep-model", + label: "Deep model", + efforts: [{ value: "high", label: "High" }, { value: "xhigh", label: "Extra high" }], + defaultEffort: "high", + }, + { value: "no-effort-model", label: "No effort model" }, + ], +}, { + id: "effort", + label: "Effort", + kind: "select", + role: "effort", + value: "low", + choices: [{ value: "low", label: "Low" }], +}] as SessionOption[]; + +const deepModelOptions = withLocalValues(modelSpecificOptions, { model: "deep-model", effort: "low" }); +const deepEffort = deepModelOptions.find((option) => option.role === "effort"); +if (deepEffort?.value !== "high" || deepEffort.choices?.map((choice) => choice.value).join(",") !== "high,xhigh") { + throw new Error(`effort picker should use the selected model's reported choices and default, got ${JSON.stringify(deepEffort)}`); +} +const noEffortOptions = withLocalValues(modelSpecificOptions, { model: "no-effort-model" }); +if (noEffortOptions.some((option) => option.role === "effort")) { + throw new Error(`model without reported effort metadata should not inherit provider-wide choices, got ${JSON.stringify(noEffortOptions)}`); +} + +const orderedStatusMarkup = renderToStaticMarkup(React.createElement(StatusRow, { + provider: provider.id, + providers: [provider], + allProviderOptions: { [provider.id]: modelSpecificOptions }, + loadingProviderIds: new Set(), + onProviderModelChange: () => {}, + cwd: "/tmp", + options: [ + ...modelSpecificOptions, + { id: "fastMode", label: "Fast", kind: "toggle", value: false }, + ], + onChange: () => {}, + openOptionId: null, + setOpenOptionId: () => {}, +})); +const effortIndex = orderedStatusMarkup.indexOf('aria-label="Effort"'); +const fastIndex = orderedStatusMarkup.indexOf('aria-label="Fast"'); +if (effortIndex < 0 || fastIndex < 0 || effortIndex > fastIndex) { + throw new Error(`effort picker should appear immediately after the model picker and before fast mode, got ${orderedStatusMarkup}`); +} + const loadedOptions: SessionOption[] = [{ id: "model", label: "Model", From 0c2263aa17cc62c555139edd2fd99086abc7b200 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:38:38 -0700 Subject: [PATCH 06/25] Use model-specific effort metadata --- agent-chat/adapters/claude.ts | 81 ++++++++++++----- agent-chat/adapters/codex.ts | 28 +++--- agent-chat/adapters/pi.ts | 73 +++++++++------ agent-chat/public/app.css | 32 ++++++- agent-chat/server.ts | 27 +++++- agent-chat/src/components/Chat.tsx | 15 ++-- agent-chat/src/components/StatusRow.tsx | 94 +++++++++++++------- agent-chat/src/components/options.ts | 40 ++++++++- agent-chat/src/gallery-fixtures.ts | 14 +-- agent-chat/src/session.ts | 10 ++- agent-chat/test/catalog.test.ts | 2 + agent-chat/test/model-picker-loading.test.ts | 4 +- agent-chat/types.ts | 2 + 13 files changed, 305 insertions(+), 117 deletions(-) diff --git a/agent-chat/adapters/claude.ts b/agent-chat/adapters/claude.ts index dff96adb8d8..58e6c53b5ca 100644 --- a/agent-chat/adapters/claude.ts +++ b/agent-chat/adapters/claude.ts @@ -18,8 +18,6 @@ const THINKING_CHOICES: OptionChoice[] = [ { value: "16384", label: "16k thinking" }, { value: "32768", label: "32k thinking" }, ]; -const EFFORT_CHOICES: OptionChoice[] = ["low", "medium", "high", "xhigh", "max"] - .map((value) => ({ value, label: value })); const CONTEXT_CHOICES: OptionChoice[] = [ { value: "200k", label: "200k" }, { value: "1m", label: "1M" }, @@ -48,7 +46,7 @@ export function claudeIndependentLaunchEnvironment( return launchEnvironment; } -function curatedClaudeModels(): Array<{ slug: string; label: string; description?: string; minVersion?: string; context?: boolean; fast?: boolean; deprecated?: boolean }> { +function curatedClaudeModels(): Array<{ slug: string; label: string; description?: string; minVersion?: string; context?: boolean; fast?: boolean; deprecated?: boolean; efforts?: OptionChoice[]; defaultEffort?: string }> { const remote = agentModelCatalog.provider("claude"); if (remote) return remote.models.map((model) => ({ slug: model.id, @@ -58,6 +56,8 @@ function curatedClaudeModels(): Array<{ slug: string; label: string; description context: model.supportsOneMillion === true, fast: model.fast, deprecated: model.deprecated === true, + efforts: model.efforts?.map((effort) => ({ value: effort.value, label: effort.label, description: effort.description })), + defaultEffort: model.defaultEffort, })); return agentModelCatalog.hasPayload ? [] : BUILT_IN_MODELS; } @@ -68,6 +68,7 @@ function defaultClaudeModel(): string { let claudeVersionCache: { value: string | null; fetchedAt: number; promise?: Promise } | null = null; interface ClaudeModelMeta { efforts: OptionChoice[]; + defaultEffort: string; supportsFastMode: boolean; context?: { base: string; extended: string }; } @@ -97,7 +98,7 @@ export const claudeAdapter: Adapter = { { id: "model", label: "Model", kind: "select", value: DEFAULT_CLAUDE_MODEL, choices: [{ value: DEFAULT_CLAUDE_MODEL, label: "Claude Sonnet 5" }], disabled: true, description: "Loads at start" }, { id: "permissionMode", label: "Mode", kind: "select", value: "acceptEdits", choices: PERMISSION_CHOICES }, { id: "thinking", label: "Thinking", kind: "select", value: "0", role: "thinking-budget", choices: THINKING_CHOICES }, - { id: "effort", label: "Effort", kind: "select", value: "medium", role: "effort", choices: EFFORT_CHOICES }, + { id: "effort", label: "Effort", kind: "select", value: "", role: "effort", choices: [], disabled: true, description: "Loads with model" }, { id: "fastMode", label: "Fast", kind: "toggle", value: false }, ], }, @@ -145,7 +146,7 @@ export const claudeAdapter: Adapter = { modelMeta: choices.meta, permissionMode: "acceptEdits", thinking: "0", - effort: "medium", + effort: "", fastMode: false, context: "200k", }; @@ -162,15 +163,26 @@ export const claudeAdapter: Adapter = { function state(sess: SessionCtx): ClaudeState { let st = sess.internal.claude as ClaudeState | undefined; if (!st) { + const seededModel = seededClaudeDefault(sess); + const seededChoices = sess.seedOptions?.find((option) => option.id === "model")?.choices; + const initialChoices = seededChoices?.length ? seededChoices : [{ value: seededModel, label: seededModel }]; + const initialMeta = new Map(); + for (const choice of initialChoices) { + initialMeta.set(choice.value, { + efforts: choice.efforts ?? [], + defaultEffort: validDefaultEffort(choice.efforts ?? [], choice.defaultEffort), + supportsFastMode: false, + }); + } st = { nextRequest: 1, pending: new Map(), model: normalizeStartModel(stringOption(sess, "model", seededClaudeDefault(sess))), - modelChoices: [{ value: seededClaudeDefault(sess), label: seededClaudeDefault(sess) }], - modelMeta: new Map([[seededClaudeDefault(sess), { efforts: EFFORT_CHOICES, supportsFastMode: false }]]), + modelChoices: initialChoices, + modelMeta: initialMeta, permissionMode: stringOption(sess, "permissionMode", sess.autoApprove ? "acceptEdits" : "default"), thinking: stringOption(sess, "thinking", "0"), - effort: stringOption(sess, "effort", "medium"), + effort: stringOption(sess, "effort", ""), fastMode: booleanOption(sess, "fastMode", false), context: stringOption(sess, "context", "200k"), initialApplied: false, @@ -231,7 +243,7 @@ function ensureProc(sess: SessionCtx): Bun.Subprocess<"pipe", "pipe", "pipe"> { if (fork?.providerSessionId) args.push("--resume", fork.providerSessionId, "--fork-session"); if (st.permissionMode !== "default") args.push("--permission-mode", st.permissionMode); if (sess.autoApprove) args.push("--allowedTools", "Bash Read Edit Write Glob Grep WebFetch WebSearch"); - if (typeof sess.startOptions.effort === "string") args.push("--effort", st.effort); + if (typeof sess.startOptions.effort === "string" && st.effort) args.push("--effort", st.effort); const proc = Bun.spawn(["claude", ...args], { cwd: sess.cwd, @@ -338,7 +350,7 @@ async function applyInitialOptions(sess: SessionCtx) { if (typeof sess.startOptions.thinking === "string" || st.thinking !== "0") { await control(sess, "set_max_thinking_tokens", { max_thinking_tokens: Number(st.thinking) || 0 }); } - if (typeof sess.startOptions.effort === "string" || st.effort !== "medium") { + if (st.effort) { await control(sess, "apply_flag_settings", { settings: { effortLevel: st.effort } }); } if (typeof sess.startOptions.fastMode === "boolean" || st.fastMode) { @@ -357,7 +369,7 @@ async function setClaudeOption(sess: SessionCtx, id: string, value: OptionValue) const model = resolveClaudeModelId(st); await control(sess, "set_model", { model }); const changed = normalizeEffort(st); - if (changed.effort) await control(sess, "apply_flag_settings", { settings: { effortLevel: st.effort } }); + if (changed.effort && st.effort) await control(sess, "apply_flag_settings", { settings: { effortLevel: st.effort } }); if (changed.fastMode) await control(sess, "apply_flag_settings", { settings: { fastMode: st.fastMode } }); break; } @@ -383,6 +395,9 @@ async function setClaudeOption(sess: SessionCtx, id: string, value: OptionValue) } case "effort": { if (typeof value !== "string") throw new Error("effort must be a string"); + if (!modelMeta(st).efforts.some((choice) => choice.value === value)) { + throw new Error(`unsupported effort for ${st.model}: ${value}`); + } await control(sess, "apply_flag_settings", { settings: { effortLevel: value } }); st.effort = value; break; @@ -419,6 +434,15 @@ function seedModelChoices(sess: SessionCtx, st: ClaudeState): boolean { const seeded = sess.seedOptions?.find((o) => o.id === "model")?.choices; if (!seeded || seeded.length <= 1 || st.modelChoices.length > 1) return false; st.modelChoices = seeded; + for (const choice of seeded) { + const current = st.modelMeta.get(choice.value); + st.modelMeta.set(choice.value, { + efforts: choice.efforts ?? [], + defaultEffort: validDefaultEffort(choice.efforts ?? [], choice.defaultEffort), + supportsFastMode: current?.supportsFastMode ?? false, + ...(current?.context ? { context: current.context } : {}), + }); + } return true; } @@ -440,14 +464,14 @@ function buildOptions(st: Pick): ClaudeModelMeta { - return st.modelMeta.get(st.model) ?? st.modelMeta.get(defaultClaudeModel()) ?? { efforts: EFFORT_CHOICES, supportsFastMode: false }; + return st.modelMeta.get(st.model) ?? { efforts: [], defaultEffort: "", supportsFastMode: false }; } function normalizeEffort(st: Pick): { effort: boolean; fastMode: boolean } { const meta = modelMeta(st); const beforeEffort = st.effort; const beforeFast = st.fastMode; - if (!meta.efforts.some((c) => c.value === st.effort)) st.effort = meta.efforts[0]?.value ?? "medium"; + if (!meta.efforts.some((c) => c.value === st.effort)) st.effort = validDefaultEffort(meta.efforts, meta.defaultEffort); if (!meta.supportsFastMode) st.fastMode = false; return { effort: st.effort !== beforeEffort, fastMode: st.fastMode !== beforeFast }; } @@ -496,10 +520,11 @@ function normalizeModelCatalog(models: any, version: string | null): { choices: supportsFastMode: m.supportsFastMode === true, efforts: Array.isArray(m.supportedEffortLevels) && m.supportedEffortLevels.length ? m.supportedEffortLevels.map((effort: unknown) => ({ value: String(effort), label: String(effort) })) - : EFFORT_CHOICES, + : [], + defaultEffort: typeof m.defaultEffortLevel === "string" ? m.defaultEffortLevel : "", }, }; - }).filter(Boolean) as Array<{ rawValue: string; value: string; base: string; suffix: string; label: string; description?: string; meta: { supportsFastMode: boolean; efforts: OptionChoice[] } }>; + }).filter(Boolean) as Array<{ rawValue: string; value: string; base: string; suffix: string; label: string; description?: string; meta: { supportsFastMode: boolean; efforts: OptionChoice[]; defaultEffort: string } }>; const rawByBase = new Map(); const extendedByBase = new Map(); for (const m of raw) if (!m.suffix && !rawByBase.has(m.base)) rawByBase.set(m.base, m); @@ -508,15 +533,17 @@ function normalizeModelCatalog(models: any, version: string | null): { choices: const disabledReason = model.minVersion && version && !versionAtLeast(version, model.minVersion) ? claudeUpgradeMessage(model.slug, model.label, model.minVersion, version) : model.deprecated ? `${model.label} is deprecated.` : undefined; - choices.push({ value: model.slug, label: model.label, description: model.description, disabled: Boolean(disabledReason), disabledReason }); - covered.add(model.slug); - covered.add(aliasClaudeModel(stripOneMillion(model.slug).base)); const extended = extendedByBase.get(model.slug)?.value ?? `${model.slug}[1m]`; const context = model.context || extendedByBase.has(model.slug) ? { base: model.slug, extended } : undefined; const binary = rawByBase.get(model.slug); - meta.set(model.slug, { efforts: binary?.meta.efforts ?? EFFORT_CHOICES, supportsFastMode: model.fast ?? binary?.meta.supportsFastMode ?? false, ...(context ? { context } : {}) }); + const efforts = model.efforts ?? binary?.meta.efforts ?? []; + const defaultEffort = validDefaultEffort(efforts, model.defaultEffort ?? binary?.meta.defaultEffort); + choices.push({ value: model.slug, label: model.label, description: model.description, disabled: Boolean(disabledReason), disabledReason, efforts, defaultEffort }); + covered.add(model.slug); + covered.add(aliasClaudeModel(stripOneMillion(model.slug).base)); + meta.set(model.slug, { efforts, defaultEffort, supportsFastMode: model.fast ?? binary?.meta.supportsFastMode ?? false, ...(context ? { context } : {}) }); } for (const m of raw) { if (covered.has(m.base)) { @@ -527,17 +554,25 @@ function normalizeModelCatalog(models: any, version: string | null): { choices: if (m.suffix && rawByBase.has(m.base)) continue; if (extendedByBase.has(m.base)) { const extended = extendedByBase.get(m.base)!; - meta.set(m.base, { efforts: m.meta.efforts, supportsFastMode: m.meta.supportsFastMode, context: { base: m.base, extended: extended.value } }); - choices.push({ value: m.base, label: m.label, description: m.description }); + const defaultEffort = validDefaultEffort(m.meta.efforts, m.meta.defaultEffort); + meta.set(m.base, { efforts: m.meta.efforts, defaultEffort, supportsFastMode: m.meta.supportsFastMode, context: { base: m.base, extended: extended.value } }); + choices.push({ value: m.base, label: m.label, description: m.description, efforts: m.meta.efforts, defaultEffort }); } else { - meta.set(m.base, { efforts: m.meta.efforts, supportsFastMode: m.meta.supportsFastMode }); - choices.push({ value: m.base, label: m.label, description: m.description }); + const defaultEffort = validDefaultEffort(m.meta.efforts, m.meta.defaultEffort); + meta.set(m.base, { efforts: m.meta.efforts, defaultEffort, supportsFastMode: m.meta.supportsFastMode }); + choices.push({ value: m.base, label: m.label, description: m.description, efforts: m.meta.efforts, defaultEffort }); } covered.add(m.base); } return { choices: dedupeChoices(choices), meta }; } +function validDefaultEffort(efforts: OptionChoice[], requested?: string): string { + return requested && efforts.some((effort) => effort.value === requested) + ? requested + : efforts[0]?.value ?? ""; +} + async function fetchClaudeVersion(): Promise { const now = Date.now(); if (claudeVersionCache?.promise) return claudeVersionCache.promise; diff --git a/agent-chat/adapters/codex.ts b/agent-chat/adapters/codex.ts index b17611dd1b1..e64ce741135 100644 --- a/agent-chat/adapters/codex.ts +++ b/agent-chat/adapters/codex.ts @@ -46,7 +46,6 @@ interface CodexState { let shared: AppServer | null = null; let sharedStarting: Promise | null = null; -const FALLBACK_EFFORTS: OptionChoice[] = ["low", "medium", "high", "xhigh"].map((value) => ({ value, label: value })); const APPROVAL_CHOICES: OptionChoice[] = [ { value: "untrusted", label: "Untrusted" }, { value: "on-request", label: "On request" }, @@ -64,7 +63,7 @@ export const codexAdapter: Adapter = { triggers: ["$"], options: [ { id: "model", label: "Model", kind: "select", value: "", disabled: true, description: "Loads at start" }, - { id: "effort", label: "Effort", kind: "select", value: "medium", role: "effort", choices: FALLBACK_EFFORTS }, + { id: "effort", label: "Effort", kind: "select", value: "", role: "effort", choices: [], disabled: true, description: "Loads with model" }, { id: "approvals", label: "Approvals", kind: "select", value: "never", choices: APPROVAL_CHOICES }, { id: "sandbox", label: "Sandbox", kind: "select", value: "workspace-write", choices: SANDBOX_CHOICES }, { id: "mode", label: "Mode", kind: "select", value: "default", choices: [{ value: "default", label: "Default" }, { value: "plan", label: "Plan" }] }, @@ -460,7 +459,7 @@ function defaultState(autoApprove: boolean): CodexState { models: [], modes: [{ value: "default", label: "Default" }, { value: "plan", label: "Plan" }], model: "", - effort: "medium", + effort: "", approvals: autoApprove ? "never" : "on-request", sandbox: autoApprove ? "workspace-write" : "read-only", fastMode: false, @@ -554,6 +553,9 @@ async function setCodexOption(sess: SessionCtx, id: string, value: OptionValue) break; case "effort": if (typeof value !== "string") throw new Error("effort must be a string"); + if (!effortForModel(st).choices.some((choice) => choice.value === value)) { + throw new Error(`unsupported effort for ${st.model}: ${value}`); + } st.effort = value; break; case "fastMode": @@ -603,7 +605,13 @@ function buildOptions(st: CodexState): SessionOption[] { label: "Model", kind: "select", value: st.model, - choices: st.models.map((m) => ({ value: m.value, label: m.label, description: m.description })), + choices: st.models.map((m) => ({ + value: m.value, + label: m.label, + description: m.description, + efforts: m.efforts, + defaultEffort: m.defaultEffort, + })), disabled: !st.models.length, }, { id: "effort", label: "Effort", kind: "select", value: st.effort, role: "effort", choices: effort.choices }, @@ -638,9 +646,9 @@ export function mergeCodexModels(binaryModels: ModelInfo[], remote = agentModelC const remoteEfforts = (model.efforts ?? []) .map((effort) => ({ value: effort.value, label: effort.label, description: effort.description })) .filter((effort) => !isOffLike(effort.value)); - const efforts = remoteEfforts.length ? remoteEfforts : reported?.efforts ?? FALLBACK_EFFORTS; + const efforts = model.efforts ? remoteEfforts : reported?.efforts ?? []; const requestedEffort = model.defaultEffort ?? reported?.defaultEffort ?? ""; - const defaultEffort = efforts.some((effort) => effort.value === requestedEffort) ? requestedEffort : efforts[0]?.value ?? "medium"; + const defaultEffort = efforts.some((effort) => effort.value === requestedEffort) ? requestedEffort : efforts[0]?.value ?? ""; return { value: model.id, label: model.label, @@ -663,13 +671,13 @@ function normalizeModel(m: any): ModelInfo { label: String(e.reasoningEffort ?? e), description: e.description ? String(e.description) : undefined, })).filter((e: OptionChoice) => !isOffLike(e.value)) - : FALLBACK_EFFORTS; + : []; return { value: String(m.model ?? m.id), label: prettifyModelLabel(String(m.displayName ?? m.model ?? m.id)), description: m.description ? String(m.description) : undefined, efforts, - defaultEffort: String(m.defaultReasoningEffort ?? efforts[0]?.value ?? "medium"), + defaultEffort: String(m.defaultReasoningEffort ?? efforts[0]?.value ?? ""), serviceTiers: (m.serviceTiers ?? []).map((t: any) => ({ id: String(t.id), name: String(t.name ?? t.id), @@ -722,10 +730,10 @@ function selectedModel(st: CodexState): ModelInfo | undefined { function effortForModel(st: CodexState): { value: string; choices: OptionChoice[] } { const m = selectedModel(st); - const choices = m?.efforts.length ? m.efforts : FALLBACK_EFFORTS; + const choices = m?.efforts ?? []; const value = choices.some((c) => c.value === st.effort) ? st.effort - : (m?.defaultEffort && choices.some((c) => c.value === m.defaultEffort) ? m.defaultEffort : choices[0]?.value ?? "medium"); + : (m?.defaultEffort && choices.some((c) => c.value === m.defaultEffort) ? m.defaultEffort : choices[0]?.value ?? ""); return { value, choices }; } diff --git a/agent-chat/adapters/pi.ts b/agent-chat/adapters/pi.ts index d25be0b7bfa..b91a2b672c1 100644 --- a/agent-chat/adapters/pi.ts +++ b/agent-chat/adapters/pi.ts @@ -2,9 +2,6 @@ import type { Adapter, CommandEntry, OptionChoice, OptionValue, SessionCtx, Sess import { readLines, tryParse, truncate } from "./lines"; import { prettifyProviderModelLabel } from "./model-label"; -const THINKING_CHOICES: OptionChoice[] = ["minimal", "low", "medium", "high", "xhigh"] - .map((value) => ({ value, label: value })); - interface PiState { proc?: Bun.Subprocess<"pipe", "pipe", "pipe">; nextId: number; @@ -12,7 +9,6 @@ interface PiState { model: string; modelChoices: OptionChoice[]; thinking: string; - thinkingNormalized: boolean; sessionFile?: string; commands: CommandEntry[]; initialApplied: boolean; @@ -25,7 +21,7 @@ export const piAdapter: Adapter = { triggers: ["/"], options: [ { id: "model", label: "Model", kind: "select", value: "", disabled: true, description: "Loads at start" }, - { id: "thinking", label: "Thinking", kind: "select", value: "minimal", role: "effort", choices: THINKING_CHOICES }, + { id: "thinking", label: "Thinking", kind: "select", value: "", role: "effort", choices: [], disabled: true, description: "Loads with model" }, ], }, async send(sess, prompt, generation?: number) { @@ -66,7 +62,7 @@ export const piAdapter: Adapter = { return buildOptions({ model: models[0]?.value ?? "", modelChoices: models, - thinking: "minimal", + thinking: "", }); }, async listCommands(cwd) { @@ -90,8 +86,7 @@ function state(sess: SessionCtx): PiState { pending: new Map(), model: typeof sess.startOptions.model === "string" ? sess.startOptions.model : "", modelChoices: [], - thinking: typeof sess.startOptions.thinking === "string" ? sess.startOptions.thinking : "off", - thinkingNormalized: false, + thinking: typeof sess.startOptions.thinking === "string" ? sess.startOptions.thinking : "", sessionFile: typeof sess.internal.piSessionFile === "string" ? sess.internal.piSessionFile : undefined, commands: [], initialApplied: false, @@ -185,14 +180,20 @@ async function setPiOption(sess: SessionCtx, id: string, value: OptionValue) { const modelId = value.slice(slash + 1); await request(sess, { type: "set_model", provider, modelId }); st.model = value; + const thinking = thinkingForModel(st); + if (!thinking.choices.some((choice) => choice.value === st.thinking)) { + st.thinking = thinking.value; + if (st.thinking) await request(sess, { type: "set_thinking_level", level: st.thinking }); + } break; } case "thinking": if (typeof value !== "string") throw new Error("thinking must be a string"); - value = normalizeThinking(value); + if (!thinkingForModel(st).choices.some((choice) => choice.value === value)) { + throw new Error(`unsupported thinking level for ${st.model}: ${value}`); + } await request(sess, { type: "set_thinking_level", level: value }); st.thinking = value; - st.thinkingNormalized = true; break; default: throw new Error(`unsupported pi option: ${id}`); @@ -207,10 +208,10 @@ async function refreshPi(sess: SessionCtx) { const models = await request(sess, { type: "get_available_models" }); st.modelChoices = normalizeModels(models?.models ?? models?.data?.models); if (!st.model) st.model = st.modelChoices[0]?.value ?? ""; - if (!st.thinkingNormalized && isOffLike(st.thinking)) { - await request(sess, { type: "set_thinking_level", level: "minimal" }); - st.thinking = "minimal"; - st.thinkingNormalized = true; + const thinking = thinkingForModel(st); + if (!thinking.choices.some((choice) => choice.value === st.thinking)) { + st.thinking = thinking.value; + if (st.thinking) await request(sess, { type: "set_thinking_level", level: st.thinking }); } emitOptions(sess); const commands = await request(sess, { type: "get_commands" }); @@ -244,18 +245,22 @@ async function captureState(sess: SessionCtx) { } function buildOptions(st: Pick): SessionOption[] { + const thinking = thinkingForModel(st); return [ { id: "model", label: "Model", kind: "select", value: st.model, choices: st.modelChoices, disabled: !st.modelChoices.length }, - { id: "thinking", label: "Thinking", kind: "select", value: normalizeThinking(st.thinking), role: "effort", choices: THINKING_CHOICES }, + { id: "thinking", label: "Thinking", kind: "select", value: thinking.value, role: "effort", choices: thinking.choices }, ]; } -function normalizeThinking(value: string): string { - return isOffLike(value) ? "minimal" : value; -} - -function isOffLike(value: string): boolean { - return /^(off|none)$/i.test(value); +function thinkingForModel(st: Pick): { value: string; choices: OptionChoice[] } { + const model = st.modelChoices.find((choice) => choice.value === st.model); + const choices = model?.efforts ?? []; + const value = choices.some((choice) => choice.value === st.thinking) + ? st.thinking + : choices.some((choice) => choice.value === model?.defaultEffort) + ? model!.defaultEffort! + : choices[0]?.value ?? ""; + return { value, choices }; } function finishTurn(sess: SessionCtx) { @@ -368,11 +373,27 @@ export function piNextSendTypeForTest(sess: SessionCtx): "prompt" | "steer" { function normalizeModels(models: any): OptionChoice[] { if (!Array.isArray(models)) return []; - return models.map((m) => ({ - value: `${m.provider}/${m.id}`, - label: prettifyProviderModelLabel(String(m.provider), String(m.id), m.name ? String(m.name) : undefined), - description: m.reasoning ? "supports thinking" : undefined, - })); + return models.map((m) => { + const reportedEfforts = m.supportedThinkingLevels + ?? m.supportedReasoningEfforts + ?? m.supportedEffortLevels + ?? m.efforts; + const efforts: OptionChoice[] = Array.isArray(reportedEfforts) + ? reportedEfforts.flatMap((entry: unknown) => { + const raw = typeof entry === "string" ? entry : String((entry as any)?.value ?? (entry as any)?.level ?? ""); + if (!raw || /^(off|none)$/i.test(raw)) return []; + return [{ value: raw, label: typeof entry === "object" && entry ? String((entry as any).label ?? raw) : raw }]; + }) + : []; + const requested = String(m.defaultThinkingLevel ?? m.defaultReasoningEffort ?? m.defaultEffort ?? ""); + return { + value: `${m.provider}/${m.id}`, + label: prettifyProviderModelLabel(String(m.provider), String(m.id), m.name ? String(m.name) : undefined), + description: m.reasoning ? "supports thinking" : undefined, + efforts, + defaultEffort: efforts.some((effort) => effort.value === requested) ? requested : efforts[0]?.value, + }; + }); } function normalizeCommands(commands: any): CommandEntry[] { diff --git a/agent-chat/public/app.css b/agent-chat/public/app.css index 6e2c1289ffc..336928ade8d 100644 --- a/agent-chat/public/app.css +++ b/agent-chat/public/app.css @@ -151,9 +151,23 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default .select-trigger[data-popup-open] .chev { transform: rotate(180deg); } .row-control .chev { display: inline-flex; opacity: .55; margin-left: 1px; } .model-picker-trigger-loading { - width: 18px; height: 18px; flex: none; display: inline-flex; align-items: center; justify-content: center; + width: 0; height: 18px; flex: none; display: inline-flex; align-items: center; justify-content: center; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); border-radius: 999px; + opacity: 0; transform: scale(.72); overflow: hidden; visibility: hidden; + transition: width 110ms ease-out, opacity 90ms ease-out, transform 110ms ease-out, visibility 0s linear 110ms; } +.model-picker-trigger-loading[data-visible="true"] { + width: 18px; opacity: 1; transform: scale(1); visibility: visible; + transition-delay: 80ms; + transition-timing-function: cubic-bezier(.16, 1, .3, 1); +} +.effort-picker-loading { + height: 24px; max-width: 150px; display: inline-flex; align-items: center; gap: 6px; padding: 0 7px; + color: var(--text-dim); background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 6px; + font: 500 calc(var(--font-size-base) - 3px) var(--font-sans); + animation: loadingControlIn 130ms 80ms cubic-bezier(.16, 1, .3, 1) both; +} +@keyframes loadingControlIn { from { opacity: 0; transform: translateY(1px) scale(.96); } } .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--text-faint); box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 12%, transparent); @@ -235,7 +249,7 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default color: var(--accent); background: var(--bg-raised); border-radius: 999px; box-shadow: 0 0 0 1px var(--bg-raised); } .model-picker-main { - min-width: 0; flex: 1; display: flex; flex-direction: column; + position: relative; min-width: 0; flex: 1; display: flex; flex-direction: column; border-left: 1px solid var(--border); } .model-picker-search { @@ -259,8 +273,15 @@ button, [role="button"], .menu-item, .model-row, .row-control { cursor: default } .model-picker-loading .pinwheel-spinner { color: var(--accent); } .model-picker-loading-full { - flex: 1; min-height: 0; flex-direction: column; gap: 11px; border-bottom: 0; + position: absolute; inset: 39px 0 0; z-index: 2; min-height: 0; flex-direction: column; gap: 11px; border-bottom: 0; background: radial-gradient(circle at center, color-mix(in srgb, var(--accent) 9%, transparent), transparent 58%); + opacity: 0; transform: scale(.985); visibility: hidden; pointer-events: none; + transition: opacity 110ms ease-out, transform 130ms ease-out, visibility 0s linear 130ms; +} +.model-picker-loading-full[data-visible="true"] { + opacity: 1; transform: scale(1); visibility: visible; + transition-delay: 80ms; + transition-timing-function: cubic-bezier(.16, 1, .3, 1); } .model-row { width: 100%; min-height: 42px; display: flex; align-items: center; gap: 9px; border: 0; border-radius: 8px; @@ -652,9 +673,12 @@ details.thinking .t-body { white-space: pre-wrap; font-size: calc(var(--font-siz .activity-label { animation: none; opacity: .68; } .provider-running .provider-icon, .provider-running .provider-icon-img, - .pinwheel-spinner { + .pinwheel-spinner, + .effort-picker-loading { animation: none; } + .model-picker-trigger-loading, + .model-picker-loading-full { transition: none; } .disclosure-motion, .activity-caret-icon { transition: none !important; diff --git a/agent-chat/server.ts b/agent-chat/server.ts index 81601e22cfc..7cf56c7ff37 100644 --- a/agent-chat/server.ts +++ b/agent-chat/server.ts @@ -615,12 +615,19 @@ async function sanitizeStartOptions(provider: string, cwd: string, raw: Record, catalog: SessionOption[]): Record { const byId = new Map(catalog.map((o) => [o.id, o])); + const modelOption = byId.get("model"); + const requestedModel = typeof raw.model === "string" + ? modelOption?.choices?.find((choice) => choice.value === raw.model && !choice.disabled) + : undefined; const out: Record = {}; for (const [id, value] of Object.entries(raw)) { const option = byId.get(id); if (!option) continue; if (option.kind === "toggle" && typeof value === "boolean") out[id] = value; - if (option.kind === "select" && typeof value === "string" && option.choices?.some((c) => c.value === value && !c.disabled)) out[id] = value; + const choices = option.role === "effort" && requestedModel + ? requestedModel.efforts ?? [] + : option.choices ?? []; + if (option.kind === "select" && typeof value === "string" && choices.some((c) => c.value === value && !c.disabled)) out[id] = value; } return out; } @@ -636,7 +643,23 @@ function mergeRemoteModelOptions(provider: string, options: SessionOption[], rem const choices: import("./types").OptionChoice[] = remote.models.map((entry) => { const reported = binary.get(entry.id); binary.delete(entry.id); - return { ...reported, value: entry.id, label: entry.label, description: entry.description ?? reported?.description }; + const efforts = entry.efforts?.map((effort) => ({ + value: effort.value, + label: effort.label, + description: effort.description, + })); + return { + ...reported, + value: entry.id, + label: entry.label, + description: entry.description ?? reported?.description, + ...(efforts?.length ? { + efforts, + defaultEffort: efforts.some((effort) => effort.value === entry.defaultEffort) + ? entry.defaultEffort + : efforts[0]!.value, + } : {}), + }; }); choices.push(...binary.values()); const current = typeof model?.value === "string" ? choices.find((choice) => choice.value === model.value && !choice.disabled) : undefined; diff --git a/agent-chat/src/components/Chat.tsx b/agent-chat/src/components/Chat.tsx index fb14b3cc3c5..b4e1c2064af 100644 --- a/agent-chat/src/components/Chat.tsx +++ b/agent-chat/src/components/Chat.tsx @@ -4,7 +4,7 @@ import { readStoredProviderOptions, persistOptionsSnapshot, updateStoredProvider import type { OptionValue, SessionOption } from "../session"; import { ArrowUp } from "./icons"; import { isCtrlJ, insertNewlineAtCaret, useCommandMenu } from "./CommandMenu"; -import { optionAcceptsValue } from "./options"; +import { optionAcceptsValue, optionsForSelectedModel } from "./options"; import { StatusRow } from "./StatusRow"; import { Blocks } from "./Transcript"; import { ShortcutOverlay, useKeymap } from "../hooks/useKeymap"; @@ -71,14 +71,15 @@ export function Chat() { [providerOptions, providers], ); const running = session?.status === "running"; + const resolvedOptions = useMemo(() => optionsForSelectedModel(options), [options]); - useRestoreModelScopedOptions({ provider: session?.provider, options, setOption, pendingModelRestoreRef }); - usePersistSessionOptions(session?.provider, options, pendingModelRestoreRef.current !== null); + useRestoreModelScopedOptions({ provider: session?.provider, options: resolvedOptions, setOption, pendingModelRestoreRef }); + usePersistSessionOptions(session?.provider, resolvedOptions, pendingModelRestoreRef.current !== null); useProviderCatalogs(ready, connectionEpoch, providers, session?.provider ?? "", cwd, requestProviderOptions, requestProviderCommands); useFileCatalog(ready, connectionEpoch, cwd, requestFiles); useStickToBottom(scrollRef, stickRef, blocks, running); useKeymap({ - options, + options: resolvedOptions, setOption, running, stop, @@ -109,7 +110,7 @@ export function Chat() { if (!session) return; if (provider === session.provider) { if (model) { - updateStoredProviderOption(provider, "model", model, options); + updateStoredProviderOption(provider, "model", model, resolvedOptions); pendingModelRestoreRef.current = model; setOption("model", model); } @@ -164,7 +165,7 @@ export function Chat() { loadingProviderIds={loadingProviderIds} onProviderModelChange={switchHarnessModel} cwd={session?.cwd ?? ""} - options={options} + options={resolvedOptions} onChange={setOption} openOptionId={openOptionId} setOpenOptionId={setOpenOptionId} @@ -180,7 +181,7 @@ export function Chat() { /> - {helpOpen ? setHelpOpen(false)} /> : null} + {helpOpen ? setHelpOpen(false)} /> : null} ); } diff --git a/agent-chat/src/components/StatusRow.tsx b/agent-chat/src/components/StatusRow.tsx index a9865c72e8c..4276a6360ec 100644 --- a/agent-chat/src/components/StatusRow.tsx +++ b/agent-chat/src/components/StatusRow.tsx @@ -5,7 +5,7 @@ import type { OptionValue, Provider, SessionOption } from "../session"; import { BarsIcon, BoltIcon, Check, Chevron, EllipsisIcon, FolderIcon, PinwheelSpinner, PlanIcon, ProviderIcon, SearchIcon, ShieldIcon, SparkIcon, basename } from "./icons"; import { CmdkMenu, type CmdkGroup } from "./CmdkMenu"; import { HintTooltip } from "./Tooltips"; -import { currentChoice, cycleSelect, effortFill, isOffLikeValue, optionAction, optionTooltip, prettyValue, visibleChoices } from "./options"; +import { currentChoice, cycleSelect, effortFill, isOffLikeValue, optionAction, optionTooltip, optionsForSelectedModel, prettyValue, visibleChoices } from "./options"; function CwdPopover({ cwd, onChange, onCommit }: { cwd: string; onChange: (v: string) => void; onCommit: (v: string) => void }) { return ( @@ -217,19 +217,26 @@ const modelLoadingMessages = { en: "Loading models", ja: "モデルを読み込み中", } as const; +const effortLoadingMessages = { + en: "Loading effort", + ja: "エフォートを読み込み中", +} as const; const noLoadingProviderIds: ReadonlySet = new Set(); -function modelLoadingLabel(): string { +function localizedLoadingLabel(messages: typeof modelLoadingMessages | typeof effortLoadingMessages): string { const browserNavigator = typeof navigator === "undefined" ? undefined : navigator; const languages = browserNavigator?.languages?.length ? browserNavigator.languages : browserNavigator?.language ? [browserNavigator.language] : []; const supportedLanguage = languages.find((language) => /^(en|ja)(-|$)/i.test(language)); return supportedLanguage?.toLowerCase().startsWith("ja") - ? modelLoadingMessages.ja - : modelLoadingMessages.en; + ? messages.ja + : messages.en; } +function modelLoadingLabel(): string { return localizedLoadingLabel(modelLoadingMessages); } +function effortLoadingLabel(): string { return localizedLoadingLabel(effortLoadingMessages); } + export function providerModelItemsForState(p: Provider, currentProvider: string, options: SessionOption[], isLoading: boolean): PickerModelItem[] { if (isLoading) return []; const model = modelOption(options); @@ -385,11 +392,15 @@ export function HarnessModelPicker({ ); @@ -478,14 +489,23 @@ export function HarnessModelPicker({ spellCheck={false} /> - {isListLoading ? ( -
+ {isListLoading && listItems.length ? ( +
{loadingLabel}
) : null} - {!isListLoading || listItems.length ? ( -
+
+ + {loadingLabel} +
+
{listItems.length ? listItems.map((item, i) => { const row = (
) : null} -
- ) : null} +
@@ -560,12 +579,13 @@ export function StatusRow({ trailing?: ReactNode; running?: boolean; }) { - const effortLike = options.filter((o) => o.role === "effort" && o.kind === "select" && !isOffLikeValue(String(o.value))); - const context = options.find((o) => o.id === "context" && o.kind === "select"); - const fast = options.find((o) => o.id === "fastMode" && o.kind === "toggle"); - const approval = options.find((o) => o.role === "approval" && o.kind === "toggle"); - const mode = options.find((o) => (o.id === "mode" || o.id === "permissionMode") && o.kind === "select"); - const overflow = options.filter((o) => !isInlineOption(o)); + const resolvedOptions = optionsForSelectedModel(options); + const effortLike = resolvedOptions.filter((o) => o.role === "effort" && o.kind === "select" && !isOffLikeValue(String(o.value))); + const context = resolvedOptions.find((o) => o.id === "context" && o.kind === "select"); + const fast = resolvedOptions.find((o) => o.id === "fastMode" && o.kind === "toggle"); + const approval = resolvedOptions.find((o) => o.role === "approval" && o.kind === "toggle"); + const mode = resolvedOptions.find((o) => (o.id === "mode" || o.id === "permissionMode") && o.kind === "select"); + const overflow = resolvedOptions.filter((o) => !isInlineOption(o)); const modeLabel = mode && !["", "default", "build"].includes(String(mode.value)) ? prettyValue(mode) : ""; const providerInfo = providers?.find((p) => p.id === provider) ?? { id: provider, label: provider }; return ( @@ -575,7 +595,7 @@ export function StatusRow({ ) : } + {loadingProviderIds?.has(provider) ? ( + + + {effortLoadingLabel()} + + ) : null} + {effortLike.map((option) => ( + } + choiceIcon={(value) => } + label={`${option.label}: ${prettyValue(option)}`} + onChange={onChange} + open={openOptionId === option.id} + onOpenChange={(open) => setOpenOptionId(open ? option.id : null)} + /> + ))} {fast ? (