diff --git a/.changeset/fn-7907-chat-default.md b/.changeset/fn-7907-chat-default.md new file mode 100644 index 0000000000..0cb69394c3 --- /dev/null +++ b/.changeset/fn-7907-chat-default.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a project Chat default (model or agent) with prompt-each-time or always-use-default New Chat behavior. +category: feature +dev: Adds project chatNewSessionMode/chatDefaultKind/chatDefaultAgentId/chatDefaultModelProvider/chatDefaultModelId/chatDefaultThinkingLevel settings, a Project Models "Chat" subsection, and a shared ChatView handleNewChat() flow. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f113402a88..0196b2241b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -515,7 +515,8 @@ Chat view provides project-scoped conversations with agents. - Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model - On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome. -- In the New Chat dialog's **Model** mode, the model picker includes a **Thinking Level** selector. Choosing **Default** leaves the session unset so Fusion uses the project/global reasoning-effort default; choosing a concrete level stores it on that chat session and applies it to model-loop replies. The Default label shows the current resolved default (for example **Default (medium)**) and falls back to **Default (off)** when no default is configured. +- **Settings → Project Models → Chat** controls New Chat defaults per project. Choose a default target kind (**Model** with provider/model and optional Thinking Level, or **Agent** with a durable agent id) and a mode: **Prompt for model each time** opens the New Chat dialog with that default preselected, while **Always use configured default** creates the session immediately from the resolved default. If the configured target is incomplete or missing, New Chat falls back to the dialog instead of creating an unroutable session. +- In the New Chat dialog's **Model** mode, the model picker includes a **Thinking Level** selector. Choosing **Default** leaves the session unset so Fusion uses the project/global reasoning-effort default; choosing a concrete level stores it on that chat session and applies to model-loop replies. The Default label shows the current resolved default (for example **Default (medium)**) and falls back to **Default (off)** when no default is configured. - A small **Brain**-icon button next to the composer's attach button lets you change an already-created direct chat session's thinking level mid-conversation, without starting a new chat. It opens a popup listing the six thinking levels plus **Default** (clear/inherit, labeled with the current resolved default such as **Default (medium)**); the selection persists immediately and applies starting with the session's next send. This control appears only for model-loop (non-CLI) direct sessions — it is not shown for CLI-agent-backed sessions or in Chat Rooms, neither of which support a per-session thinking level. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 77ed312bd0..528eeb8398 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -697,6 +697,12 @@ GitLab configuration examples: leave both URL fields blank for GitLab.com (`http | `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). | | `quickChatCloseOnOutsideClick` | `boolean` | `true` | Close the desktop Quick Chat floating window when clicking outside it; disable to keep it open until explicitly closed. | | `chatAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-cleanup retention window for idle chat sessions and chat rooms. `0` is off (default). When enabled, periodic self-healing maintenance deletes rows with `updatedAt` older than the configured day window. | +| `chatNewSessionMode` | `"prompt" \| "always-default"` | `undefined` | Project Direct-chat New Chat behavior. Unset/`"prompt"` opens the New Chat picker with any configured chat default preselected; `"always-default"` creates a session immediately when `chatDefault*` resolves to a complete model or agent target. | +| `chatDefaultKind` | `"model" \| "agent"` | `undefined` | Project Direct-chat default target kind. `"agent"` uses `chatDefaultAgentId`; `"model"` uses `chatDefaultModelProvider` + `chatDefaultModelId`. Incomplete defaults fall back to the picker. | +| `chatDefaultAgentId` | `string` | `undefined` | Durable agent id used by New Chat when `chatDefaultKind` is `"agent"`. | +| `chatDefaultModelProvider` | `string` | `undefined` | Provider for the model-mode Direct-chat default; must pair with `chatDefaultModelId`. | +| `chatDefaultModelId` | `string` | `undefined` | Model id for the model-mode Direct-chat default; must pair with `chatDefaultModelProvider`. | +| `chatDefaultThinkingLevel` | `ThinkingLevel` | `undefined` | Optional thinking-level override for model-mode New Chat defaults. Empty/unset inherits the resolved project/global default thinking level. | | `mailAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-prune retention window for inbox/outbox mail messages. `0` is off (default). When enabled, periodic self-healing maintenance deletes `messages` rows where `updatedAt < cutoff` for the configured day window. Suggested setting: `7`. | | `operationalLogRetentionDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`), terminal `agentRuns` rows (by `endedAt`), and `agentConfigRevisions` (by `createdAt`). `0` is off. Lower values mean Reliability metrics/charts and the Activity feed will not show history older than the configured window; per-task task detail history is unaffected. Periodic maintenance prunes timestamped operational-log rows older than this many days while always preserving in-flight `agentRuns` (`endedAt IS NULL`) and the most-recent `agentConfigRevisions` row per agent. | | `agentLogFileRetentionDays` | `number` | `0` | Retention window for per-task `.fusion/tasks/{ID}/agent-log.jsonl` files after a task is soft-deleted or archived. Periodic maintenance removes JSONL entries older than this many days; active tasks are never pruned. Set `0` to disable pruning. | @@ -979,6 +985,8 @@ Short-lived token bounds are enforced server-side: Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models. +Direct-chat defaults are project-scoped and independent of task workflow lanes. Configure them in **Settings -> Project Models -> Chat**. `chatDefaultKind: "agent"` resolves only when `chatDefaultAgentId` is set; `chatDefaultKind: "model"` resolves only when both `chatDefaultModelProvider` and `chatDefaultModelId` are set, with optional `chatDefaultThinkingLevel`. If `chatNewSessionMode` is `"always-default"` and that target resolves, every New Chat entry point creates the session directly. If the target is incomplete, or the mode is unset/`"prompt"`, Fusion opens the New Chat dialog instead and preselects the resolved default when one exists. + Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Primary workflow lanes declare `executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel` per `(workflow, project)`; planning/reviewer fallback lanes declare `planningFallbackThinkingLevel` and `validatorFallbackThinkingLevel`; global fallback uses `fallbackThinkingLevel`; and project title summarization fallback uses `titleSummarizerFallbackThinkingLevel`. Empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence for task/workflow execution is node/step `config.thinkingLevel` > task `thinkingLevel` > workflow lane thinking override > global lane thinking override > project default thinking override > global `defaultThinkingLevel`. Model-mode Chat sessions use the same executor-lane resolver with session `thinkingLevel` in the task slot, so an empty chat-session value inherits project/global defaults while a concrete New Chat selection wins for that session. The resolved value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters). When the planning lane has neither `planningFallback*` nor a global `fallback*` pair configured, triage now derives an **implicit fallback** from the resolved project/global default (execution) model (FN-7719). This lets a retryable primary planner-model failure (e.g. a provider 404/429) recover via one distinct swap instead of permanently failing triage with "no fallback configured" — the operator's chosen primary planner lane is unchanged, and the implicit fallback is skipped when it would equal the primary model or when test mode is active. diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 4f93ad7d2d..ef454c8e21 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -170,6 +170,26 @@ describe("settings key parity", () => { expect(isGlobalSettingsKey("chatAutoCleanupDays")).toBe(false); }); + it("keeps Direct-chat default target settings project-scoped with unset defaults", () => { + const chatDefaultKeys = [ + "chatNewSessionMode", + "chatDefaultKind", + "chatDefaultAgentId", + "chatDefaultModelProvider", + "chatDefaultModelId", + "chatDefaultThinkingLevel", + ] as const; + + for (const key of chatDefaultKeys) { + expect(DEFAULT_PROJECT_SETTINGS[key]).toBeUndefined(); + expect(isProjectSettingsKey(key)).toBe(true); + expect(PROJECT_SETTINGS_KEYS).toContain(key); + expect(isGlobalSettingsKey(key)).toBe(false); + expect(isGlobalOnlySettingsKey(key)).toBe(false); + expect(GLOBAL_SETTINGS_KEYS).not.toContain(key); + } + }); + it("keeps room compaction defaults project-scoped with expanded retention", () => { expect(DEFAULT_PROJECT_SETTINGS.chatRoomRecentVerbatimMessages).toBe(25); expect(DEFAULT_PROJECT_SETTINGS.chatRoomCompactionFetchLimit).toBe(200); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index d000aee294..1bafab749c 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -641,6 +641,12 @@ export const DEFAULT_PROJECT_SETTINGS = { */ showTaskChatsInCommonFeed: false, chatAutoCleanupDays: 0, + chatNewSessionMode: undefined, + chatDefaultKind: undefined, + chatDefaultAgentId: undefined, + chatDefaultModelProvider: undefined, + chatDefaultModelId: undefined, + chatDefaultThinkingLevel: undefined, mailAutoCleanupDays: 0, operationalLogRetentionDays: 30, agentLogFileRetentionDays: 0, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3973133163..6ff937b5ef 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4119,6 +4119,21 @@ export interface ProjectSettings { * Optional project default-lane thinking override used when a task does not set its own thinking level. */ defaultThinkingLevelOverride?: ThinkingLevel; + /** + * FNXC:ChatModels 2026-07-12-20:45: + * Projects can pin a default Direct-chat target as either a model pair with optional thinking level or a durable agent, then choose whether New Chat prompts with that default preselected or creates the session immediately. + */ + chatNewSessionMode?: "prompt" | "always-default"; + /** Which configured default target kind New Chat should use or preselect. */ + chatDefaultKind?: "model" | "agent"; + /** Durable agent id used when `chatDefaultKind === "agent"`. */ + chatDefaultAgentId?: string; + /** Model provider used when `chatDefaultKind === "model"`; must be paired with `chatDefaultModelId`. */ + chatDefaultModelProvider?: string; + /** Model id used when `chatDefaultKind === "model"`; must be paired with `chatDefaultModelProvider`. */ + chatDefaultModelId?: string; + /** Optional thinking-level override for the model chat default; undefined inherits the resolved project/global default. */ + chatDefaultThinkingLevel?: ThinkingLevel; /** Project-level AI model provider for task execution (executor agent). * This is the execution lane that overrides the global `executionGlobalProvider`. * Must be set together with `executionModelId`. Falls back to diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 74873f4fba..33521d079d 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -280,16 +280,19 @@ export function resolveSessionProvider( interface NewChatDialogProps { projectId?: string; defaultModel: DefaultModelSelection; + defaultKind?: "model" | "agent"; + defaultAgentId?: string; defaultThinkingLevel?: string; + defaultSelectedThinkingLevel?: string; onClose: () => void; onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }) => void; } -function NewChatDialog({ projectId, defaultModel, defaultThinkingLevel, onClose, onCreate }: NewChatDialogProps) { +function NewChatDialog({ projectId, defaultModel, defaultKind, defaultAgentId, defaultThinkingLevel, defaultSelectedThinkingLevel, onClose, onCreate }: NewChatDialogProps) { const { t } = useTranslation("app"); - const [chatMode, setChatMode] = useState<"agent" | "model">("agent"); + const [chatMode, setChatMode] = useState<"agent" | "model">(defaultKind ?? "agent"); const { agents, loading: agentsLoading } = useAgentsMapCache(projectId); - const [selectedAgentId, setSelectedAgentId] = useState(""); + const [selectedAgentId, setSelectedAgentId] = useState(defaultAgentId ?? ""); const { models, favoriteProviders: cachedFavoriteProviders, favoriteModels: cachedFavoriteModels, loading: modelsLoading, refresh } = useModelsCache(); const defaultModelValue = defaultModel.provider && defaultModel.modelId ? `${defaultModel.provider}/${defaultModel.modelId}` @@ -299,7 +302,7 @@ function NewChatDialog({ projectId, defaultModel, defaultThinkingLevel, onClose, * FNXC:Chat-ThinkingLevel 2026-07-10-00:00: * New model-mode chats expose the shared inline thinking selector; an empty value means Default and is omitted from the create-session payload so the backend resolves project/global reasoning effort. */ - const [thinkingLevel, setThinkingLevel] = useState(""); + const [thinkingLevel, setThinkingLevel] = useState(defaultSelectedThinkingLevel ?? ""); const [favoriteProviders, setFavoriteProviders] = useState(cachedFavoriteProviders); const [favoriteModels, setFavoriteModels] = useState(cachedFavoriteModels); @@ -311,6 +314,19 @@ function NewChatDialog({ projectId, defaultModel, defaultThinkingLevel, onClose, setFavoriteModels(cachedFavoriteModels); }, [cachedFavoriteModels]); + useEffect(() => { + if (defaultKind) { + setChatMode(defaultKind); + } + }, [defaultKind]); + + useEffect(() => { + if (!defaultAgentId) { + return; + } + setSelectedAgentId((current) => current || defaultAgentId); + }, [defaultAgentId]); + useEffect(() => { if (!defaultModelValue) { return; @@ -318,6 +334,13 @@ function NewChatDialog({ projectId, defaultModel, defaultThinkingLevel, onClose, setSelectedModel((current) => current || defaultModelValue); }, [defaultModelValue]); + useEffect(() => { + if (!defaultSelectedThinkingLevel) { + return; + } + setThinkingLevel((current) => current || defaultSelectedThinkingLevel); + }, [defaultSelectedThinkingLevel]); + const handleToggleFavorite = useCallback(async (provider: string) => { const currentFavorites = favoriteProviders; const isFavorite = currentFavorites.includes(provider); @@ -511,6 +534,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout */ useEffect(() => { let cancelled = false; + setChatSettings(null); fetchSettings(projectId) .then((settings) => { if (!cancelled) { @@ -527,6 +551,27 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout }; }, [projectId]); const resolvedDefaultThinkingLevel = chatSettings?.defaultThinkingLevel ?? "off"; + const chatDefaultTarget = useMemo(() => { + /* + FNXC:ChatModels 2026-07-12-20:45: + New Chat has one project-scoped default target resolver shared by every affordance. A complete agent default wins only when kind=agent; a complete model pair wins only when kind=model; incomplete always-default settings fall back to the picker instead of creating an unroutable session. + */ + if (chatSettings?.chatDefaultKind === "agent" && chatSettings.chatDefaultAgentId) { + return { + kind: "agent" as const, + agentId: chatSettings.chatDefaultAgentId, + }; + } + if (chatSettings?.chatDefaultKind === "model" && chatSettings.chatDefaultModelProvider && chatSettings.chatDefaultModelId) { + return { + kind: "model" as const, + modelProvider: chatSettings.chatDefaultModelProvider, + modelId: chatSettings.chatDefaultModelId, + thinkingLevel: chatSettings.chatDefaultThinkingLevel, + }; + } + return null; + }, [chatSettings]); const { activeSession, @@ -594,6 +639,12 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const agentsMap = useMemo(() => (chatAgentsMap.size > 0 ? chatAgentsMap : cachedAgentsMap), [cachedAgentsMap, chatAgentsMap]); const { models, defaultProvider, defaultModelId } = useModelsCache(); const defaultModel = useMemo(() => ({ provider: defaultProvider, modelId: defaultModelId }), [defaultModelId, defaultProvider]); + const dialogDefaultModel = useMemo(() => { + if (chatDefaultTarget?.kind === "model") { + return { provider: chatDefaultTarget.modelProvider, modelId: chatDefaultTarget.modelId }; + } + return defaultModel; + }, [chatDefaultTarget, defaultModel]); const { skills: discoveredSkills, loading: skillsLoading } = useDiscoveredSkillsCache(projectId); const [showSkillMenu, setShowSkillMenu] = useState(false); const [skillFilter, setSkillFilter] = useState(""); @@ -1486,9 +1537,26 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout addToast(t("chat.failedToCreateSession", "Failed to create chat session"), "error"); } }, - [createSession, addToast, isChatMobile], + [createSession, addToast, isChatMobile, t], ); + const handleNewChat = useCallback(() => { + if (chatSettings?.chatNewSessionMode === "always-default" && chatDefaultTarget) { + if (chatDefaultTarget.kind === "agent") { + void handleCreateSession({ agentId: chatDefaultTarget.agentId }); + return; + } + void handleCreateSession({ + agentId: FN_AGENT_ID, + modelProvider: chatDefaultTarget.modelProvider, + modelId: chatDefaultTarget.modelId, + thinkingLevel: chatDefaultTarget.thinkingLevel, + }); + return; + } + setShowNewDialog(true); + }, [chatDefaultTarget, chatSettings?.chatNewSessionMode, handleCreateSession]); + const resizeComposer = useCallback((textarea?: HTMLTextAreaElement | null) => { const composer = textarea ?? inputRef.current; if (!composer) { @@ -2252,7 +2320,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout

{t("chat.startNewConversation", "Start a new conversation")}

- @@ -2850,7 +2918,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout ))} {/* FNXC:Chat 2026-06-27-00:00: - Mobile Direct-scope quick session switching must let users start a new chat without leaving the open thread. Route this affordance through the same setShowNewDialog(true) / NewChatDialog path as the header and sidebar-footer controls. + Mobile Direct-scope quick session switching must let users start a new chat without leaving the open thread. Route this affordance through the same handleNewChat() path as the header and sidebar-footer controls so project chatNewSessionMode is honored everywhere. */} + +
+ + {chatDefaultKind === "model" ? (
+ +
+
+ +
+ {chatDefaultCustomized && ()} +
+ {t("settings.projectModels.chatDefaultModelHelp", "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.")} +
) : (
+ +
+
+ +
+ {chatDefaultCustomized && ()} +
+ {agents.length === 0 && !agentsLoading ? t("settings.projectModels.chatDefaultAgentEmpty", "No agents are available for this project yet.") : t("settings.projectModels.chatDefaultAgentHelp", "Agent-mode New Chat starts a Direct chat with the selected durable agent.")} +
)} + {/* --- Default workflow model lanes --- */}

{t("settings.projectModels.defaultWorkflowModelLanes", "Default workflow model lanes")}

diff --git a/packages/dashboard/app/components/settings/sections/__tests__/ProjectModelsSection.chatDefault.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/ProjectModelsSection.chatDefault.test.tsx new file mode 100644 index 0000000000..7d013380df --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/ProjectModelsSection.chatDefault.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment jsdom +import { useState } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ProjectModelsSection } from "../ProjectModelsSection"; +import type { ProjectModelsSectionModelProps } from "../ProjectModelsSection"; +import type { SettingsFormState } from "../context"; +import { fetchWorkflow, fetchWorkflowSettingValues, updateWorkflowSettingValues } from "../../../../api"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback: string) => fallback, + }), +})); + +vi.mock("../../../../api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWorkflow: vi.fn(async () => ({ id: "builtin:coding", name: "Coding", ir: {} })), + fetchWorkflowSettingValues: vi.fn(async () => ({ stored: {}, effective: {}, orphaned: [] })), + updateWorkflowSettingValues: vi.fn(async () => ({ stored: {}, effective: {}, orphaned: [] })), + }; +}); + +vi.mock("../../../CustomModelDropdown", () => ({ + CustomModelDropdown: ({ id, label, value, onChange, showThinkingLevel, thinkingLevel, onThinkingLevelChange, defaultThinkingLevel }: { + id?: string; + label: string; + value?: string; + onChange?: (value: string) => void; + showThinkingLevel?: boolean; + thinkingLevel?: string; + onThinkingLevelChange?: (value: string) => void; + defaultThinkingLevel?: string; + }) => ( +

+ + {showThinkingLevel ? : null} +
+ ), +})); + +vi.mock("../../../../hooks/useAgentsMapCache", () => ({ + useAgentsMapCache: () => ({ + loading: false, + agents: [ + { id: "agent-alpha", name: "Alpha", role: "engineer" }, + { id: "agent-review", name: "Review", role: "reviewer" }, + ], + agentsMap: new Map(), + }), +})); + +expect.extend(jestDomMatchers); + +afterEach(() => cleanup()); + +beforeEach(() => { + vi.mocked(fetchWorkflow).mockResolvedValue({ id: "builtin:coding", name: "Coding", ir: {} } as never); + vi.mocked(fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }); + vi.mocked(updateWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }); +}); + +const models: ProjectModelsSectionModelProps = { + modelLanes: [], + getLaneStatus: () => "inherited", + getLaneValue: () => "", + updateLaneValue: vi.fn(), + resetLaneValue: vi.fn(), + getLaneThinkingValue: () => "", + updateLaneThinkingValue: vi.fn(), + resetLaneThinkingValue: vi.fn(), + availableModels: [{ id: "claude-sonnet-4-5", provider: "anthropic", name: "Claude Sonnet" }], + modelsLoading: false, + favoriteProviders: [], + favoriteModels: [], + onToggleFavorite: vi.fn(), + onToggleModelFavorite: vi.fn(), + editingPresetId: null, + setEditingPresetId: vi.fn(), + presetDraft: null, + setPresetDraft: vi.fn(), + onSavePresetDraft: vi.fn(), + confirmDelete: vi.fn(async () => true), +}; + +function renderSection(initialForm: SettingsFormState = { defaultThinkingLevel: "medium" } as SettingsFormState) { + let latestForm = initialForm; + function Host() { + const [form, setForm] = useState(initialForm); + latestForm = form; + return ( + + ); + } + render(); + return { getForm: () => latestForm }; +} + +describe("ProjectModelsSection Chat default settings", () => { + it("renders the Chat subsection with prompt mode and model controls by default", () => { + renderSection(); + + expect(screen.getByRole("heading", { name: "Chat" })).toBeInTheDocument(); + expect(screen.getByLabelText("New Chat behavior")).toHaveValue("prompt"); + expect(screen.getByTestId("project-models-chat-model")).toBeInTheDocument(); + expect(screen.getByTestId("mock-model-host-chatDefaultModel")).toHaveAttribute("data-default-thinking", "medium"); + }); + + it("swaps between model and agent controls", () => { + renderSection(); + + fireEvent.click(screen.getByRole("button", { name: "Agent" })); + expect(screen.getByTestId("project-models-chat-agent")).toBeInTheDocument(); + expect(screen.getByLabelText("Chat Default Agent")).toHaveValue(""); + + fireEvent.click(screen.getByRole("button", { name: "Model" })); + expect(screen.getByTestId("project-models-chat-model")).toBeInTheDocument(); + }); + + it("selecting a model writes provider/model and clears the agent", () => { + const { getForm } = renderSection({ chatDefaultKind: "agent", chatDefaultAgentId: "agent-alpha" } as SettingsFormState); + + fireEvent.click(screen.getByRole("button", { name: "Model" })); + fireEvent.click(screen.getByTestId("mock-model-dropdown-chatDefaultModel")); + + expect(getForm()).toMatchObject({ + chatDefaultKind: "model", + chatDefaultModelProvider: "anthropic", + chatDefaultModelId: "claude-sonnet-4-5", + chatDefaultAgentId: undefined, + }); + }); + + it("selecting an agent writes the agent id and clears model fields", () => { + const { getForm } = renderSection({ + chatDefaultKind: "model", + chatDefaultModelProvider: "anthropic", + chatDefaultModelId: "claude-sonnet-4-5", + chatDefaultThinkingLevel: "high", + } as SettingsFormState); + + fireEvent.click(screen.getByRole("button", { name: "Agent" })); + fireEvent.change(screen.getByLabelText("Chat Default Agent"), { target: { value: "agent-review" } }); + + expect(getForm()).toMatchObject({ + chatDefaultKind: "agent", + chatDefaultAgentId: "agent-review", + chatDefaultModelProvider: undefined, + chatDefaultModelId: undefined, + chatDefaultThinkingLevel: undefined, + }); + }); + + it("setting the mode writes chatNewSessionMode", () => { + const { getForm } = renderSection(); + + fireEvent.change(screen.getByLabelText("New Chat behavior"), { target: { value: "always-default" } }); + + expect(getForm().chatNewSessionMode).toBe("always-default"); + }); + + it("Reset clears all chat default fields", () => { + const { getForm } = renderSection({ + chatNewSessionMode: "always-default", + chatDefaultKind: "model", + chatDefaultModelProvider: "anthropic", + chatDefaultModelId: "claude-sonnet-4-5", + chatDefaultThinkingLevel: "high", + chatDefaultAgentId: "agent-alpha", + } as SettingsFormState); + + fireEvent.click(screen.getByTitle("Reset Chat default")); + + expect(getForm()).toMatchObject({ + chatNewSessionMode: undefined, + chatDefaultKind: undefined, + chatDefaultAgentId: undefined, + chatDefaultModelProvider: undefined, + chatDefaultModelId: undefined, + chatDefaultThinkingLevel: undefined, + }); + }); +}); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index e3023d203b..dbd6c5ea8e 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6407,7 +6407,25 @@ "whenEnabledTasksCreatedWithoutATitleBut": " When enabled, tasks created without a title but with descriptions over 200 characters will automatically get an AI-generated title (max 60 characters). The same model is also used to generate fallback merge commit message bodies when the branch's commit log is empty (e.g. squash merges with no unique commits), and GitHub tracking issue titles when a tracked task has no title yet. Default: disabled. ", "autoSelectModelPresetHint": "Default: disabled.", "prTitlePromptInstructionsHelp": "Guides the AI-generated Create PR title. Leave blank to use the default PR metadata prompt. No default — unset.", - "prDescriptionPromptInstructionsHelp": "Guides the AI-generated Create PR summary, changes, and testing sections. Leave blank to use the default PR metadata prompt. No default — unset." + "prDescriptionPromptInstructionsHelp": "Guides the AI-generated Create PR summary, changes, and testing sections. Leave blank to use the default PR metadata prompt. No default — unset.", + "chatHeading": "Chat", + "chatDescription": "Choose the default target for new Direct chats and whether New Chat should prompt or immediately use that default.", + "chatNewSessionMode": "New Chat behavior", + "chatNewSessionModePrompt": "Prompt for model each time", + "chatNewSessionModeAlwaysDefault": "Always use configured default", + "chatNewSessionModeHelp": "Prompt mode opens New Chat with this default preselected. Always-default mode skips the dialog when the configured default is complete.", + "chatDefaultKind": "Chat default target", + "chatDefaultKindModel": "Model", + "chatDefaultKindAgent": "Agent", + "chatDefaultModel": "Chat Default Model", + "selectChatDefaultModel": "Select a chat default model", + "chatDefaultModelHelp": "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.", + "chatDefaultAgent": "Chat Default Agent", + "loadingAgents": "Loading agents…", + "selectChatDefaultAgent": "Select a chat default agent", + "chatDefaultAgentEmpty": "No agents are available for this project yet.", + "chatDefaultAgentHelp": "Agent-mode New Chat starts a Direct chat with the selected durable agent.", + "chatDefaultReset": "Reset Chat default" }, "remote": { "acceptRoutes": " Accept routes ", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 87e624cba3..2050ca0d37 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -6387,7 +6387,25 @@ "whenEnabledTasksCreatedWithoutATitleBut": "", "autoSelectModelPresetHint": "", "prTitlePromptInstructionsHelp": "", - "prDescriptionPromptInstructionsHelp": "" + "prDescriptionPromptInstructionsHelp": "", + "chatHeading": "Chat", + "chatDescription": "Choose the default target for new Direct chats and whether New Chat should prompt or immediately use that default.", + "chatNewSessionMode": "New Chat behavior", + "chatNewSessionModePrompt": "Prompt for model each time", + "chatNewSessionModeAlwaysDefault": "Always use configured default", + "chatNewSessionModeHelp": "Prompt mode opens New Chat with this default preselected. Always-default mode skips the dialog when the configured default is complete.", + "chatDefaultKind": "Chat default target", + "chatDefaultKindModel": "Model", + "chatDefaultKindAgent": "Agent", + "chatDefaultModel": "Chat Default Model", + "selectChatDefaultModel": "Select a chat default model", + "chatDefaultModelHelp": "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.", + "chatDefaultAgent": "Chat Default Agent", + "loadingAgents": "Loading agents…", + "selectChatDefaultAgent": "Select a chat default agent", + "chatDefaultAgentEmpty": "No agents are available for this project yet.", + "chatDefaultAgentHelp": "Agent-mode New Chat starts a Direct chat with the selected durable agent.", + "chatDefaultReset": "Reset Chat default" }, "remote": { "acceptRoutes": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index aa29f2f4f4..c05becb1bc 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -6387,7 +6387,25 @@ "whenEnabledTasksCreatedWithoutATitleBut": "", "autoSelectModelPresetHint": "", "prTitlePromptInstructionsHelp": "", - "prDescriptionPromptInstructionsHelp": "" + "prDescriptionPromptInstructionsHelp": "", + "chatHeading": "Chat", + "chatDescription": "Choose the default target for new Direct chats and whether New Chat should prompt or immediately use that default.", + "chatNewSessionMode": "New Chat behavior", + "chatNewSessionModePrompt": "Prompt for model each time", + "chatNewSessionModeAlwaysDefault": "Always use configured default", + "chatNewSessionModeHelp": "Prompt mode opens New Chat with this default preselected. Always-default mode skips the dialog when the configured default is complete.", + "chatDefaultKind": "Chat default target", + "chatDefaultKindModel": "Model", + "chatDefaultKindAgent": "Agent", + "chatDefaultModel": "Chat Default Model", + "selectChatDefaultModel": "Select a chat default model", + "chatDefaultModelHelp": "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.", + "chatDefaultAgent": "Chat Default Agent", + "loadingAgents": "Loading agents…", + "selectChatDefaultAgent": "Select a chat default agent", + "chatDefaultAgentEmpty": "No agents are available for this project yet.", + "chatDefaultAgentHelp": "Agent-mode New Chat starts a Direct chat with the selected durable agent.", + "chatDefaultReset": "Reset Chat default" }, "remote": { "acceptRoutes": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 8051f3b037..20d2891190 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -6387,7 +6387,25 @@ "whenEnabledTasksCreatedWithoutATitleBut": "", "autoSelectModelPresetHint": "", "prTitlePromptInstructionsHelp": "", - "prDescriptionPromptInstructionsHelp": "" + "prDescriptionPromptInstructionsHelp": "", + "chatHeading": "Chat", + "chatDescription": "Choose the default target for new Direct chats and whether New Chat should prompt or immediately use that default.", + "chatNewSessionMode": "New Chat behavior", + "chatNewSessionModePrompt": "Prompt for model each time", + "chatNewSessionModeAlwaysDefault": "Always use configured default", + "chatNewSessionModeHelp": "Prompt mode opens New Chat with this default preselected. Always-default mode skips the dialog when the configured default is complete.", + "chatDefaultKind": "Chat default target", + "chatDefaultKindModel": "Model", + "chatDefaultKindAgent": "Agent", + "chatDefaultModel": "Chat Default Model", + "selectChatDefaultModel": "Select a chat default model", + "chatDefaultModelHelp": "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.", + "chatDefaultAgent": "Chat Default Agent", + "loadingAgents": "Loading agents…", + "selectChatDefaultAgent": "Select a chat default agent", + "chatDefaultAgentEmpty": "No agents are available for this project yet.", + "chatDefaultAgentHelp": "Agent-mode New Chat starts a Direct chat with the selected durable agent.", + "chatDefaultReset": "Reset Chat default" }, "remote": { "acceptRoutes": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 492f87344e..b8abe1eb28 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -6387,7 +6387,25 @@ "whenEnabledTasksCreatedWithoutATitleBut": "", "autoSelectModelPresetHint": "", "prTitlePromptInstructionsHelp": "", - "prDescriptionPromptInstructionsHelp": "" + "prDescriptionPromptInstructionsHelp": "", + "chatHeading": "Chat", + "chatDescription": "Choose the default target for new Direct chats and whether New Chat should prompt or immediately use that default.", + "chatNewSessionMode": "New Chat behavior", + "chatNewSessionModePrompt": "Prompt for model each time", + "chatNewSessionModeAlwaysDefault": "Always use configured default", + "chatNewSessionModeHelp": "Prompt mode opens New Chat with this default preselected. Always-default mode skips the dialog when the configured default is complete.", + "chatDefaultKind": "Chat default target", + "chatDefaultKindModel": "Model", + "chatDefaultKindAgent": "Agent", + "chatDefaultModel": "Chat Default Model", + "selectChatDefaultModel": "Select a chat default model", + "chatDefaultModelHelp": "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.", + "chatDefaultAgent": "Chat Default Agent", + "loadingAgents": "Loading agents…", + "selectChatDefaultAgent": "Select a chat default agent", + "chatDefaultAgentEmpty": "No agents are available for this project yet.", + "chatDefaultAgentHelp": "Agent-mode New Chat starts a Direct chat with the selected durable agent.", + "chatDefaultReset": "Reset Chat default" }, "remote": { "acceptRoutes": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index f0676804c5..1e7478c018 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -6387,7 +6387,25 @@ "whenEnabledTasksCreatedWithoutATitleBut": "", "autoSelectModelPresetHint": "", "prTitlePromptInstructionsHelp": "", - "prDescriptionPromptInstructionsHelp": "" + "prDescriptionPromptInstructionsHelp": "", + "chatHeading": "Chat", + "chatDescription": "Choose the default target for new Direct chats and whether New Chat should prompt or immediately use that default.", + "chatNewSessionMode": "New Chat behavior", + "chatNewSessionModePrompt": "Prompt for model each time", + "chatNewSessionModeAlwaysDefault": "Always use configured default", + "chatNewSessionModeHelp": "Prompt mode opens New Chat with this default preselected. Always-default mode skips the dialog when the configured default is complete.", + "chatDefaultKind": "Chat default target", + "chatDefaultKindModel": "Model", + "chatDefaultKindAgent": "Agent", + "chatDefaultModel": "Chat Default Model", + "selectChatDefaultModel": "Select a chat default model", + "chatDefaultModelHelp": "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.", + "chatDefaultAgent": "Chat Default Agent", + "loadingAgents": "Loading agents…", + "selectChatDefaultAgent": "Select a chat default agent", + "chatDefaultAgentEmpty": "No agents are available for this project yet.", + "chatDefaultAgentHelp": "Agent-mode New Chat starts a Direct chat with the selected durable agent.", + "chatDefaultReset": "Reset Chat default" }, "remote": { "acceptRoutes": "", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index ca4391da3a..d1cb043583 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -6372,6 +6372,21 @@ export default interface Resources { "autoSelectPresetBasedOnTaskSize": " Auto-select preset based on task size ", "autoSummarizeLongDescriptionsAsTitles": " Auto-summarize long descriptions as titles ", "automaticallyCompactContextWhenApproachingThisTokenCount": "Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count. No default — unset (no cap).", + "chatDefaultAgent": "Chat Default Agent", + "chatDefaultAgentEmpty": "No agents are available for this project yet.", + "chatDefaultAgentHelp": "Agent-mode New Chat starts a Direct chat with the selected durable agent.", + "chatDefaultKind": "Chat default target", + "chatDefaultKindAgent": "Agent", + "chatDefaultKindModel": "Model", + "chatDefaultModel": "Chat Default Model", + "chatDefaultModelHelp": "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.", + "chatDefaultReset": "Reset Chat default", + "chatDescription": "Choose the default target for new Direct chats and whether New Chat should prompt or immediately use that default.", + "chatHeading": "Chat", + "chatNewSessionMode": "New Chat behavior", + "chatNewSessionModeAlwaysDefault": "Always use configured default", + "chatNewSessionModeHelp": "Prompt mode opens New Chat with this default preselected. Always-default mode skips the dialog when the configured default is complete.", + "chatNewSessionModePrompt": "Prompt for model each time", "configuredPresets": "Configured presets", "configuresTheModelUsedForTwoShortSummary": " Configures the model used for two short-summary jobs: auto-generating task titles from long descriptions, and generating merge commit summaries from step commits and diff stats. ", "defaultWorkflowModelLaneActions": "Default workflow model lane actions", @@ -6380,6 +6395,7 @@ export default interface Resources { "edit": " Edit ", "executorModel": "Executor model", "fallsBackTo": " Falls back to: ", + "loadingAgents": "Loading agents…", "loadingAvailableModels": "Loading available models…", "loadingWorkflowModelLanes": "Loading workflow model lanes…", "modelLanes": "Model Lanes", @@ -6401,6 +6417,8 @@ export default interface Resources { "resetToInheritFromGlobal": "Reset to inherit from global", "resetToInheritFromWorkflow": "Reset to inherit from workflow", "reviewerModel": "Reviewer model", + "selectChatDefaultAgent": "Select a chat default agent", + "selectChatDefaultModel": "Select a chat default model", "theseProjectOverridesApplyToTheActiveDefault": " These project overrides apply to the active default workflow. ", "tokenCap": "Token Cap", "useDefault": "Use default",