From ba94bc73cf40562f01f7341102251da306eaf4ed Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Sat, 10 Oct 2026 22:53:56 +0800 Subject: [PATCH] Improve Ranger settings and chat history --- FEATURES.md | 30 +- docs/DEPLOYMENT.md | 87 +- server/src/assistant/pi-driver.test.ts | 120 +- server/src/assistant/pi-driver.ts | 52 +- server/src/assistant/service.test.ts | 73 +- server/src/assistant/service.ts | 29 +- shared/assistant.ts | 2 + web/src/components/AssistantModelForm.test.ts | 560 +++++- web/src/components/AssistantPanel.css | 457 +++-- web/src/components/AssistantPanel.test.ts | 221 ++- web/src/components/AssistantPanel.tsx | 1609 +++++++++-------- web/src/components/ThemedSelect.tsx | 4 +- 12 files changed, 2122 insertions(+), 1122 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 8ec66822..9ded2fdc 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -183,22 +183,28 @@ The conversation stays available when switching workspaces or connections. - Connect a provider and choose a model through the window's **Settings** button. Use a separate Ranger connection or explicitly reuse the bridge account's - saved Pi credentials with **Shared Pi credentials**. Select a saved provider or - expand **Connect another provider**; both credential sources support sign-in - and API-key entry in the window. **Configure custom models** connects compatible - endpoints by API address, format, key, and model IDs separated by commas or - newlines. Shared mode also reads - Pi's custom models. See [model setup](docs/DEPLOYMENT.md#ranger-model-connection). + saved Pi credentials with **Shared Pi credentials**. Settings is one page with + **Model** and **Workspace access** sections and a single **Save settings** + button, which notes unsaved changes. The searchable **Provider** picker lists + saved credentials first; the selected provider's status and its sign-in or + API-key action appear below it. Under **Custom models**, **Add** opens an + editor for new model IDs (keeping the selected custom endpoint) and **Edit** + opens the selected custom model. Enter IDs separated by commas or newlines and + check the supported efforts from Off, Minimal, Low, Medium, High, Extra high, + and Maximum. Leaving the efforts unchanged preserves existing capabilities. + **Save custom models** returns to Settings; **Back** or **Cancel** abandons + unsaved editor changes. Shared mode also reads Pi's custom models. + See [model setup](docs/DEPLOYMENT.md#ranger-model-connection). - Change the model and **Thinking effort** with the compact selectors below the chat input. Search models across connected providers. Effort choices come from the selected model; **Default** shows its effective level, and models without adjustable thinking show a disabled control. Changes are saved on the bridge and apply to the next message, including when a reply is still streaming. They preserve your draft, conversation, and workspace permissions. -- Allow workspaces in Settings. Each question automatically uses saved - authorized workspaces that are currently available. **Select all** adds the - currently available workspaces; **Clear** removes every selection. Use - **Save connection** to apply permission changes. +- Allow workspaces under **Workspace access** in Settings. Each question + automatically uses saved authorized workspaces that are currently available. + **Select all** adds the currently available workspaces; **Clear** removes every + selection. Use **Save settings** to apply permission changes. Workspace choices use a compact grid that scrolls when the list is long. No workspaces are authorized by default. In normal mode, new workspaces need to be selected separately. Confirmed deleted workspaces are removed from the @@ -258,8 +264,8 @@ The conversation stays available when switching workspaces or connections. - Closing the window hides it while work continues; the topbar indicator shows activity. **Stop** cancels the current turn. **New chat** saves the current conversation and starts an empty one, keeping the model connection and workspace - permissions. **History** lists saved conversations by their first question; - select one to read its messages and continue chatting. Switching chats retires + permissions. **History** opens a full-panel list of saved conversations by their + first question; select one to read its messages and continue chatting. Switching chats retires unconfirmed action previews. - The wave bar marks messages and highlights the visible portion of the conversation. It sits below the messages on narrow panels and mobile, and on diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 2acc39ef..bf584be9 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -387,16 +387,18 @@ roamgate --host 0.0.0.0 --port 8443 \ ## Ranger model connection Open the global **Ranger** window and its **Settings** button. Choose -**Ranger connection** or **Shared Pi credentials**, select a provider, and use the available **Sign in** -or **Enter API key** action. Complete the provider's web/device-code or manual -authorization prompt in the window, choose a default model, select allowed -workspaces, and **Save connection**. Each question automatically uses saved +**Ranger connection** or **Shared Pi credentials**, select a provider in the +**Provider** picker, and use the available **Sign in** or **Enter API key** +action. Complete the provider's web/device-code or manual authorization prompt +in the window, choose a default model, select allowed workspaces under +**Workspace access**, and **Save settings**. Each question automatically uses saved authorized workspaces that are currently available; no workspaces are authorized by default. -**High-permission mode** is a separate Settings control, off by default. Enabling -it requires an explicit confirmation and authorizes all currently discoverable -workspaces, including new workspaces that appear while the mode is enabled, +**High-permission mode** is a separate switch under **Workspace access**, off by +default. Enabling it requires an explicit confirmation and authorizes all +currently discoverable workspaces, including new workspaces that appear while +the mode is enabled, without individual selection. Workspace content may be sent to the selected model provider. Ranger can then execute its supported workspace, worktree, tab, pane, agent and prompt operations, and create schedules, without @@ -422,42 +424,47 @@ concrete scopes are bounded to 512 workspaces. If an inventory is truncated, Ranger will not silently treat the partial result as all workspaces. Large scopes can take longer to validate and send more workspace context to the model. -For a compatible endpoint, expand **Configure custom models** and enter a unique -provider ID, API format, API base URL, model IDs, and API key. Enter one model ID -per line or separate IDs with commas, up to 100 IDs of 500 characters each. +For a compatible endpoint, select **Add** under **Custom models**. The editor +shows **Models** (model IDs and supported efforts) and **Endpoint** (provider +ID, API format, API base URL, and API key) together. When a custom provider is +selected, **Add** keeps its endpoint and starts with no model IDs, so new IDs +join that provider; enter a different provider ID for a new endpoint. Enter one +model ID per line or separate IDs with commas, up to 100 IDs of 500 characters +each. Duplicate IDs are saved once. Supported formats are OpenAI Chat Completions, -OpenAI Responses, and Anthropic Messages. Use a -dummy key for an unauthenticated local server. **Save custom models** saves the -whole batch and selects the first ID; then select allowed workspaces and -**Save connection**. Select a custom provider and model to **Edit custom models**; -a blank key keeps its existing key. Stop running tasks before changing a model -connection. Existing models outside the batch keep their settings. +OpenAI Responses, and Anthropic Messages. Use a dummy key for an unauthenticated +local server. **Save custom models** saves the whole batch and selects the first +ID; then select allowed workspaces and +**Save settings**. Select a custom provider and model and choose **Edit** to +change that model. +**Save custom models** returns to Settings after saving; **Back** or **Cancel** +abandons unsaved editor changes. A blank key keeps its existing key. +Stop running tasks before changing a model connection. Existing models outside +the batch keep their settings. Use a separate provider ID for a different endpoint so existing providers keep their connection settings. -Custom model IDs do not declare reasoning support automatically. For a model -whose endpoint supports thinking, choose **Enable reasoning for all IDs** under -**Reasoning capability** and save the custom models. Existing models can be -edited in place; no new provider or API key is needed. The default **Keep -existing / model defaults** preserves each model's declaration, while new IDs -remain non-reasoning. An explicit enable or disable applies to every entered -ID, so use separate batches for models with different capabilities. Existing -Pi thinking-level maps and compatibility settings are preserved. Select the -desired **Thinking effort** and save the connection, or use the chat selector. -Pi determines the available levels and translates the selected effort into the -chosen API format; declaring support does not verify a third-party endpoint's -capabilities. Without a custom thinking-level map, reasoning models expose -Off, Minimal, Low, Medium, and High; imported maps can restrict or extend these. - -**Provider** lists OAuth logins and API keys from the selected credential -source. Click a saved provider to select it, or expand **Connect another provider** -to choose one without saved credentials and log in from the window. **Refresh** -reads changes made outside the window. These labels report stored -credentials, not a successful live connection test; credentials are checked or -refreshed when used. -**Search providers** filters saved and other providers by name or ID, ignoring -case. Matching providers without saved credentials expand automatically; -filtering does not change the selected provider. +Under **Supported thinking efforts**, select the levels your endpoint supports: +Off, Minimal, Low, Medium, High, Extra high, and Maximum. New models start with +Off selected. Existing models show their saved supported levels; leaving the +checklist unchanged preserves each model's capabilities and Pi thinking-level +maps. Changing the checklist applies the selected levels to every entered ID, +so save separate batches for models with different capabilities. At least one +level is required. Existing Pi compatibility settings are preserved. + +You determine which levels a third-party endpoint supports; saving a selection +does not verify those capabilities. Pi translates the selected effort into the +chosen API format. Select the desired **Thinking effort** and **Save settings**, +or use the chat selector. Existing reasoning models without an explicit level +list or Pi thinking-level map expose Off, Minimal, Low, Medium, and High. + +The **Provider** picker lists providers with saved OAuth logins and API keys +from the selected credential source first, each with its credential status, +followed by providers without saved credentials. Type to search by name or ID. +Select a provider to choose it or log in from the window. The refresh button +next to **Provider** reads changes made outside the window. These labels report +stored credentials, not a successful live connection test; credentials are +checked or refreshed when used. Ranger runs as the Roamgate bridge's OS account, including when the browser is on a phone or a workspace uses SSH. Provider credentials therefore belong on @@ -520,7 +527,7 @@ The catalog uses credentials saved in Pi's `auth.json` and compatible endpoints in Pi's `models.json`. Custom model changes in shared mode update that file. **Refresh** rereads both files. Environment-only credentials for built-in providers are not imported. Model-file keys may use Pi's environment or command -syntax; entering a key in **Configure custom model** accepts literal keys only. +syntax; entering a key in the custom model editor accepts literal keys only. Pi's extensions, skills, project instructions, and built-in write/command tools are not loaded into Ranger. diff --git a/server/src/assistant/pi-driver.test.ts b/server/src/assistant/pi-driver.test.ts index a153deba..b1bae20d 100644 --- a/server/src/assistant/pi-driver.test.ts +++ b/server/src/assistant/pi-driver.test.ts @@ -18,6 +18,7 @@ import { ASSISTANT_MAX_TOOL_ARGUMENTS, ASSISTANT_MAX_TOOL_OUTPUT, ASSISTANT_MAX_TOOL_DETAILS, + ASSISTANT_THINKING_LEVELS, type AssistantMessage, } from "../../../shared/assistant"; import { type AssistantDriver, createPiDriver } from "./pi-driver"; @@ -1315,6 +1316,93 @@ test("SQLite pauses a committed partial without aborting the durable submission } }, 3000); +test("explicit custom thinking efforts override imported restrictions and preserve provider translations", async () => { + const directory = mkdtempSync(join(tmpdir(), "roamgate-custom-efforts-")); + const path = join(directory, "models.json"); + const saved = { + providers: { + custom: { + api: "openai-responses", + baseUrl: "http://127.0.0.1:1234/v1", + apiKey: "synthetic-test-key", + models: [ + { + id: "reasoner", + reasoning: true, + thinkingLevelMap: { off: null, minimal: "low", high: "default" }, + contextWindow: 8192, + }, + { id: "untouched", reasoning: false }, + ], + modelOverrides: { + reasoner: { + reasoning: false, + thinkingLevelMap: { high: "provider-high", max: null }, + maxTokens: 2048, + }, + }, + }, + }, + }; + writeFileSync(path, JSON.stringify(saved)); + const driver = createPiDriver(directory); + const save = (path: string, value: unknown) => + writeFileSync(path, JSON.stringify(value)); + const connection = { + credential_source: "assistant" as const, + provider: "custom", + model: "reasoner", + base_url: saved.providers.custom.baseUrl, + api: "openai-responses" as const, + }; + try { + await driver.configureModel!( + { + ...connection, + models: ["reasoner", "new-reasoner"], + thinking_levels: ["minimal", "high", "max"], + }, + save, + ); + const updated = JSON.parse(readFileSync(path, "utf8")); + const expectedMap = { + off: null, + minimal: "low", + low: null, + medium: null, + high: "provider-high", + xhigh: null, + max: "max", + }; + expect(updated.providers.custom.models[0]).toMatchObject({ + contextWindow: 8192, + reasoning: true, + thinkingLevelMap: expectedMap, + }); + expect(updated.providers.custom.modelOverrides.reasoner).toEqual({ + reasoning: true, + thinkingLevelMap: expectedMap, + maxTokens: 2048, + }); + expect(updated.providers.custom.models[1]).toEqual( + saved.providers.custom.models[1], + ); + const catalog = await driver.catalog("assistant"); + for (const id of ["reasoner", "new-reasoner"]) + expect(catalog.models.find((model) => model.id === id)).toMatchObject({ + thinking_levels: ["minimal", "high", "max"], + default_thinking_level: "minimal", + custom: { reasoning: true }, + }); + const before = readFileSync(path, "utf8"); + await driver.configureModel!(connection, save); + expect(readFileSync(path, "utf8")).toBe(before); + } finally { + await driver.dispose(); + rmSync(directory, { recursive: true, force: true }); + } +}); + test.each([ "read", "proposal", @@ -2505,7 +2593,7 @@ test.each([ "openai-responses", "anthropic-messages", ] as const)( - "configured custom reasoning reaches the %s request", + "configured custom thinking efforts reach the %s request", async (api) => { const directory = mkdtempSync(join(tmpdir(), "roamgate-reasoning-wire-")); const requests: { @@ -2620,7 +2708,13 @@ test.each([ expect( (await driver.catalog("assistant")).models[0]?.thinking_levels, ).toEqual(["off"]); - await driver.configureModel!({ ...connection, reasoning: true }, save); + await driver.configureModel!( + { ...connection, thinking_levels: [...ASSISTANT_THINKING_LEVELS] }, + save, + ); + expect( + (await driver.catalog("assistant")).models[0]?.thinking_levels, + ).toEqual([...ASSISTANT_THINKING_LEVELS]); const entries = await driver.run({ ...input, config: { ...input.config, thinking_level: "high" }, @@ -2644,7 +2738,25 @@ test.each([ else if (api === "openai-responses") expect(requests[1]!.reasoning?.effort).toBe("none"); else expect(requests[1]!.thinking).toEqual({ type: "disabled" }); - await driver.configureModel!({ ...connection, reasoning: false }, save); + if (api !== "anthropic-messages") + for (const thinking_level of ["xhigh", "max"] as const) { + await driver.run({ + ...input, + entries, + requestId: `${thinking_level}-effort`, + config: { ...input.config, thinking_level }, + }); + expect( + api === "openai-completions" + ? requests.at(-1)!.reasoning_effort + : requests.at(-1)!.reasoning?.effort, + ).toBe(thinking_level); + } + const requestCount = requests.length; + await driver.configureModel!( + { ...connection, thinking_levels: ["off"] }, + save, + ); await expect( driver.run({ ...input, @@ -2652,7 +2764,7 @@ test.each([ config: { ...input.config, thinking_level: "high" }, }), ).rejects.toThrow("not supported"); - expect(requests).toHaveLength(2); + expect(requests).toHaveLength(requestCount); } finally { await driver.dispose(); server.stop(true); diff --git a/server/src/assistant/pi-driver.ts b/server/src/assistant/pi-driver.ts index 41b0cfdb..c0fca9aa 100644 --- a/server/src/assistant/pi-driver.ts +++ b/server/src/assistant/pi-driver.ts @@ -2,6 +2,7 @@ import { ASSISTANT_MAX_TOOL_ARGUMENTS, ASSISTANT_MAX_TOOL_OUTPUT, ASSISTANT_MAX_TOOL_DETAILS, + ASSISTANT_THINKING_LEVELS, type AssistantConfig, type AssistantMessage, type AssistantModelConnection, @@ -546,6 +547,38 @@ export function createPiDriver( ) throw new Error("Select a chat model"); const existing = new Set(selected.map((model) => model.id)); + const levels = input.thinking_levels; + const capabilities = (model: Record) => { + if (!levels) + return input.reasoning === undefined + ? {} + : { reasoning: input.reasoning }; + const override = isRecord(previous?.modelOverrides) + ? previous.modelOverrides[String(model.id)] + : undefined; + const imported = { + ...(isRecord(model.thinkingLevelMap) ? model.thinkingLevelMap : {}), + ...(isRecord(override) && isRecord(override.thinkingLevelMap) + ? override.thinkingLevelMap + : {}), + }; + return { + reasoning: levels.some((level) => level !== "off"), + // Missing lower-level mappings retain Pi's API-specific wire defaults. + thinkingLevelMap: Object.fromEntries( + ASSISTANT_THINKING_LEVELS.map((level) => [ + level, + !levels.includes(level) + ? null + : typeof imported[level] === "string" + ? imported[level] + : level === "xhigh" || level === "max" + ? level + : undefined, + ]), + ), + }; + }; save(path, { ...saved, providers: { @@ -555,7 +588,7 @@ export function createPiDriver( ...(!previous ? { baseUrl: input.base_url, api: input.api } : {}), // Pi applies modelOverrides after declarations. An explicit choice // must update that layer too, without replacing imported metadata. - ...(input.reasoning !== undefined && + ...((input.reasoning !== undefined || input.thinking_levels) && isRecord(previous?.modelOverrides) ? { modelOverrides: Object.fromEntries( @@ -563,7 +596,14 @@ export function createPiDriver( ([id, value]) => [ id, ids.has(id) && isRecord(value) - ? { ...value, reasoning: input.reasoning } + ? { + ...value, + ...capabilities( + selected.find((model) => model.id === id) ?? { + id, + }, + ), + } : value, ], ), @@ -577,9 +617,7 @@ export function createPiDriver( ...model, baseUrl: input.base_url, api: input.api, - ...(input.reasoning !== undefined - ? { reasoning: input.reasoning } - : {}), + ...capabilities(model), } : model, ), @@ -587,9 +625,7 @@ export function createPiDriver( .filter((id) => !existing.has(id)) .map((id) => ({ id, - ...(input.reasoning !== undefined - ? { reasoning: input.reasoning } - : {}), + ...capabilities({ id }), ...(previous ? { baseUrl: input.base_url, api: input.api } : {}), diff --git a/server/src/assistant/service.test.ts b/server/src/assistant/service.test.ts index 8e30b928..1c33e29f 100644 --- a/server/src/assistant/service.test.ts +++ b/server/src/assistant/service.test.ts @@ -2681,10 +2681,50 @@ describe("bridge-global assistant", () => { value: secret, }); await until(() => snapshots.at(-1)?.auth?.status === "failed"); + expect(snapshots.at(-1)?.auth?.message).toBe( + "Provider login failed. Try again.", + ); expect(JSON.stringify(snapshots)).not.toContain(secret); expect((await service.snapshot()).messages).toEqual([]); }); + test("cancelling an API key update clears login state and preserves saved credentials for retry", async () => { + let savedKey = "existing-key"; + const { service, snapshots } = setup({ + login: async (_provider, _method, interaction) => { + savedKey = await interaction.prompt({ + type: "secret", + message: "API key", + }); + }, + }); + await service.handle("configure", configured); + for (let attempt = 0; attempt < 2; attempt++) { + const started = await service.handle("auth.start", { + provider: "test", + method: "api_key", + }); + expect(started.auth?.status).toBe("waiting"); + const cancelled = await service.handle("auth.cancel", { + auth_id: started.auth?.id, + }); + expect(cancelled.auth).toBeNull(); + expect(snapshots.at(-1)?.auth).toBeNull(); + expect(cancelled.providers[0]?.configured).toBe(true); + expect(savedKey).toBe("existing-key"); + await expect( + service.handle("auth.respond", { + auth_id: started.auth?.id, + prompt_id: started.auth?.prompt?.id, + value: "late-response", + }), + ).rejects.toThrow("no longer active"); + } + expect( + snapshots.some((snapshot) => snapshot.auth?.status === "failed"), + ).toBe(false); + }); + test("cancels OAuth and reads Pi credentials only after explicitly switching source", async () => { const sources: string[] = []; const loginSources: string[] = []; @@ -2728,8 +2768,8 @@ describe("bridge-global assistant", () => { const cancelled = await service.handle("auth.cancel", { auth_id: start.auth?.id, }); - expect(cancelled.auth?.status).toBe("failed"); - expect(snapshots.at(-1)?.auth?.url).toBeUndefined(); + expect(cancelled.auth).toBeNull(); + expect(snapshots.at(-1)?.auth).toBeNull(); await service.handle("configure", { ...configured, credential_source: "pi", @@ -3001,6 +3041,13 @@ describe("bridge-global assistant", () => { { reasoning: "true" }, { reasoning: null }, { reasoning: 1 }, + { thinking_levels: "high" }, + { thinking_levels: null }, + { thinking_levels: [] }, + { thinking_levels: ["ultra"] }, + { thinking_levels: ["high", "high"] }, + { thinking_levels: ["high"], reasoning: false }, + { thinking_levels: ["off"], reasoning: true }, { api_key: "!touch secret-value" }, { api_key: "$SECRET_VALUE" }, { credential_source: "pi" }, @@ -3047,10 +3094,30 @@ describe("bridge-global assistant", () => { ); expect(bodies[0]?.thinking?.type).toBe("enabled"); expect(bodies[0]?.thinking?.budget_tokens).toBeGreaterThan(0); + const restricted = await f.service.handle("configure_model", { + ...input, + api_key: undefined, + thinking_levels: ["high", "xhigh", "max"], + }); + expect( + restricted.models.find((model) => model.id === input.model), + ).toMatchObject({ + thinking_levels: ["high", "xhigh", "max"], + default_thinking_level: "high", + custom: { reasoning: true }, + }); + await expect( + f.service.handle("configure", { + ...configured, + provider: input.provider, + model: input.model, + thinking_level: "low", + }), + ).rejects.toThrow("could not be saved"); const disabled = await f.service.handle("configure_model", { ...input, api_key: undefined, - reasoning: false, + thinking_levels: ["off"], }); expect( disabled.models.find((model) => model.id === input.model), diff --git a/server/src/assistant/service.ts b/server/src/assistant/service.ts index cd23928d..11828410 100644 --- a/server/src/assistant/service.ts +++ b/server/src/assistant/service.ts @@ -1866,14 +1866,14 @@ export function createAssistantService(options: { message: "Provider connected. Select a model to continue.", }; } catch { - state.auth = { - id, - provider, - status: "failed", - message: controller.signal.aborted - ? "Login cancelled." - : "Provider login failed. Try again.", - }; + state.auth = controller.signal.aborted + ? null + : { + id, + provider, + status: "failed", + message: "Provider login failed. Try again.", + }; } finally { answer = undefined; authController = undefined; @@ -2370,6 +2370,7 @@ export function createAssistantService(options: { "model", "models", "reasoning", + "thinking_levels", "base_url", "api", "api_key", @@ -2397,6 +2398,7 @@ export function createAssistantService(options: { ), ]; const base_url = string(params.base_url, "base URL", 2000).trim(); + const thinking_levels = params.thinking_levels; if ( !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(provider) || /[\u0000-\u001f\u007f]/.test(model) || @@ -2406,7 +2408,15 @@ export function createAssistantService(options: { !isAssistantModelEndpoint(base_url) || !isAssistantModelApi(params.api) || (params.reasoning !== undefined && - typeof params.reasoning !== "boolean") + typeof params.reasoning !== "boolean") || + (thinking_levels !== undefined && + (!Array.isArray(thinking_levels) || + !thinking_levels.length || + !thinking_levels.every(isAssistantThinkingLevel) || + new Set(thinking_levels).size !== thinking_levels.length || + (params.reasoning !== undefined && + params.reasoning !== + thinking_levels.some((level) => level !== "off")))) ) throw new Error("Invalid model connection"); const api_key = @@ -2426,6 +2436,7 @@ export function createAssistantService(options: { ...(params.reasoning !== undefined ? { reasoning: params.reasoning } : {}), + ...(thinking_levels !== undefined ? { thinking_levels } : {}), base_url, api: params.api, api_key, diff --git a/shared/assistant.ts b/shared/assistant.ts index ef8a2987..2208f4c0 100644 --- a/shared/assistant.ts +++ b/shared/assistant.ts @@ -102,6 +102,8 @@ export type AssistantModelConnection = { models?: string[]; /** Explicit capability for every submitted model; omitted preserves declarations. */ reasoning?: boolean; + /** Explicit supported efforts for every submitted model; omitted preserves maps. */ + thinking_levels?: AssistantThinkingLevel[]; base_url: string; api: AssistantModelApi; api_key?: string; diff --git a/web/src/components/AssistantModelForm.test.ts b/web/src/components/AssistantModelForm.test.ts index 601553ac..3d4b519f 100644 --- a/web/src/components/AssistantModelForm.test.ts +++ b/web/src/components/AssistantModelForm.test.ts @@ -1,10 +1,16 @@ import { afterAll, afterEach, expect, spyOn, test } from "bun:test"; import { Window } from "happy-dom"; -import type { AssistantSnapshot } from "../../../shared/assistant"; +import { + ASSISTANT_THINKING_LEVELS, + type AssistantSnapshot, + type AssistantModelConnection, + type AssistantThinkingLevel, +} from "../../../shared/assistant"; +import { thinkingLabels } from "../assistantModels"; // Radix/cmdk need DOM globals before loading, isolated from other hook spies. if (process.env.ROAMGATE_MODEL_FORM_DOM_TEST !== "1") { - test("custom model reasoning setup in an isolated DOM runtime", async () => { + test("custom model thinking setup in an isolated DOM runtime", async () => { const child = Bun.spawn([process.execPath, "test", import.meta.path], { env: { ...process.env, ROAMGATE_MODEL_FORM_DOM_TEST: "1" }, stdout: "pipe", @@ -52,12 +58,16 @@ function snapshot(model = "reasoner"): AssistantSnapshot { id: "reasoner", label: "Reasoner", custom: { ...custom, reasoning: true }, + thinking_levels: ["off", "medium", "xhigh"], + default_thinking_level: "medium", }, { provider: "custom", id: "plain", label: "Plain", custom: { ...custom, reasoning: false }, + thinking_levels: ["off"], + default_thinking_level: "off", }, { provider: "custom", @@ -65,6 +75,12 @@ function snapshot(model = "reasoner"): AssistantSnapshot { label: "Undeclared", custom, }, + { + provider: "custom", + id: "legacy", + label: "Legacy reasoner", + custom: { ...custom, reasoning: true }, + }, ], messages: [], running: false, @@ -118,7 +134,7 @@ async function registerDomTests() { }); afterAll(async () => browser.happyDOM.close()); - async function mount(initial = snapshot()) { + async function mount(initial = snapshot(), openCustom = true) { const clientState: ReturnType = { snapshot: initial, loading: false, @@ -138,6 +154,40 @@ async function registerDomTests() { ...clientState.snapshot!, config: params?.config as AssistantSnapshot["config"], }; + } else if (action === "configure_model") { + const input = params as AssistantModelConnection; + const custom = { base_url: input.base_url, api: input.api }; + const current = clientState.snapshot!; + current.providers = [ + ...current.providers.filter((item) => item.id !== input.provider), + { + id: input.provider, + label: input.provider, + configured: true, + methods: ["api_key"], + custom, + }, + ]; + for (const id of input.models ?? [input.model]) { + const previous = current.models.find( + (item) => item.provider === input.provider && item.id === id, + ); + current.models = [ + ...current.models.filter( + (item) => item.provider !== input.provider || item.id !== id, + ), + { + ...previous, + provider: input.provider, + id, + label: previous?.label ?? id, + custom: { ...previous?.custom, ...custom }, + ...(input.thinking_levels + ? { thinking_levels: input.thinking_levels } + : {}), + }, + ]; + } } return clientState.snapshot!; }, @@ -204,6 +254,12 @@ async function registerDomTests() { await act(async () => option.click()); await act(async () => browser.happyDOM.whenAsyncComplete()); }; + const thinkingInput = (level: AssistantThinkingLevel) => + input(`Support ${thinkingLabels[level]} thinking`) as HTMLInputElement; + const selectedLevels = () => + ASSISTANT_THINKING_LEVELS.filter((level) => thinkingInput(level).checked); + const toggle = async (level: AssistantThinkingLevel) => + act(async () => thinkingInput(level).click()); const submit = async () => act(async () => container @@ -213,9 +269,26 @@ async function registerDomTests() { ), ); const lastInput = () => call.mock.calls[call.mock.calls.length - 1]?.[1]; + const overview = async () => { + const back = container.querySelector( + '[aria-label="Back to Ranger settings"]', + ); + if (back) await act(async () => back.click()); + }; + const custom = async () => { + await overview(); + await act(async () => + container + .querySelector( + '[aria-label="Edit custom models"], [aria-label="Add custom models"]', + )! + .click(), + ); + }; await render(); if (!container.querySelector(".assistant-panel-settings")) await act(async () => button("Ranger settings").click()); + if (openCustom) await custom(); return { container, clientState, @@ -225,145 +298,432 @@ async function registerDomTests() { input, fill, select, + selectedLevels, + toggle, submit, lastInput, + overview, + custom, }; } - test.each([undefined, true, false])( - "new arbitrary IDs use only explicitly selected reasoning (%s)", - async (reasoning) => { - const fixture = await mount(snapshot("")); - expect( - fixture.button("Custom reasoning capability").textContent, - ).toContain("Keep existing / model defaults"); - expect(fixture.container.textContent).toContain( - "New unknown models default to no reasoning", - ); - await fixture.fill("Custom provider ID", "new-provider"); - await fixture.fill("Custom API base URL", "https://new.example.com/v1"); - await fixture.fill("Custom model IDs", "arbitrary-model"); - await fixture.fill("Custom API key", "synthetic-key"); - if (reasoning !== undefined) - await fixture.select( - "Custom reasoning capability", - reasoning ? "enabled" : "disabled", - ); - await fixture.submit(); - expect(fixture.call).toHaveBeenLastCalledWith("configure_model", { - provider: "new-provider", - model: "arbitrary-model", - base_url: "https://new.example.com/v1", - api: "openai-completions", - api_key: "synthetic-key", - ...(reasoning === undefined ? {} : { reasoning }), - credential_source: "assistant", - }); - if (reasoning === undefined) - expect(fixture.lastInput()).not.toHaveProperty("reasoning"); - }, - ); - - test("an existing undeclared model can enable reasoning without re-entering its saved key", async () => { - const fixture = await mount(snapshot("undeclared")); - expect(fixture.input("Custom API key").required).toBe(false); - expect(fixture.input("Custom model IDs").value).toBe("undeclared"); - await fixture.select("Custom reasoning capability", "enabled"); - fixture.call.mockRejectedValueOnce(new Error("Save failed")); - await fixture.submit(); - expect( - fixture.container.querySelector(".assistant-error")?.textContent, - ).toContain("Save failed"); - expect(fixture.button("Custom reasoning capability").textContent).toContain( - "Enable reasoning for all IDs", + test("new models offer every thinking level and default to Off", async () => { + const fixture = await mount(snapshot("")); + const levels = fixture.container.querySelector( + 'fieldset[aria-label="Supported thinking efforts"]', + )!; + expect(levels.querySelectorAll('input[type="checkbox"]').length).toBe( + ASSISTANT_THINKING_LEVELS.length, ); + expect(fixture.selectedLevels()).toEqual(["off"]); + expect( + fixture.container.querySelector("form.assistant-custom-model"), + ).not.toBeNull(); + expect(fixture.container.querySelector("details")).toBeNull(); + expect(fixture.container.textContent).toContain("Add custom models"); + await fixture.fill("Custom provider ID", "new-provider"); + await fixture.fill("Custom API base URL", "https://new.example.com/v1"); + await fixture.fill("Custom model IDs", "arbitrary-model"); + await fixture.fill("Custom API key", "synthetic-key"); await fixture.submit(); expect(fixture.call).toHaveBeenLastCalledWith("configure_model", { - provider: "custom", - model: "undeclared", - base_url: "https://example.com/v1", + provider: "new-provider", + model: "arbitrary-model", + base_url: "https://new.example.com/v1", api: "openai-completions", - reasoning: true, + api_key: "synthetic-key", credential_source: "assistant", }); - expect(fixture.lastInput()).not.toHaveProperty("api_key"); - expect(fixture.button("Custom reasoning capability").textContent).toContain( - "Keep existing / model defaults", - ); }); - test("unchanged single and mixed batch edits preserve declarations and explicit choices apply to every ID", async () => { + test.each([ + ["legacy", ["off", "minimal", "low", "medium", "high"]], + ["plain", ["off"]], + ["undeclared", ["off"]], + ] as const)( + "%s models use their saved capability as a fallback", + async (model, levels) => { + const fixture = await mount(snapshot(model)); + expect(fixture.selectedLevels()).toEqual([...levels]); + await fixture.submit(); + expect(fixture.lastInput()).not.toHaveProperty("thinking_levels"); + expect(fixture.lastInput()).not.toHaveProperty("reasoning"); + }, + ); + + test("unchanged single and mixed batch edits preserve imported thinking settings", async () => { const fixture = await mount(); - expect(fixture.button("Custom reasoning capability").textContent).toContain( - "Keep current (reasoning enabled)", - ); + expect(fixture.selectedLevels()).toEqual(["off", "medium", "xhigh"]); + expect(fixture.input("Custom API key").required).toBe(false); + expect( + fixture.container.querySelector("form.assistant-custom-model"), + ).not.toBeNull(); + expect(fixture.container.querySelector("details")).toBeNull(); + expect(fixture.container.textContent).toContain("Edit custom models"); + for (const label of [ + "Custom provider ID", + "Custom API base URL", + "Custom API key", + ]) + expect(fixture.input(label)).not.toBeNull(); await fixture.fill("Custom API base URL", "https://updated.example.com/v1"); await fixture.submit(); + expect(fixture.lastInput()).not.toHaveProperty("thinking_levels"); expect(fixture.lastInput()).not.toHaveProperty("reasoning"); expect(fixture.lastInput()).not.toHaveProperty("api_key"); + await fixture.custom(); await fixture.fill( "Custom model IDs", "reasoner, plain\nunknown, reasoner", ); - expect(fixture.button("Custom reasoning capability").textContent).toContain( - "Keep existing / model defaults", - ); await fixture.submit(); expect(fixture.lastInput()).toMatchObject({ + model: "reasoner", models: ["reasoner", "plain", "unknown"], base_url: "https://updated.example.com/v1", }); + expect(fixture.lastInput()).not.toHaveProperty("thinking_levels"); expect(fixture.lastInput()).not.toHaveProperty("reasoning"); - for (const [choice, reasoning] of [ - ["enabled", true], - ["disabled", false], - ] as const) { - await fixture.select("Custom reasoning capability", choice); - await fixture.submit(); - expect(fixture.lastInput()).toMatchObject({ - model: "reasoner", - models: ["reasoner", "plain", "unknown"], - reasoning, - }); + await fixture.custom(); + await fixture.fill( + "Custom model IDs", + "reasoner, plain\nunknown, reasoner", + ); + await fixture.toggle("max"); + await fixture.toggle("minimal"); + await fixture.submit(); + expect(fixture.lastInput()).toMatchObject({ + model: "reasoner", + models: ["reasoner", "plain", "unknown"], + thinking_levels: ["off", "minimal", "max"], + }); + expect(fixture.lastInput()).not.toHaveProperty("reasoning"); + }); + + test("a model with one thinking level shows its effective default while disabled", async () => { + const initial = snapshot(); + initial.chat_selection = true; + initial.models[0]!.thinking_levels = ["high"]; + initial.models[0]!.default_thinking_level = "high"; + const fixture = await mount(initial, false); + const effort = fixture.button("Default thinking effort"); + expect(effort.disabled).toBe(true); + expect(effort.textContent).toContain("Default (High)"); + }); + + test("users can choose the full range in canonical order", async () => { + const fixture = await mount(snapshot("undeclared")); + for (const level of [...ASSISTANT_THINKING_LEVELS].reverse()) { + if (level !== "off") await fixture.toggle(level); } - await fixture.select("Custom reasoning capability", "enabled"); - await fixture.select("Custom reasoning capability", "preserve"); await fixture.submit(); + expect(fixture.lastInput()).toMatchObject({ + thinking_levels: [...ASSISTANT_THINKING_LEVELS], + }); + }); + + test("at least one thinking level is required even for a synthetic submit", async () => { + const fixture = await mount(snapshot("undeclared")); + await fixture.toggle("off"); + expect(fixture.button("Save custom models").disabled).toBe(true); + const calls = fixture.call.mock.calls.length; + await fixture.submit(); + expect(fixture.call.mock.calls.length).toBe(calls); + await fixture.toggle("high"); + expect(fixture.button("Save custom models").disabled).toBe(false); + await fixture.submit(); + expect(fixture.lastInput()).toMatchObject({ thinking_levels: ["high"] }); + }); + + test("a failed save keeps choices and a successful save clears their dirty state", async () => { + const fixture = await mount(snapshot("undeclared")); + await fixture.toggle("high"); + fixture.call.mockRejectedValueOnce(new Error("Save failed")); + await fixture.submit(); + expect( + fixture.container.querySelector(".assistant-error")?.textContent, + ).toContain("Save failed"); + expect(fixture.selectedLevels()).toEqual(["off", "high"]); + await fixture.submit(); + expect(fixture.call).toHaveBeenLastCalledWith("configure_model", { + provider: "custom", + model: "undeclared", + base_url: "https://example.com/v1", + api: "openai-completions", + thinking_levels: ["off", "high"], + credential_source: "assistant", + }); + expect(fixture.lastInput()).not.toHaveProperty("api_key"); + expect( + fixture.container.querySelector(".assistant-custom-model"), + ).toBeNull(); + await fixture.custom(); + await fixture.submit(); + expect(fixture.lastInput()).not.toHaveProperty("thinking_levels"); expect(fixture.lastInput()).not.toHaveProperty("reasoning"); }); - test("model, provider, credential source and refreshed metadata reset unsaved reasoning choices", async () => { + test("model, provider, credential source and refreshed levels reset unsaved choices", async () => { const fixture = await mount(); - await fixture.select("Custom reasoning capability", "disabled"); + await fixture.toggle("max"); + await fixture.overview(); await fixture.select("Ranger model", "plain"); + await fixture.custom(); expect(fixture.input("Custom model IDs").value).toBe("plain"); - expect(fixture.button("Custom reasoning capability").textContent).toContain( - "Keep current (reasoning disabled)", - ); + expect(fixture.selectedLevels()).toEqual(["off"]); await fixture.submit(); - expect(fixture.lastInput()).not.toHaveProperty("reasoning"); - await fixture.select("Custom reasoning capability", "enabled"); + expect(fixture.lastInput()).not.toHaveProperty("thinking_levels"); + await fixture.custom(); + await fixture.toggle("high"); + await fixture.overview(); await fixture.select("Ranger model", "reasoner"); - expect(fixture.button("Custom reasoning capability").textContent).toContain( - "Keep current (reasoning enabled)", - ); - await fixture.select("Custom reasoning capability", "disabled"); - const current = fixture.clientState.snapshot!.models[0]!; - current.custom = { ...current.custom!, reasoning: false }; + await fixture.custom(); + expect(fixture.selectedLevels()).toEqual(["off", "medium", "xhigh"]); + await fixture.toggle("max"); + fixture.clientState.snapshot!.models.find( + (model) => model.id === "reasoner", + )!.thinking_levels = ["off", "low"]; await fixture.render(); - expect(fixture.button("Custom reasoning capability").textContent).toContain( - "Keep current (reasoning disabled)", + expect(fixture.selectedLevels()).toEqual(["off", "low"]); + await fixture.toggle("max"); + await fixture.overview(); + await fixture.select("Model provider", "other"); + expect( + fixture.container.querySelector('[aria-label="Edit custom models"]'), + ).toBeNull(); + await act(async () => fixture.button("Add custom models").click()); + expect(fixture.input("Custom model IDs").value).toBe(""); + expect(fixture.selectedLevels()).toEqual(["off"]); + await fixture.toggle("high"); + await fixture.overview(); + await act(async () => fixture.button("Shared Pi credentials").click()); + await act(async () => fixture.button("Add custom models").click()); + expect(fixture.selectedLevels()).toEqual(["off"]); + }); + + test("settings keep model and workspace access on one page with a single save", async () => { + const fixture = await mount(snapshot(), false); + expect(fixture.container.querySelector("details")).toBeNull(); + expect( + fixture.container.querySelector(".assistant-custom-model"), + ).toBeNull(); + for (const label of [ + "Credential source", + "Model provider", + "Ranger model", + "Edit custom models", + "Add custom models", + "High-permission mode", + "Refresh Ranger workspaces", + ]) + expect( + fixture.container.querySelector(`[aria-label="${label}"]`), + ).not.toBeNull(); + expect(fixture.container.querySelector(".assistant-workspaces")).not.toBe( + null, + ); + expect( + fixture.container.querySelectorAll(".assistant-settings-primary"), + ).toHaveLength(1); + expect(fixture.button("Model provider").textContent).toContain( + "Custom provider", ); - await fixture.select("Custom reasoning capability", "enabled"); - await act(async () => fixture.button("Select provider Other").click()); + expect( + fixture.container.querySelector(".assistant-provider-status") + ?.textContent, + ).toContain("Credentials saved"); + expect( + fixture.container.querySelector(".assistant-custom-models")?.textContent, + ).toContain("Custom provider · example.com"); + const status = fixture.container.querySelector( + ".assistant-settings-save [role=status]", + )!; + expect(status.textContent).toBe(""); + await fixture.select("Ranger model", "plain"); + expect(status.textContent).toBe("Unsaved changes"); + await fixture.select("Model provider", "other"); + expect(fixture.button("Ranger model").textContent).toContain( + "No models available", + ); + expect(fixture.clientState.snapshot!.config.provider).toBe("custom"); + }); + + test("adding models to a selected custom endpoint starts with no IDs", async () => { + const fixture = await mount(snapshot(), false); + await act(async () => fixture.button("Add custom models").click()); + expect( + fixture.container.querySelector(".assistant-settings-heading h3") + ?.textContent, + ).toBe("Add custom models"); expect(fixture.input("Custom model IDs").value).toBe(""); - expect(fixture.button("Custom reasoning capability").textContent).toContain( - "Keep existing / model defaults", + expect(document.activeElement).toBe(fixture.input("Custom model IDs")); + expect(fixture.input("Custom provider ID").value).toBe("custom"); + expect(fixture.input("Custom API base URL").value).toBe( + "https://example.com/v1", ); - await fixture.select("Custom reasoning capability", "enabled"); - await act(async () => fixture.button("Shared Pi credentials").click()); - expect(fixture.button("Custom reasoning capability").textContent).toContain( - "Keep existing / model defaults", + expect(fixture.input("Custom API key").required).toBe(false); + expect(fixture.selectedLevels()).toEqual(["off"]); + await fixture.fill("Custom model IDs", "fresh-a\nfresh-b"); + await fixture.toggle("low"); + await fixture.toggle("high"); + await fixture.submit(); + expect(fixture.call).toHaveBeenLastCalledWith("configure_model", { + provider: "custom", + model: "fresh-a", + models: ["fresh-a", "fresh-b"], + base_url: "https://example.com/v1", + api: "openai-completions", + thinking_levels: ["off", "low", "high"], + credential_source: "assistant", + }); + expect( + fixture.container.querySelector(".assistant-custom-model"), + ).toBeNull(); + expect(document.activeElement).toBe(fixture.button("Add custom models")); + expect(fixture.button("Ranger model").textContent).toContain("fresh-a"); + expect( + fixture.container.querySelector(".assistant-settings-save [role=status]") + ?.textContent, + ).toBe("Unsaved changes"); + }); + + test("a running task moves custom editor focus to its heading when inputs are disabled", async () => { + const initial = snapshot(); + const at = "2026-10-10T00:00:00Z"; + initial.tasks = [ + { + id: "running-task", + title: "Running task", + prompt: "Read workspace status", + scope: [], + workspaces: [], + schedule: { type: "interval", minutes: 60 }, + status: "active", + created_at: at, + updated_at: at, + next_run_at: null, + model: { provider: "custom", id: "reasoner" }, + current_run: { + id: "current-run", + task_id: "running-task", + status: "running", + scheduled_at: at, + started_at: at, + error: null, + }, + }, + ]; + const fixture = await mount(initial); + expect( + fixture.input("Custom model IDs").closest("fieldset") + ?.disabled, + ).toBe(true); + const heading = fixture.container.querySelector( + ".assistant-settings-heading h3", + )!; + expect(heading.textContent).toBe("Edit custom models"); + expect(document.activeElement).toBe(heading); + expect(fixture.button("Save custom models").disabled).toBe(true); + expect(fixture.button("Cancel custom model changes").disabled).toBe(false); + expect(fixture.call).not.toHaveBeenCalled(); + }); + + test("pending provider login stays usable while settings controls are locked", async () => { + const initial = snapshot(); + initial.providers.push({ + id: "new-provider", + label: "New provider", + methods: ["oauth"], + configured: false, + }); + initial.auth = { + id: "pending-login", + provider: "custom", + status: "waiting", + message: "Enter the provider API key.", + prompt: { id: "api-key", type: "secret", message: "API key" }, + }; + const fixture = await mount(initial, false); + const login = fixture.container.querySelector( + '[aria-label="Model login"]', + )!; + expect(login).not.toBeNull(); + expect(login.closest("fieldset[disabled]")).toBeNull(); + expect(login.querySelector("input")?.disabled).toBe( + false, + ); + expect(fixture.button("Cancel login").disabled).toBe(false); + for (const label of ["Ranger connection", "Model provider", "Ranger model"]) + expect( + fixture.button(label).closest("fieldset") + ?.disabled, + ).toBe(true); + expect(fixture.button("Edit custom models").disabled).toBe(true); + expect(fixture.button("Add custom models").disabled).toBe(true); + expect(fixture.button("Save settings").disabled).toBe(true); + expect(fixture.clientState.snapshot!.auth?.id).toBe("pending-login"); + expect(fixture.call).not.toHaveBeenCalled(); + await act(async () => fixture.button("Ranger settings").click()); + await act(async () => fixture.button("Ranger settings").click()); + expect( + fixture.container.querySelector('[aria-label="Model login"]'), + ).not.toBeNull(); + }); + + test("opening and leaving the custom model editor preserves unsaved model defaults", async () => { + const fixture = await mount(snapshot(), false); + await fixture.select("Ranger model", "plain"); + const calls = fixture.call.mock.calls.length; + await act(async () => fixture.button("Add custom models").click()); + await fixture.overview(); + expect(fixture.button("Ranger model").textContent).toContain("Plain"); + expect(document.activeElement).toBe(fixture.button("Add custom models")); + expect(fixture.clientState.snapshot!.config.model).toBe("reasoner"); + expect(fixture.call.mock.calls.length).toBe(calls); + }); + + test("cancel and Escape discard custom edits and restore the overview trigger", async () => { + const fixture = await mount(); + await fixture.fill("Custom model IDs", "unsaved-id"); + await fixture.fill("Custom API key", "unsaved-key"); + await fixture.toggle("max"); + expect( + fixture.container.querySelector('[aria-label="Save settings"]'), + ).toBeNull(); + expect( + Array.from(fixture.container.querySelectorAll("button")).some( + (item) => item.textContent?.trim() === "Save settings", + ), + ).toBe(false); + const calls = fixture.call.mock.calls.length; + await act(async () => + fixture.button("Cancel custom model changes").click(), + ); + expect(fixture.call.mock.calls.length).toBe(calls); + expect( + fixture.container.querySelector(".assistant-custom-model"), + ).toBeNull(); + expect(document.activeElement).toBe(fixture.button("Edit custom models")); + await fixture.custom(); + expect(fixture.input("Custom model IDs").value).toBe("reasoner"); + expect(fixture.input("Custom API key").value).toBe(""); + expect(fixture.selectedLevels()).toEqual(["off", "medium", "xhigh"]); + await fixture.fill("Custom model IDs", "discard-on-escape"); + await act(async () => + fixture.input("Custom model IDs").dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }), + ), ); + expect( + fixture.container.querySelector(".assistant-custom-model"), + ).toBeNull(); + expect(document.activeElement).toBe(fixture.button("Edit custom models")); + await fixture.custom(); + expect(fixture.input("Custom model IDs").value).toBe("reasoner"); + expect(fixture.call.mock.calls.length).toBe(calls); }); } diff --git a/web/src/components/AssistantPanel.css b/web/src/components/AssistantPanel.css index fede0724..e8f0a42e 100644 --- a/web/src/components/AssistantPanel.css +++ b/web/src/components/AssistantPanel.css @@ -55,7 +55,8 @@ padding-inline: max(16px, calc((100% - 960px) / 2)); } -.assistant-panel.is-maximized .assistant-panel-settings { +.assistant-panel.is-maximized .assistant-panel-settings, +.assistant-panel.is-maximized .assistant-chat-history { width: 100%; max-width: 720px; align-self: center; @@ -162,20 +163,7 @@ color: var(--accent); } -.assistant-chat-history > .assistant-section-heading { - margin-bottom: 5px; - font-size: 11px; -} - -.assistant-chat-history > .assistant-section-heading button { - width: auto; - display: flex; - align-items: center; - gap: 3px; -} - -.assistant-panel-list, -.assistant-panel-settings { +.assistant-panel-list { flex: 1 1 0; min-height: 0; overflow: auto; @@ -276,17 +264,243 @@ font-size: 13px; } -.assistant-panel-settings { - display: grid; - grid-auto-rows: max-content; - align-content: start; - gap: 10px; +.assistant-panel-settings, +.assistant-chat-history { + flex: 1 1 0; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; } .assistant-panel-settings p { margin: 0; } +.assistant-settings-heading, +.assistant-history-heading { + flex: 0 0 auto; + min-height: 40px; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px 6px 12px; + border-bottom: 1px solid var(--viewer-border); +} + +.assistant-settings-heading > h3, +.assistant-history-heading > h3 { + flex: 1 1 auto; + min-width: 0; + margin: 0; + color: var(--text-strong); + font-size: 12px; + font-weight: 650; + outline: none; +} + +.assistant-settings-heading > .assistant-icon-button { + margin-left: -4px; +} + +.assistant-settings-heading > button, +.assistant-history-heading > button { + flex: 0 0 auto; +} + +.assistant-settings-body { + flex: 1 1 0; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0 12px; + overflow: auto; + overscroll-behavior: contain; +} + +.assistant-settings-body > * { + flex-shrink: 0; +} + +.assistant-settings-section { + min-width: 0; + display: grid; + gap: 12px; + padding: 14px 0 16px; +} + +.assistant-settings-section + .assistant-settings-section { + border-top: 1px solid var(--viewer-border); +} + +.assistant-settings-section > h4 { + margin: 0; + color: var(--text-strong); + font-size: 12px; + font-weight: 650; +} + +.assistant-model-selection { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); +} + +.assistant-provider-status { + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 6px; + color: var(--muted); + font-size: 11px; +} + +.assistant-provider-status > span { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; +} + +.assistant-panel-settings .assistant-provider-status > button, +.assistant-custom-models > button, +.assistant-workspaces-heading > button:not(.assistant-icon-button), +.assistant-panel-settings .assistant-settings-inline-action { + min-height: 26px; + padding: 3px 9px; + border-radius: 6px; +} + +.assistant-custom-models { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + padding-top: 2px; +} + +.assistant-custom-models > div, +.assistant-settings-toggle > div { + flex: 1 1 auto; + min-width: 0; + display: grid; + gap: 2px; +} + +.assistant-custom-models strong, +.assistant-settings-toggle strong { + color: var(--text); + font-size: 12px; + font-weight: 600; +} + +.assistant-custom-models > div > span { + overflow: hidden; + color: var(--muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assistant-settings-toggle { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.assistant-settings-toggle > .settings-switch { + margin-top: 1px; +} + +.assistant-panel-settings .assistant-settings-inline-action { + justify-self: start; +} + +.assistant-workspaces-heading { + min-width: 0; + display: flex; + align-items: center; + gap: 2px; +} + +.assistant-workspaces-heading > span { + flex: 1 1 auto; + min-width: 0; + color: var(--muted); + font-size: 11px; +} + +.assistant-workspaces-heading small { + margin-left: 6px; + color: var(--muted); + font-size: 10px; + opacity: 0.8; +} + +.assistant-settings-select .command-list { + max-height: min(300px, var(--radix-popover-content-available-height)); + overscroll-behavior: contain; +} + +.assistant-settings-save { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid var(--viewer-border); + background: var(--viewer-header-bg); +} + +.assistant-settings-save > span { + flex: 1 1 auto; + min-width: 0; + color: var(--muted); + font-size: 11px; +} + +.assistant-settings-save > button { + flex: 0 0 auto; + min-height: 30px; + padding-inline: 14px; +} + +.assistant-panel-settings .assistant-settings-primary { + border-color: color-mix(in srgb, var(--accent) 58%, var(--border)); + background: var(--accent-soft); + color: var(--text-strong); + font-weight: 650; +} + +.assistant-settings-primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.assistant-custom-model { + flex: 1 1 0; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.assistant-custom-model .assistant-settings-body > fieldset { + gap: 0; +} + +.assistant-field-heading > .assistant-icon-button, +.assistant-workspaces-heading > .assistant-icon-button { + width: 24px; + height: 24px; +} + +.assistant-panel .assistant-hint.is-error, +.assistant-field-heading > small.is-invalid { + color: var(--danger-text); +} + .assistant-panel fieldset { min-width: 0; display: grid; @@ -304,11 +518,35 @@ gap: 5px; } +.assistant-panel-settings .form-field { + margin-top: 0; +} + .assistant-panel .form-field > span { color: var(--muted); font-size: 11px; } +.assistant-panel-settings .form-field > span, +.assistant-field-heading { + text-transform: none; + letter-spacing: normal; +} + +.assistant-field-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--muted); + font-size: 11px; +} + +.assistant-panel .assistant-custom-model .form-field textarea { + min-height: 92px; + resize: vertical; +} + .assistant-panel .form-field input, .assistant-panel .form-field textarea, .assistant-panel .themed-select-trigger { @@ -317,6 +555,7 @@ } .assistant-panel-settings button, +.assistant-history-heading > button, .assistant-panel-footer button, .assistant-chat-toolbar button, .assistant-setup-notice button, @@ -358,148 +597,70 @@ flex: 0 0 auto; } -.assistant-providers { - display: grid; - gap: 7px; -} - .assistant-credential-source { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 3px; - padding: 3px; + gap: 2px; + padding: 2px; border: 1px solid var(--viewer-border); - border-radius: 8px; + border-radius: 7px; background: var(--viewer-content-bg); } .assistant-credential-source > button { - min-height: 30px; - flex-direction: column; - align-items: flex-start; - gap: 3px; - padding: 8px; + min-height: 28px; + padding: 4px 8px; border-color: transparent; border-radius: 5px; background: transparent; color: var(--muted); - text-align: left; -} - -.assistant-credential-source small { - color: var(--muted); - font-size: 10px; - line-height: 1.45; } .assistant-credential-source > button[aria-pressed="true"] { - border-color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 58%, var(--border)); background: var(--accent-soft); color: var(--text-strong); } -.assistant-providers .assistant-section-heading > strong { - color: var(--text-strong); - font-size: 11px; -} - -.assistant-providers .assistant-section-heading > button { - flex: 0 0 auto; - font-size: 10px; -} - -.assistant-provider-search { - flex: 0 1 220px; - min-width: 0; - height: 28px; - display: flex; - align-items: center; - gap: 6px; - margin-left: auto; - padding: 0 8px; - border: 1px solid var(--viewer-border); - border-radius: 6px; - background: var(--viewer-content-bg); - color: var(--muted); -} - -.assistant-provider-search:focus-within { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-soft); -} - -.assistant-provider-search > svg { - flex: 0 0 auto; -} - -.assistant-provider-search input { - flex: 1 1 auto; - min-width: 0; - width: 100%; - height: 100%; +.assistant-thinking-levels > legend { + margin-bottom: 4px; padding: 0; - border: 0; - border-radius: 0; - outline: none; - box-shadow: none; - background: transparent; - color: var(--text); - font: inherit; - font-size: 11px; -} - -.assistant-provider-search input::placeholder { color: var(--muted); + font-size: 11px; } -.assistant-provider-list { +.assistant-thinking-levels > div { display: grid; - gap: 5px; + grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); + gap: 2px 6px; } -.assistant-panel-settings .assistant-provider-choice { +.assistant-thinking-levels label { + min-width: 0; + min-height: 28px; display: flex; align-items: center; - justify-content: space-between; - gap: 8px; - text-align: left; - font-size: 11px; -} - -.assistant-provider-choice > strong { - min-width: 0; - overflow-wrap: anywhere; -} - -.assistant-provider-choice > span { - flex: 0 0 auto; + gap: 6px; + padding: 2px 4px; + border-radius: 5px; color: var(--muted); - font-size: 10px; - font-weight: 400; + font-size: 12px; + white-space: nowrap; + cursor: pointer; } -.assistant-provider-choice[aria-pressed="true"] { - border-color: var(--accent); +.assistant-thinking-levels label:hover { background: var(--accent-soft); } -.assistant-other-providers > summary, -.assistant-custom-model > summary { - color: var(--accent); - font-size: 11px; - cursor: pointer; -} - -.assistant-custom-model > form { - display: grid; - gap: 10px; - margin-top: 10px; +.assistant-thinking-levels label:has(input:checked) { + color: var(--text-strong); } -.assistant-other-providers .assistant-provider-list { - max-height: 240px; - overflow-y: auto; - margin-top: 7px; +.assistant-thinking-levels input { + flex: 0 0 auto; + margin: 0; + accent-color: var(--accent); } .assistant-panel .assistant-workspaces { @@ -508,7 +669,7 @@ grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); align-content: start; gap: 6px 12px; - max-height: min(240px, 35dvh); + max-height: min(280px, 40dvh); overflow: auto; overscroll-behavior: contain; } @@ -624,30 +785,28 @@ font-size: 11px; } -.assistant-chat-history { - flex: 0 1 auto; +.assistant-history-body { + flex: 1 1 0; min-height: 0; - max-height: min(240px, 35vh); overflow: auto; - padding: 6px 10px; - border-bottom: 1px solid var(--viewer-border); - background: var(--viewer-header-bg); + overscroll-behavior: contain; + padding: 12px; } .assistant-chat-history ul { display: grid; - gap: 4px; + gap: 8px; margin: 0; padding: 0; list-style: none; } -.assistant-chat-history button { +.assistant-chat-history li > button { width: 100%; min-width: 0; display: grid; gap: 4px; - padding: 7px 8px; + padding: 10px 12px; text-align: left; } @@ -657,15 +816,15 @@ opacity: 1; } -.assistant-chat-history strong { +.assistant-chat-history li strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 11px; + font-size: 12px; } -.assistant-chat-history button > span { +.assistant-chat-history li > button > span { display: flex; justify-content: space-between; gap: 8px; @@ -674,7 +833,7 @@ } .assistant-chat-history p { - margin: 3px 0 7px; + margin: 0 0 12px; color: var(--muted); font-size: 10px; line-height: 1.5; @@ -1100,6 +1259,24 @@ align-items: center; } +.assistant-panel.is-mobile .assistant-thinking-levels label, +.assistant-panel.is-mobile .form-field input, +.assistant-panel.is-mobile .themed-select-trigger { + min-height: 44px; +} + +.assistant-panel.is-mobile .assistant-settings-toggle > .settings-switch { + min-height: 22px; +} + +.assistant-settings-select.is-mobile .command-item { + min-height: 44px; +} + +.assistant-panel.is-mobile .assistant-settings-save { + padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); +} + .assistant-panel.is-mobile .assistant-panel-footer { padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); } diff --git a/web/src/components/AssistantPanel.test.ts b/web/src/components/AssistantPanel.test.ts index f598dbca..8337986a 100644 --- a/web/src/components/AssistantPanel.test.ts +++ b/web/src/components/AssistantPanel.test.ts @@ -195,13 +195,19 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { await click("Chat"); await click("Ranger chat history"); expect(container.querySelector(".assistant-chat-history")).not.toBeNull(); + expect(container.querySelector(".assistant-chat-toolbar")).toBeNull(); + expect(container.querySelector(".assistant-setup-notice")).toBeNull(); + expect(container.querySelector(".assistant-conversation")).toBeNull(); + expect(container.querySelector(".assistant-panel-footer")).toBeNull(); + expect(document.activeElement).toBe( + container.querySelector(".assistant-history-heading h3"), + ); await render({ ...target }); expect(container.querySelector(".assistant-chat-history")).toBeNull(); expect(button("Tasks").getAttribute("aria-pressed")).toBe("true"); await click("Chat"); - expect(button("Ranger chat history").getAttribute("aria-expanded")).toBe( - "false", - ); + expect(container.querySelector(".assistant-chat-history")).toBeNull(); + expect(button("Ranger chat history").disabled).toBe(false); await click("Ranger settings"); await render({ ...target, taskId: "task-two", runId: "new-run" }); expect(container.querySelector(".assistant-panel-settings")).toBeNull(); @@ -1665,11 +1671,47 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { expect( container.querySelectorAll(".assistant-chat-toolbar button"), ).toHaveLength(2); + const conversation = () => + container.querySelector(".assistant-panel-list")!; + Object.defineProperties(conversation(), { + scrollHeight: { value: 800, configurable: true }, + clientHeight: { value: 200, configurable: true }, + }); + await React.act(async () => { + conversation().scrollTop = 120; + conversation().dispatchEvent(new Event("scroll")); + }); await React.act(async () => button("History").click()); expect( container.querySelector('[aria-label="Saved Ranger chats"]'), ).not.toBeNull(); + expect(container.querySelector(".assistant-chat-toolbar")).toBeNull(); + expect( + container.querySelector('[aria-label="Message Ranger"]'), + ).toBeNull(); await React.act(async () => button("Close Ranger chat history").click()); + expect(conversation().scrollTop).toBe(120); + expect( + container.querySelector('[aria-label="Message Ranger"]')?.textContent, + ).toBe("Next question"); + expect(document.activeElement?.getAttribute("aria-label")).toBe( + "Message Ranger", + ); + await React.act(async () => button("History").click()); + await React.act(async () => + document.activeElement!.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }), + ), + ); + expect(container.querySelector(".assistant-chat-history")).toBeNull(); + expect(close).toHaveBeenCalledTimes(1); + await React.act(async () => button("History").click()); + await React.act(async () => button("Chat").click()); + expect(container.querySelector(".assistant-chat-history")).toBeNull(); await React.act(async () => button("Tasks").click()); expect( container.querySelector('[aria-label="Ranger tasks"]'), @@ -1860,8 +1902,8 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { ); expect(send).not.toHaveBeenCalled(); await React.act(async () => button("Ranger settings").click()); - expect(button("Save connection").disabled).toBe(true); - expect(container.querySelectorAll("fieldset:disabled")).toHaveLength(2); + expect(button("Save settings").disabled).toBe(true); + expect(container.querySelector("fieldset:disabled")).not.toBeNull(); button("Refresh Ranger workspaces").focus(); terminalFrame(); expect(document.activeElement).toBe(button("Refresh Ranger workspaces")); @@ -2012,6 +2054,7 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { await React.act(async () => button("Ranger settings").click()); expect(container.querySelector(".assistant-panel-settings")).toBeNull(); await React.act(async () => button("Ranger settings").click()); + await React.act(async () => button("Add custom models").click()); const customForm = () => container.querySelector( '[aria-label="Custom model connection"]', @@ -2106,13 +2149,18 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { api_key: "synthetic-key", credential_source: "assistant", }); + expect(customForm()).toBeNull(); + expect(button("Save settings").disabled).toBe(false); + await React.act(async () => button("Edit custom models").click()); expect(customInput("Custom API key").value).toBe(""); expect(customInput("Custom API key").required).toBe(false); expect(customInput("Custom API base URL").value).toBe( "https://example.com/v1", ); expect(customInput("Custom model IDs").value).toBe("my-model"); - expect(button("Save connection").disabled).toBe(false); + expect( + container.querySelector('[aria-label="Save settings"]'), + ).toBeNull(); clientState.snapshot.providers[ clientState.snapshot.providers.length - 1 ]!.custom = { @@ -2144,7 +2192,7 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { base_url: "https://model-override.example.com/v1", api: "anthropic-messages", }); - await React.act(async () => button("Save connection").click()); + await React.act(async () => button("Save settings").click()); expect(call).toHaveBeenLastCalledWith("configure", { config: { ...clientState.snapshot.config, @@ -2153,11 +2201,11 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { }, }); await React.act(async () => button("Ranger settings").click()); + const modelDraft = button("Ranger model").textContent; const workspaceDraft = () => container.querySelector( ".assistant-workspaces input", )!; - const modelDraft = button("Ranger model").textContent; expect(workspaceDraft().checked).toBe(true); await React.act(async () => workspaceDraft().click()); expect(workspaceDraft().checked).toBe(false); @@ -2166,9 +2214,7 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { '[role="dialog"][aria-label="Enable high-permission mode?"]', ); const callsBeforeApproval = call.mock.calls.length; - await React.act(async () => - button("Enable high-permission mode").click(), - ); + await React.act(async () => button("High-permission mode").click()); expect(approvalDialog()).not.toBeNull(); expect(approvalDialog()!.textContent).toContain( "all current and future workspaces", @@ -2181,9 +2227,7 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { await React.act(async () => button("Cancel", approvalDialog()!).click()); expect(approvalDialog()).toBeNull(); expect(call).toHaveBeenCalledTimes(callsBeforeApproval); - await React.act(async () => - button("Enable high-permission mode").click(), - ); + await React.act(async () => button("High-permission mode").click()); await React.act(async () => button("Enable high-permission mode", approvalDialog()!).click(), ); @@ -2220,15 +2264,13 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { ".assistant-panel-title .assistant-panel-access", )?.textContent, ).toBe("Full access"); - expect( - button("Disable high-permission mode").getAttribute("aria-pressed"), - ).toBe("true"); + expect(button("High-permission mode").getAttribute("aria-checked")).toBe( + "true", + ); clientState.snapshot.running = true; await render(); - expect(button("Disable high-permission mode").disabled).toBe(false); - await React.act(async () => - button("Disable high-permission mode").click(), - ); + expect(button("High-permission mode").disabled).toBe(false); + await React.act(async () => button("High-permission mode").click()); expect(call).toHaveBeenLastCalledWith("configure_approval", { approval_mode: "manual", }); @@ -2242,10 +2284,8 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { })), }; await render(); - expect(button("Disable high-permission mode").disabled).toBe(false); - await React.act(async () => - button("Disable high-permission mode").click(), - ); + expect(button("High-permission mode").disabled).toBe(false); + await React.act(async () => button("High-permission mode").click()); expect(call).toHaveBeenLastCalledWith("configure_approval", { approval_mode: "manual", }); @@ -2255,7 +2295,7 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { approval_mode: "manual", }; await render(); - expect(button("Enable high-permission mode").disabled).toBe(true); + expect(button("High-permission mode").disabled).toBe(true); expect(workspaceDraft().checked).toBe(false); expect(container.querySelector(".assistant-panel-access")).toBeNull(); @@ -2272,7 +2312,7 @@ if (process.env.ROAMGATE_ASSISTANT_ACTION_DOM_TEST !== "1") { ).toBe("High permission: selected workspaces"); expect(workspaceDraft().checked).toBe(false); expect(workspaceDraft().disabled).toBe(false); - expect(button("Disable high-permission mode").disabled).toBe(false); + expect(button("High-permission mode").disabled).toBe(false); expect(button("Select all available Ranger workspaces")).toBeDefined(); await React.act(async () => button("Enable high permission for all workspaces").click(), @@ -2705,16 +2745,9 @@ test("assistant uses saved workspace permissions, guards IME sends, and hides wi credential_method: "api_key", }); states[0] = true; + states[15] = null; states[1] = { ...snapshot.config, model: "unsaved-model" }; render(); - expect( - elements.filter( - (item) => item.props.className === "assistant-provider-choice", - ), - ).toHaveLength(3); - expect(find("children", "Saved login (OAuth)")).toBeDefined(); - expect(find("children", "API key saved")).toBeDefined(); - expect(find("aria-label", "Model provider").props.role).toBe("group"); expect(find("aria-label", "Credential source").props.role).toBe("group"); expect( find("aria-label", "Credential source").props.options, @@ -2725,71 +2758,52 @@ test("assistant uses saved workspace permissions, guards IME sends, and hides wi expect( find("aria-label", "Shared Pi credentials").props["aria-pressed"], ).toBe(false); - expect(find("aria-label", "Model provider").props.options).toBeUndefined(); expect(find("aria-label", "Ranger model").props.disabled).toBe(false); - expect(find("className", "assistant-other-providers").props.open).toBe( - false, - ); - invoke("aria-label", "Search providers", "onChange", { - currentTarget: { value: " NOT-sa " }, - }); - expect( - elements.filter( - (item) => item.props.className === "assistant-provider-choice", - ), - ).toHaveLength(1); - expect(find("aria-label", "Select provider Not saved")).toBeDefined(); - expect(find("className", "assistant-other-providers").props.open).toBe( - true, - ); - expect(states[1]).toMatchObject({ - provider: "provider", - model: "unsaved-model", - }); - invoke("aria-label", "Search providers", "onChange", { - currentTarget: { value: " OTHER PROVIDER " }, - }); - expect(find("aria-label", "Select provider Other provider")).toBeDefined(); - invoke("aria-label", "Search providers", "onChange", { - currentTarget: { value: "no such provider" }, - }); - expect(find("children", "No providers match your search.").props.role).toBe( - "status", - ); - expect(states[1]).toMatchObject({ - provider: "provider", - model: "unsaved-model", - }); - invoke("aria-label", "Search providers", "onChange", { - currentTarget: { value: " " }, - }); - expect( - elements.filter( - (item) => item.props.className === "assistant-provider-choice", - ), - ).toHaveLength(3); - expect(find("className", "assistant-other-providers").props.open).toBe( - false, - ); - invoke("aria-label", "Search providers", "onChange", { - currentTarget: { value: "" }, - }); - invoke("aria-label", "Select provider Provider", "onClick"); + expect(elements.some((item) => item.type === "details")).toBe(false); + // One searchable picker lists saved credentials first with their status. + const providerPicker = () => find("aria-label", "Model provider"); + expect(providerPicker().props.searchPlaceholder).toBe("Search providers"); + expect(providerPicker().props.emptyText).toBe("No providers found"); + expect(providerPicker().props.options).toEqual([ + { + value: "provider", + label: "Provider", + detail: "Saved login (OAuth)", + keywords: ["provider"], + }, + { + value: "other", + label: "Other provider", + detail: "API key saved", + keywords: ["other"], + }, + { + value: "not-saved", + label: "Not saved", + detail: "No saved credentials", + keywords: ["not-saved"], + }, + ]); + const chooseProvider = (value: string) => { + (providerPicker().props.onChange as (value: string) => void)(value); + render(); + }; + chooseProvider("provider"); expect(states[1]).toMatchObject({ provider: "provider", model: "unsaved-model", }); - invoke("aria-label", "Select provider Other provider", "onClick"); + chooseProvider("other"); expect(states[1]).toMatchObject({ provider: "other", model: "" }); - expect(find("aria-label", "Ranger model").props.disabled).toBe(true); - expect(find("aria-label", "Ranger model").props.placeholder).toBe( - "No models available", - ); expect(snapshot.config.provider).toBe("provider"); expect(stop).not.toHaveBeenCalled(); invoke("aria-label", "Refresh Ranger credentials", "onClick"); expect(refresh).toHaveBeenCalledTimes(1); expect(stop).not.toHaveBeenCalled(); + expect(find("aria-label", "Ranger model").props.disabled).toBe(true); + expect(find("aria-label", "Ranger model").props.placeholder).toBe( + "No models available", + ); await ( find("aria-label", "Ranger connection").props .onClick as () => Promise @@ -2818,11 +2832,8 @@ test("assistant uses saved workspace permissions, guards IME sends, and hides wi for (const source of ["assistant", "pi"] as const) { states[1] = { ...snapshot.config, credential_source: source }; render(); - invoke("aria-label", "Select provider Not saved", "onClick"); - expect(find("aria-label", "Ranger model").props.disabled).toBe(true); - expect(find("className", "assistant-other-providers").props.open).toBe( - true, - ); + chooseProvider("not-saved"); + expect(states[1]).toMatchObject({ provider: "not-saved", model: "" }); invoke("aria-label", "Sign in to Not saved", "onClick"); await Promise.resolve(); await Promise.resolve(); @@ -2841,10 +2852,20 @@ test("assistant uses saved workspace permissions, guards IME sends, and hides wi stop.mockClear(); snapshot.running = true; render(); + const containsRefresh = (node: React.ReactNode): boolean => + React.Children.toArray(node).some( + (child) => + React.isValidElement>(child) && + (child.props["aria-label"] === "Refresh Ranger credentials" || + containsRefresh(child.props.children as React.ReactNode)), + ); expect( - find("aria-label", "Refresh Ranger credentials").props.disabled, + elements.find( + (item) => + item.type === "fieldset" && + containsRefresh(item.props.children as React.ReactNode), + )?.props.disabled, ).toBe(true); - snapshot.running = false; const offlineWorkspace = { connection_id: "offline", @@ -3013,9 +3034,6 @@ test("assistant uses saved workspace permissions, guards IME sends, and hides wi : null; states[5] = blocked === "busy"; render(); - expect(find("aria-label", "Ranger chat history").props.disabled).toBe( - true, - ); expect(find("title", "Older investigation").props.disabled).toBe(true); invoke("title", "Older investigation", "onClick"); expect(stop).not.toHaveBeenCalled(); @@ -3036,8 +3054,11 @@ test("assistant uses saved workspace permissions, guards IME sends, and hides wi await Promise.resolve(); render(); expect( - find("aria-label", "Ranger chat history").props["aria-expanded"], + elements.some( + (item) => item.props["aria-label"] === "Saved Ranger chats", + ), ).toBe(false); + expect(find("aria-label", "Ranger chat history")).toBeDefined(); const newChat = elements.find( (item) => item.type === "button" && diff --git a/web/src/components/AssistantPanel.tsx b/web/src/components/AssistantPanel.tsx index 14b97d30..82def6e4 100644 --- a/web/src/components/AssistantPanel.tsx +++ b/web/src/components/AssistantPanel.tsx @@ -10,7 +10,6 @@ import { PinOff, Plus, RefreshCw, - Search, Send, Settings, Square, @@ -26,6 +25,7 @@ import { import { ASSISTANT_MAX_CUSTOM_MODELS, ASSISTANT_MAX_WORKSPACES, + ASSISTANT_THINKING_LEVELS, type AssistantAction, type AssistantAuthState, type AssistantConfig, @@ -34,6 +34,7 @@ import { type AssistantModelConnection, type AssistantSnapshot, type AssistantSource, + type AssistantThinkingLevel, type AssistantWorkspace, type AssistantWorkspaceRef, } from "../../../shared/assistant"; @@ -62,6 +63,7 @@ import { AssistantChatControls } from "./AssistantChatControls"; import { sameAssistantSelection, thinkingForModel, + thinkingLabels, thinkingOptions, } from "../assistantModels"; import { AssistantConversationMap } from "./AssistantConversationMap"; @@ -157,6 +159,14 @@ function credentialStatus(provider: AssistantSnapshot["providers"][number]) { : "Credentials saved"; } +function endpointHost(url: string) { + try { + return new URL(url).host; + } catch { + return url; + } +} + const activityLabels = new Map([ ["workspace_status", "Workspace status"], ["workspace_diff", "Workspace changes"], @@ -261,25 +271,34 @@ function AssistantModelForm({ provider, providers, selectedModel, + adding, tasksRunning, + disabled, + onCancel, onSave, }: { config: AssistantConfig; provider: AssistantSnapshot["providers"][number] | undefined; providers: AssistantSnapshot["providers"]; selectedModel: AssistantSnapshot["models"][number] | undefined; + /** Adding starts with no IDs but keeps the selected custom endpoint. */ + adding: boolean; tasksRunning: boolean; + disabled: boolean; + onCancel(): void; onSave(input: AssistantModelConnection): Promise; }) { const custom = selectedModel?.custom ?? provider?.custom; const [providerId, setProviderId] = useState( custom ? (provider?.id ?? "") : "", ); - const [model, setModel] = useState(custom ? config.model : ""); + const [model, setModel] = useState(custom && !adding ? config.model : ""); const [baseUrl, setBaseUrl] = useState(custom?.base_url ?? ""); const [api, setApi] = useState(custom?.api ?? "openai-completions"); const [apiKey, setApiKey] = useState(""); - const [reasoning, setReasoning] = useState("preserve"); + const [thinkingLevels, setThinkingLevels] = useState< + AssistantThinkingLevel[] | undefined + >(); const models = [ ...new Set( model @@ -291,171 +310,233 @@ function AssistantModelForm({ const keySaved = providers.some( (item) => item.id === providerId.trim() && item.configured, ); - const savedReasoning = + const savedModel = providerId.trim() === selectedModel?.provider && models.length === 1 && models[0] === selectedModel.id - ? selectedModel.custom?.reasoning + ? selectedModel : undefined; + const supportedLevels = + thinkingLevels ?? + savedModel?.thinking_levels ?? + (savedModel?.custom?.reasoning + ? ASSISTANT_THINKING_LEVELS.slice(0, 5) + : ["off"]); return ( -
- - {custom ? "Edit custom models" : "Configure custom models"} - -
{ - event.preventDefault(); - if ( - tasksRunning || - !models.length || - models.length > ASSISTANT_MAX_CUSTOM_MODELS - ) - return; - if ( - await onSave({ - provider: providerId.trim(), - model: models[0]!, - ...(models.length > 1 ? { models } : {}), - base_url: baseUrl.trim(), - api, - ...(apiKey ? { api_key: apiKey } : {}), - ...(reasoning !== "preserve" - ? { reasoning: reasoning === "enabled" } - : {}), - credential_source: config.credential_source, - }) - ) { - setApiKey(""); - setReasoning("preserve"); - } - }} - > - - - -