diff --git a/web-ui/src/components/detail-panels/cline-agent-chat-panel.model-persistence.test.tsx b/web-ui/src/components/detail-panels/cline-agent-chat-panel.model-persistence.test.tsx new file mode 100644 index 000000000..481874bad --- /dev/null +++ b/web-ui/src/components/detail-panels/cline-agent-chat-panel.model-persistence.test.tsx @@ -0,0 +1,162 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RuntimeClineProviderModel, RuntimeClineReasoningEffort } from "@/runtime/types"; + +interface MockRuntimeSettingsState { + providerId: string; + modelId: string; + reasoningEffort: RuntimeClineReasoningEffort | ""; + providerModels: RuntimeClineProviderModel[]; + selectedModelSupportsReasoningEffort: boolean; + isLoadingProviderModels: boolean; + hasUnsavedChanges: boolean; + setModelId: ReturnType; + setReasoningEffort: ReturnType; + saveProviderSettings: ReturnType; +} + +const runtimeSettingsStateRef = vi.hoisted(() => ({ + current: null as MockRuntimeSettingsState | null, +})); + +vi.mock("@/hooks/use-runtime-settings-cline-controller", () => ({ + useRuntimeSettingsClineController: () => { + if (!runtimeSettingsStateRef.current) { + throw new Error("Expected runtime settings mock state."); + } + return runtimeSettingsStateRef.current; + }, +})); + +vi.mock("@/hooks/use-cline-chat-panel-controller", () => ({ + useClineChatPanelController: () => ({ + draft: "", + setDraft: vi.fn(), + messages: [], + error: null, + isSending: false, + canSend: true, + canCancel: false, + showReviewActions: false, + showAgentProgressIndicator: false, + showActionFooter: false, + showCancelAutomaticAction: false, + handleSendText: vi.fn(), + handleSendDraft: vi.fn(async () => true), + handleCancelTurn: vi.fn(), + }), +})); + +vi.mock("@/components/detail-panels/cline-chat-composer", () => ({ + ClineChatComposer: ({ onSelectModel }: { onSelectModel: (modelId: string) => void }) => ( +
+ +
+ ), +})); + +describe("ClineAgentChatPanel model persistence", () => { + let container: HTMLDivElement; + let root: Root; + let previousActEnvironment: boolean | undefined; + + beforeEach(() => { + runtimeSettingsStateRef.current = { + providerId: "anthropic", + modelId: "anthropic/claude-sonnet-4.6", + reasoningEffort: "", + providerModels: [], + selectedModelSupportsReasoningEffort: false, + isLoadingProviderModels: false, + hasUnsavedChanges: false, + setModelId: vi.fn(), + setReasoningEffort: vi.fn(), + saveProviderSettings: vi.fn(async () => ({ ok: true })), + }; + previousActEnvironment = (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT; + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + vi.restoreAllMocks(); + act(() => { + root.unmount(); + }); + container.remove(); + if (previousActEnvironment === undefined) { + delete (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + } else { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + previousActEnvironment; + } + }); + + it("persists detail picker changes to task settings even when the task was inheriting defaults", async () => { + const onTaskClineSettingsChanged = vi.fn(); + const { ClineAgentChatPanel } = await import("@/components/detail-panels/cline-agent-chat-panel"); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const selectModelButton = container.querySelector('[data-testid="select-model"]'); + expect(selectModelButton).toBeInstanceOf(HTMLButtonElement); + if (!(selectModelButton instanceof HTMLButtonElement)) { + throw new Error("Expected model selection button."); + } + + await act(async () => { + selectModelButton.click(); + await Promise.resolve(); + }); + + expect(runtimeSettingsStateRef.current?.setModelId).toHaveBeenCalledWith("anthropic/claude-opus-4.6"); + expect(onTaskClineSettingsChanged).toHaveBeenCalledWith({ + providerId: "anthropic", + modelId: "anthropic/claude-opus-4.6", + reasoningEffort: "", + }); + expect(runtimeSettingsStateRef.current?.saveProviderSettings).not.toHaveBeenCalled(); + }); + + it("falls back to saving workspace settings when no task override callback exists", async () => { + const { ClineAgentChatPanel } = await import("@/components/detail-panels/cline-agent-chat-panel"); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const selectModelButton = container.querySelector('[data-testid="select-model"]'); + expect(selectModelButton).toBeInstanceOf(HTMLButtonElement); + if (!(selectModelButton instanceof HTMLButtonElement)) { + throw new Error("Expected model selection button."); + } + + await act(async () => { + selectModelButton.click(); + await Promise.resolve(); + }); + + expect(runtimeSettingsStateRef.current?.saveProviderSettings).toHaveBeenCalledWith({ + modelId: "anthropic/claude-opus-4.6", + reasoningEffort: null, + }); + }); +}); diff --git a/web-ui/src/components/detail-panels/cline-agent-chat-panel.tsx b/web-ui/src/components/detail-panels/cline-agent-chat-panel.tsx index 1416b6e1b..bf97c6865 100644 --- a/web-ui/src/components/detail-panels/cline-agent-chat-panel.tsx +++ b/web-ui/src/components/detail-panels/cline-agent-chat-panel.tsx @@ -300,7 +300,9 @@ export const ClineAgentChatPanel = React.forwardRef { expect(requireSnapshot(latestSnapshot).hasUnsavedChanges).toBe(false); }); + it("uses the overridden provider default model when a task override omits modelId", async () => { + const config = createRuntimeConfigResponse({ + providerId: "anthropic", + modelId: "anthropic/claude-opus-4.7", + }); + let latestSnapshot: HookSnapshot | null = null; + fetchClineProviderCatalogMock.mockResolvedValue([ + { + id: "anthropic", + name: "Anthropic", + oauthSupported: false, + enabled: true, + defaultModelId: "anthropic/claude-opus-4.7", + baseUrl: null, + }, + { + id: "groq", + name: "Groq", + oauthSupported: false, + enabled: true, + defaultModelId: "groq/llama-4-maverick", + baseUrl: null, + }, + ]); + fetchClineProviderModelsMock.mockResolvedValue([ + { + id: "groq/llama-4-maverick", + name: "Llama 4 Maverick", + contextWindow: null, + maxOutputTokens: null, + supportsReasoningEffort: false, + }, + ]); + + await act(async () => { + root.render( + { + latestSnapshot = snapshot; + }} + />, + ); + await flushAsyncWork(); + }); + + await act(async () => { + await flushAsyncWork(); + }); + + expect(requireSnapshot(latestSnapshot).providerId).toBe("groq"); + expect(requireSnapshot(latestSnapshot).modelId).toBe("groq/llama-4-maverick"); + expect(requireSnapshot(latestSnapshot).hasUnsavedChanges).toBe(false); + }); + + it("does not transiently mark provider-default task overrides dirty while the provider catalog loads", async () => { + const config = createRuntimeConfigResponse({ + providerId: "anthropic", + modelId: "anthropic/claude-opus-4.7", + }); + const catalogDeferred = createDeferred(); + const snapshots: HookSnapshot[] = []; + fetchClineProviderCatalogMock.mockReturnValue(catalogDeferred.promise); + fetchClineProviderModelsMock.mockResolvedValue([ + { + id: "groq/llama-4-maverick", + name: "Llama 4 Maverick", + contextWindow: null, + maxOutputTokens: null, + supportsReasoningEffort: false, + }, + ]); + + await act(async () => { + root.render( + { + snapshots.push(snapshot); + }} + />, + ); + await flushAsyncWork(); + }); + + expect(snapshots.at(-1)?.providerId).toBe("groq"); + expect(snapshots.at(-1)?.modelId).toBe(""); + expect(snapshots.at(-1)?.hasUnsavedChanges).toBe(false); + + await act(async () => { + catalogDeferred.resolve([ + { + id: "anthropic", + name: "Anthropic", + oauthSupported: false, + enabled: true, + defaultModelId: "anthropic/claude-opus-4.7", + baseUrl: null, + }, + { + id: "groq", + name: "Groq", + oauthSupported: false, + enabled: true, + defaultModelId: "groq/llama-4-maverick", + baseUrl: null, + }, + ]); + await flushAsyncWork(); + await flushAsyncWork(); + }); + + expect( + snapshots.some( + (snapshot) => snapshot.providerId === "groq" && snapshot.modelId === "" && snapshot.hasUnsavedChanges, + ), + ).toBe(false); + expect(snapshots.at(-1)?.modelId).toBe("groq/llama-4-maverick"); + expect(snapshots.at(-1)?.hasUnsavedChanges).toBe(false); + }); + it("treats an explicit task-level default reasoning override as the clean baseline", async () => { const config = createRuntimeConfigResponse({ providerId: "openrouter", diff --git a/web-ui/src/hooks/use-runtime-settings-cline-controller.ts b/web-ui/src/hooks/use-runtime-settings-cline-controller.ts index 443fac07d..19a9ef48e 100644 --- a/web-ui/src/hooks/use-runtime-settings-cline-controller.ts +++ b/web-ui/src/hooks/use-runtime-settings-cline-controller.ts @@ -198,6 +198,48 @@ function getDefaultModelIdForProvider(providers: RuntimeClineProviderCatalogItem ); } +function getInitialTaskModelId( + providerCatalog: RuntimeClineProviderCatalogItem[], + effectiveProviderSettings: RuntimeClineProviderSettings, + taskClineSettings?: RuntimeTaskClineSettings, +): string { + const taskModelId = taskClineSettings?.modelId?.trim(); + if (taskModelId) { + return taskModelId; + } + const taskProviderId = taskClineSettings?.providerId?.trim(); + if (taskProviderId) { + return getDefaultModelIdForProvider(providerCatalog, taskProviderId); + } + return effectiveProviderSettings.modelId ?? ""; +} + +function getComparableModelId( + providers: RuntimeClineProviderCatalogItem[], + providerId: string, + modelId: string | null | undefined, +): string { + const normalizedModelId = modelId?.trim() ?? ""; + if (normalizedModelId.length > 0) { + return normalizedModelId; + } + return getDefaultModelIdForProvider(providers, providerId); +} + +function getResetModelId( + configProviderSettings: RuntimeClineProviderSettings, + taskClineSettings?: RuntimeTaskClineSettings, +): string { + const taskModelId = taskClineSettings?.modelId?.trim(); + if (taskModelId) { + return taskModelId; + } + if (taskClineSettings?.providerId?.trim()) { + return ""; + } + return configProviderSettings.modelId ?? ""; +} + export function useRuntimeSettingsClineController( options: UseRuntimeSettingsClineControllerOptions, ): UseRuntimeSettingsClineControllerResult { @@ -237,7 +279,11 @@ export function useRuntimeSettingsClineController( effectiveProviderSettings?.providerId || effectiveProviderSettings?.oauthProvider || ""; - const initialModelId = taskClineSettings?.modelId || effectiveProviderSettings?.modelId || ""; + const initialModelId = getInitialTaskModelId( + providerCatalog, + effectiveProviderSettings ?? getRuntimeClineProviderSettings(null), + taskClineSettings, + ); const initialBaseUrl = resolveBaseUrlForProvider( providerCatalog, initialProviderId, @@ -246,6 +292,8 @@ export function useRuntimeSettingsClineController( const initialReasoningEffort = hasTaskClineSettingsOverride ? (taskClineSettings?.reasoningEffort ?? "") : (effectiveProviderSettings?.reasoningEffort ?? ""); + const comparableInitialModelId = getComparableModelId(providerCatalog, initialProviderId, initialModelId); + const comparableCurrentModelId = getComparableModelId(providerCatalog, providerId, modelId); const normalizedProviderId = providerId.trim().toLowerCase(); const managedOauthProvider = toManagedClineOauthProvider(normalizedProviderId); const isOauthProviderSelected = managedOauthProvider !== null; @@ -282,7 +330,7 @@ export function useRuntimeSettingsClineController( if (providerId.trim() !== initialProviderId.trim()) { return true; } - if (modelId.trim() !== initialModelId.trim()) { + if (comparableCurrentModelId !== comparableInitialModelId) { return true; } if (baseUrl.trim() !== initialBaseUrl.trim()) { @@ -317,11 +365,11 @@ export function useRuntimeSettingsClineController( config, gcpProjectId, gcpRegion, + comparableCurrentModelId, + comparableInitialModelId, initialBaseUrl, - initialModelId, initialProviderId, initialReasoningEffort, - modelId, providerId, region, reasoningEffort, @@ -335,7 +383,7 @@ export function useRuntimeSettingsClineController( taskClineSettings?.providerId || (configProviderSettings.providerId ?? configProviderSettings.oauthProvider ?? ""); setProviderId(nextProviderId); - setModelId(taskClineSettings?.modelId || (configProviderSettings.modelId ?? "")); + setModelId(getResetModelId(configProviderSettings, taskClineSettings)); setApiKey(""); setBaseUrl(resolveBaseUrlForProvider(providerCatalog, nextProviderId, configProviderSettings.baseUrl)); setRegion(""); diff --git a/web-ui/src/hooks/use-task-sessions.test.tsx b/web-ui/src/hooks/use-task-sessions.test.tsx index 538a7bcf5..0dcd784eb 100644 --- a/web-ui/src/hooks/use-task-sessions.test.tsx +++ b/web-ui/src/hooks/use-task-sessions.test.tsx @@ -30,7 +30,7 @@ interface HookSnapshot { startTaskSession: ReturnType["startTaskSession"]; } -function createTask(): BoardCard { +function createTask(overrides: Partial = {}): BoardCard { return { id: "task-1", title: "Resume me", @@ -41,6 +41,7 @@ function createTask(): BoardCard { baseRef: "main", createdAt: 1, updatedAt: 1, + ...overrides, }; } @@ -67,13 +68,13 @@ describe("useTaskSessions", () => { beforeEach(() => { startTaskSessionMutateMock.mockReset(); trackTaskResumedFromTrashMock.mockReset(); - startTaskSessionMutateMock.mockResolvedValue({ + startTaskSessionMutateMock.mockImplementation(async (input: { taskId: string; agentId?: string }) => ({ ok: true, summary: { - taskId: "task-1", + taskId: input.taskId, state: "running", - agentId: "codex", - workspacePath: "/tmp/task-1", + agentId: input.agentId ?? "codex", + workspacePath: `/tmp/${input.taskId}`, pid: 123, startedAt: 1, updatedAt: 1, @@ -83,7 +84,7 @@ describe("useTaskSessions", () => { lastHookAt: null, latestHookActivity: null, }, - }); + })); previousActEnvironment = (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) .IS_REACT_ACT_ENVIRONMENT; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -309,4 +310,76 @@ describe("useTaskSessions", () => { }), ); }); + + it("keeps each task start bound to its own provider and model overrides", async () => { + let latestSnapshot: HookSnapshot | null = null; + + await act(async () => { + root.render( + { + latestSnapshot = snapshot; + }} + />, + ); + }); + + if (latestSnapshot === null) { + throw new Error("Expected a hook snapshot."); + } + + await act(async () => { + await latestSnapshot?.startTaskSession( + createTask({ + id: "task-anthropic", + title: "Anthropic task", + prompt: "Use Anthropic", + agentId: "cline", + clineSettings: { + providerId: "anthropic", + modelId: "anthropic/claude-opus-4.6", + }, + }), + ); + await latestSnapshot?.startTaskSession( + createTask({ + id: "task-groq", + title: "Groq task", + prompt: "Use Groq", + agentId: "cline", + clineSettings: { + providerId: "groq", + modelId: "groq/llama-4-maverick", + reasoningEffort: "medium", + }, + }), + ); + }); + + expect(startTaskSessionMutateMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + taskId: "task-anthropic", + taskTitle: "Anthropic task", + agentId: "cline", + clineSettings: { + providerId: "anthropic", + modelId: "anthropic/claude-opus-4.6", + }, + }), + ); + expect(startTaskSessionMutateMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + taskId: "task-groq", + taskTitle: "Groq task", + agentId: "cline", + clineSettings: { + providerId: "groq", + modelId: "groq/llama-4-maverick", + reasoningEffort: "medium", + }, + }), + ); + }); }); diff --git a/web-ui/src/state/board-state.test.ts b/web-ui/src/state/board-state.test.ts index a22af2940..be52ea831 100644 --- a/web-ui/src/state/board-state.test.ts +++ b/web-ui/src/state/board-state.test.ts @@ -704,6 +704,41 @@ describe("board dependency state", () => { expect(unchangedTask?.clineSettings).toBeUndefined(); }); + it("materializes task model overrides for inherited tasks when saving chat picker changes", () => { + let board = createInitialBoardData(); + board = addTaskToColumn(board, "backlog", { + prompt: "Task inheriting workspace Cline settings", + baseRef: "main", + }); + const task = board.columns.find((column) => column.id === "backlog")?.cards[0]; + expect(task).toBeDefined(); + if (!task) { + throw new Error("Expected backlog task to exist"); + } + + const result = applyTaskDetailClineSettingsChange( + board, + task.id, + { + providerId: "anthropic", + modelId: "anthropic/claude-opus-4.6", + reasoningEffort: "", + }, + { + providerId: "anthropic", + modelId: "anthropic/claude-sonnet-4.6", + }, + ); + + expect(result.updated).toBe(true); + const updatedTask = result.board.columns.find((column) => column.id === "backlog")?.cards[0]; + expect(updatedTask?.agentId).toBe("cline"); + expect(updatedTask?.clineSettings).toEqual({ + providerId: "anthropic", + modelId: "anthropic/claude-opus-4.6", + }); + }); + it("updates task model overrides when the task already has explicit task-level settings", () => { let board = createInitialBoardData(); board = addTaskToColumn(board, "backlog", { @@ -829,6 +864,79 @@ describe("board dependency state", () => { }); }); + it("keeps task-level provider and model changes isolated across tasks", () => { + let board = createInitialBoardData(); + board = addTaskToColumn(board, "backlog", { + prompt: "Task using Anthropic", + agentId: "cline", + clineSettings: { + providerId: "anthropic", + modelId: "anthropic/claude-sonnet-4.6", + }, + baseRef: "main", + }); + board = addTaskToColumn(board, "backlog", { + prompt: "Task using Groq", + agentId: "cline", + clineSettings: { + providerId: "groq", + modelId: "groq/llama-3.3-70b-versatile", + }, + baseRef: "main", + }); + + const [anthropicTask, groqTask] = board.columns.find((column) => column.id === "backlog")?.cards ?? []; + expect(anthropicTask).toBeDefined(); + expect(groqTask).toBeDefined(); + if (!anthropicTask || !groqTask) { + throw new Error("Expected both backlog tasks to exist"); + } + + const anthropicUpdate = applyTaskDetailClineSettingsChange( + board, + anthropicTask.id, + { + providerId: "anthropic", + modelId: "anthropic/claude-opus-4.7", + reasoningEffort: "", + }, + { + providerId: "anthropic", + modelId: "anthropic/claude-sonnet-4.6", + }, + ); + expect(anthropicUpdate.updated).toBe(true); + + const groqUpdate = applyTaskDetailClineSettingsChange( + anthropicUpdate.board, + groqTask.id, + { + providerId: "groq", + modelId: "groq/llama-4-maverick", + reasoningEffort: "medium", + }, + { + providerId: "anthropic", + modelId: "anthropic/claude-sonnet-4.6", + }, + ); + expect(groqUpdate.updated).toBe(true); + + const updatedBacklogTasks = groqUpdate.board.columns.find((column) => column.id === "backlog")?.cards ?? []; + const updatedAnthropicTask = updatedBacklogTasks.find((task) => task.id === anthropicTask.id); + const updatedGroqTask = updatedBacklogTasks.find((task) => task.id === groqTask.id); + + expect(updatedAnthropicTask?.clineSettings).toEqual({ + providerId: "anthropic", + modelId: "anthropic/claude-opus-4.7", + }); + expect(updatedGroqTask?.clineSettings).toEqual({ + providerId: "groq", + modelId: "groq/llama-4-maverick", + reasoningEffort: "medium", + }); + }); + it("keeps tasks pinned to cline when the global selected agent is different", () => { let board = createInitialBoardData(); board = addTaskToColumn(board, "backlog", { diff --git a/web-ui/src/state/board-state.ts b/web-ui/src/state/board-state.ts index fa44cbb3d..1a1b47cdc 100644 --- a/web-ui/src/state/board-state.ts +++ b/web-ui/src/state/board-state.ts @@ -628,25 +628,25 @@ export function applyTaskDetailClineSettingsChange( return { board, updated: false }; } - const hasExplicitTaskAgentSettings = - selection.card.agentId === "cline" || selection.card.clineSettings !== undefined; - if (!hasExplicitTaskAgentSettings) { - return { board, updated: false }; - } - const nextTaskProviderId = change.providerId.trim() || defaults.providerId?.trim() || ""; const nextTaskModelId = change.modelId.trim() || defaults.modelId?.trim() || ""; if (!nextTaskProviderId || !nextTaskModelId) { return { board, updated: false }; } - return applyTaskDetailClineSettingsSelection(board, taskId, { + return updateTask(board, taskId, { + prompt: selection.card.prompt, + startInPlanMode: selection.card.startInPlanMode, + autoReviewEnabled: selection.card.autoReviewEnabled, + autoReviewMode: selection.card.autoReviewMode, + images: selection.card.images, agentId: "cline", clineSettings: { providerId: nextTaskProviderId, modelId: nextTaskModelId, ...(change.reasoningEffort ? { reasoningEffort: change.reasoningEffort } : {}), }, + baseRef: selection.card.baseRef, }); }