diff --git a/.changeset/fn-033-task-chat-default-model.md b/.changeset/fn-033-task-chat-default-model.md new file mode 100644 index 0000000000..41bed3bed6 --- /dev/null +++ b/.changeset/fn-033-task-chat-default-model.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Use the Direct Chat default model in task-detail Chat with task-aware context. +category: feature +dev: Task Chat retains its synthetic task session and adds model/thinking controls without impersonating Direct Chat agents. diff --git a/docs/agents.md b/docs/agents.md index cb58641c5b..23d7eec354 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -957,9 +957,9 @@ Mail has an optional structural metadata contract: `mailKind` distinguishes ordi ### Dashboard Chat workspace tools -Dashboard Chat, Chat Room responders, and task-detail Planner Chat run at the interactive project checkout with coding workspace tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`. Use them for user-directed file changes and shell investigation. When a durable agent is bound, its permanent-agent permission policy still governs file writes/deletes and command execution; unbound model Chat has no durable-principal policy gate. Chat must keep the checkout branch sticky: inspect Git freely, but do not use `git checkout` or `git switch` unless the operator explicitly requests it. +Dashboard Chat, Chat Room responders, and task-aware task-detail Chat run at the interactive project checkout with coding workspace tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`. Use them for user-directed file changes and shell investigation. When a durable agent is bound, its permanent-agent permission policy still governs file writes/deletes and command execution; unbound model Chat has no durable-principal policy gate. Chat must keep the checkout branch sticky: inspect Git freely, but do not use `git checkout` or `git switch` unless the operator explicitly requests it. -Task-detail Planner Chat is included because it is a `task-planner:` ChatManager session. This does not change the readonly planning/mission interview lanes or WhatsApp plugin chat. Chat verification remains limited to its existing allowlisted profiles rather than accepting arbitrary shell commands. +Task-detail Chat is included because it is a `task-planner:` ChatManager session, not because it is the workflow planner lane. The session keeps the server-built task definition, dependencies, activity, metrics, steering, and refinement context and retains the existing task-scoped tool and SSE task-identity contract. Its model target follows the project Direct Chat default, and the Chat UI exposes model/thinking selectors while keeping targeting model-only; it never impersonates a configured durable Direct Chat agent. This does not change the readonly planning/mission interview lanes or WhatsApp plugin chat. Chat verification remains limited to its existing allowlisted profiles rather than accepting arbitrary shell commands. ### Worktree session file boundary diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 8db1d495b3..b0f8b23b4f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -246,17 +246,21 @@ Content views such as Artifacts, Research, Insights, Skills, Memory, Evals, Goal On mobile viewports, the Right Dock never renders. The compact Header actions and bottom `MobileNavBar` keep their existing mobile behavior even when the experiment is enabled. +## Task-detail Chat + +Task-detail **Chat** uses the project’s configured Direct Chat default model and thinking level rather than the task’s planning model. It remains task-aware: the server builds the task definition, dependencies, activity, metrics, steering, and refinement context, and the existing `task-planner:` session keeps one transcript per task. The composer exposes the same model and thinking controls as Direct Chat; model choices remain model-targeted and do not replace the synthetic task-scoped permission contract. Changing the project default does not hide history, and the next explicit send applies the current target to the existing idle session. There is no separate planner-model lane for this conversation. + ## Chat message editing and rewind -Direct Chat and task-detail Planner Chat let you edit a persisted user message with the inline **Edit message** control. Rooms and CLI-backed chat sessions do not expose this control. Saving sends one replacement-aware SSE request with the trimmed correction and the target message identity; the server validates the project/session/role and rejects edits during an active generation. +Direct Chat and task-detail Chat let you edit a persisted user message with the inline **Edit message** control. Rooms and CLI-backed chat sessions do not expose this control. Saving sends one replacement-aware SSE request with the trimmed correction and the target message identity; the server validates the project/session/role and rejects edits during an active generation. -The server accepts the replacement only after it has discarded the target and every later persisted turn and repointed reachable pi session history to the retained prefix. The Direct and Planner transcript keeps the old range visible until that acceptance callback, then shows the trimmed replacement and its new response. A pre-acceptance validation, transport, or fencing failure reloads the authoritative old transcript and leaves the correction editable; it never issues a second send. A provider failure after acceptance remains a normal SSE/fetch reconciliation path: discarded history is not restored and no duplicate response is started. +The server accepts the replacement only after it has discarded the target and every later persisted turn and repointed reachable pi session history to the retained prefix. The Direct and task Chat transcript keeps the old range visible until that acceptance callback, then shows the trimmed replacement and its new response. A pre-acceptance validation, transport, or fencing failure reloads the authoritative old transcript and leaves the correction editable; it never issues a second send. A provider failure after acceptance remains a normal SSE/fetch reconciliation path: discarded history is not restored and no duplicate response is started. -Planner Chat refreshes task detail after an accepted replacement. Steering comments and refinement tasks created by discarded planner turns are durable side effects and are not rolled back; when applicable, Planner Chat shows the existing informational notice. The edit behavior is shared across desktop, popup/dock/host, mobile/touch, and task-detail surfaces. +Task Chat refreshes task detail after an accepted replacement. Steering comments and refinement tasks created by discarded task-chat turns are durable side effects and are not rolled back; when applicable, task Chat shows the existing informational notice. The edit behavior is shared across desktop, popup/dock/host, mobile/touch, and task-detail surfaces. -## Pending messages in Direct and Planner Chat +## Pending messages in Direct and task Chat -Direct Chat and task-detail Planner Chat share one browser-local, text-only pending queue per chat session. While a model reply is active, send additional text to add it to the queue; the queue survives reloads under the session-scoped `fusion:chat-pending:` storage key and does not cross projects, tasks, or sessions. +Direct Chat and task-detail Chat share one browser-local, text-only pending queue per chat session. While a model reply is active, send additional text to add it to the queue; the queue survives reloads under the session-scoped `fusion:chat-pending:` storage key and does not cross projects, tasks, or sessions. Both model-loop surfaces expose the same queue controls: edit an entry, move it earlier or later, delete it, or **Force send** a selected entry. Duplicate text is selected by its position in the list, not by its content. A blank edit is rejected without deleting the queued entry, and queue controls remain named and touch-reachable on narrow screens. @@ -730,17 +734,17 @@ Chat view provides project-scoped conversations with agents. The default convers ### Conversation layout -Use **Settings → Appearance → Conversation layout** to choose the project-scoped message presentation for every dashboard chat surface. **Bubbles** is the default and keeps the bounded, left/right-aligned message bubbles; **Full width** lets each message use the available transcript width. The choice applies immediately to normal Chat, Quick Chat, and dock/overflow Chat hosts, as well as task-detail **Activity** and **Planner Chat**. Missing or invalid values safely use **Bubbles**. +Use **Settings → Appearance → Conversation layout** to choose the project-scoped message presentation for every dashboard chat surface. **Bubbles** is the default and keeps the bounded, left/right-aligned message bubbles; **Full width** lets each message use the available transcript width. The choice applies immediately to normal Chat, Quick Chat, and dock/overflow Chat hosts, as well as task-detail **Activity** and task-aware **Chat**. Missing or invalid values safely use **Bubbles**. -Primary Chat, Rooms, Activity, and Planner Chat composers grow automatically through five rendered lines, then scroll excess text inside the input. On desktop and tablet, drag the native vertical resize affordance to enlarge the current draft; that manual height is in-memory only, is not saved, and resets when the draft is cleared, sent, or the conversation/task target changes. Mobile keeps the composer compact, capped at five lines, and internally scrolling without advertising a mouse-only resize affordance. +Primary Chat, Rooms, Activity, and task Chat composers grow automatically through five rendered lines, then scroll excess text inside the input. On desktop and tablet, drag the native vertical resize affordance to enlarge the current draft; that manual height is in-memory only, is not saved, and resets when the draft is cleared, sent, or the conversation/task target changes. Mobile keeps the composer compact, capped at five lines, and internally scrolling without advertising a mouse-only resize affordance. ## Mailbox archive Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unread badges ignore it. Select **Archived** to review archived messages and restore them; Archive is the default removal action and Delete remains available as an explicit destructive action. -- Direct Chat, Chat Room responders, and task-detail Planner Chat have coding workspace tools at the interactive project checkout: `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`. They can make user-directed edits and run shell investigation; a bound durable agent remains subject to its permanent-agent file-write and command-execution permission policy. These Chat sessions keep the checkout branch sticky unless you explicitly ask to switch it. Planning/mission interviews and WhatsApp plugin chat remain readonly. +- Direct Chat, Chat Room responders, and task-detail Chat have coding workspace tools at the interactive project checkout: `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`. They can make user-directed edits and run shell investigation; a bound durable agent remains subject to its permanent-agent file-write and command-execution permission policy. These Chat sessions keep the checkout branch sticky unless you explicitly ask to switch it. Planning/mission interviews and WhatsApp plugin chat remain readonly. - Chat recognizes native structure references in both assistant and user messages using the explicit `fusion:///` form. Supported kinds are `mission`, `milestone`, `roadmap-item`, `research-finding`, `eval-result`, and `goal`. Use a bare token such as `fusion://mission/M-001` in either message type, or an assistant Markdown link such as `[Mission](fusion://mission/M-001)`. `roadmap-item` previews the roadmap feature title and description when available; a missing feature or unavailable roadmap data layer renders the shared unavailable card. @@ -757,7 +761,7 @@ Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unrea - A small **Brain**-icon button next to the composer's attach button lets you change an already-created direct chat session's target and thinking level mid-conversation, without starting a new chat. Its **Model / Agent** section can switch the session to another model via the shared model picker or to a real agent from the agent list; its **Thinking level** section still lists the six thinking levels plus **Default** (clear/inherit, labeled with the current resolved default such as **Default (medium)**). Each selection persists immediately and applies starting with the session's next send, including on mobile/tablet touch viewports and narrow floating Chat windows or compact docks where the popup stays fitted to the chat surface. This control appears only for non-CLI Direct sessions — it is not shown for CLI-agent-backed sessions or in Chat Rooms, neither of which support this per-session retargeting control. - Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately) -- Stopping a Direct/Quick Chat or task Planner model-loop response retains any non-empty text already streamed as one interrupted assistant conversation message, including after refresh/remount and in the next turn's file-backed model context. Chat Rooms and CLI-agent-backed chat sessions are excluded from this model-loop continuity contract. +- Stopping a Direct/Quick Chat or task Chat model-loop response retains any non-empty text already streamed as one interrupted assistant conversation message, including after refresh/remount and in the next turn's file-backed model context. Chat Rooms and CLI-agent-backed chat sessions are excluded from this model-loop continuity contract. - Final assistant messages with no text, tool calls, thinking output, attachments, or failure details render a muted **No message** placeholder instead of a blank bubble. In-progress responses still use the existing **Working…** / **Thinking…** streaming state until the run finishes. - In-progress assistant responses now survive refresh/navigation while generation is still active: Chat restores the last durable in-flight text/thinking/tool state immediately, keeps the prior persisted conversation visible, then resumes streaming from the stored replay point; any new text, thinking, or tool-call updates append to that restored bubble instead of replacing it or starting from an empty "Working…" placeholder. @@ -1562,7 +1566,7 @@ For related global/project configuration behavior, see [Settings reference](./se Inspect task definition, logs, review feedback, comments, artifacts, workflow outcomes, model overrides, and task routing from a single modal. - Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form. -- The top-level **Chat** tab appears first for active task details and is the default landing tab for non-`done` tasks. It uses the task's effective planning model, but opening the tab is lookup-only: Fusion creates the task-scoped planner chat only after you send a composer message, starter prompt, or planner-question answer. Once a user message exists, the resumable planner chat can appear in the global Chat list; interacted chats are kept when the task reaches `done` and removed when the task is archived. Each send includes bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. The composer stays pinned while the transcript, loading, error, starter, history, and streaming states scroll internally; on mobile/narrow task detail, the default focused Chat layout hides nonessential title/metadata/tab/action rows until you collapse it from the in-view expand control. +- The top-level **Chat** tab appears first for active task details and is the default landing tab for non-`done` tasks. It uses the project Direct Chat default model and thinking level, and exposes reusable model/thinking selectors without impersonating a Direct Chat agent. Opening the tab is lookup-only: Fusion creates the task-scoped Chat session only after you send a composer message, starter prompt, or question answer. Once a user message exists, the resumable planner chat can appear in the global Chat list; interacted chats are kept when the task reaches `done` and removed when the task is archived. Each send includes bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. The composer stays pinned while the transcript, loading, error, starter, history, and streaming states scroll internally; on mobile/narrow task detail, the default focused Chat layout hides nonessential title/metadata/tab/action rows until you collapse it from the in-view expand control. - The **Activity → Live**, **Feed**, and **Raw Logs** segments remain immediately after **Chat** and share an expand/collapse control that lets the active Activity segment fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed. - The **Summary** tab appears for `done` tasks and remains their default landing tab. It shows the recorded completion summary, the **Merge Details** card (merge status, commit, PR, timestamp, and message), changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`. - The **Recommendations** tab appears on a completed task only when at least one recommendation was captured. At accepted completion, executors evaluate optional, non-blocking out-of-scope findings and submit task-ready recommendations; an explicit `[]` means none qualified, not that filler should be invented. The project cap bounds captured results, and `maxRecommendationsPerTask: 0` disables capture and therefore removes the tab entirely. A non-empty set sends one mailbox notice per distinct recommendation-id set only after completion is accepted; interrupted or rolled-back handoffs and linking an already-captured recommendation to a created task send nothing. Delivery is asynchronous and best-effort, so it never delays task completion; **Settings → General → Recommendation mailbox notices** can disable only this notice, not capture. Recommendations are distinct from immediately created/delegated tasks, which remain appropriate for required dependency coordination, explicit task requirements, or operator-directed filing. An empty result surfaces as no tab; otherwise each row shows a task-ready title, category, and description. **Create task** uses the normal guarded intake policy (including duplicate checks), so a duplicate conflict creates no child and leaves the recommendation available to retry; successful repeated clicks reuse the same linked triage task. The same recommendations also appear project-wide in **Insights → Task Recommendations**, where row pagination and an explicit **Load more** control keep the aggregate bounded without hiding later suggestions. @@ -1661,7 +1665,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig The **Activity** tab is the first task-detail tab by default and presents **Live**, **Feed**, and **Raw Logs** as a segmented control on wider layouts and as a fixed, root-portaled **Activity views** dropdown on narrow mobile layouts. The dropdown stays above its owning task-detail modal or task popup and follows the Activity tab while a popup is dragged or resized. Live contains the live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded for `in-progress` and `in-review` tasks so active reasoning is visible at a glance; blocks start collapsed for other task columns and remain user-toggleable in every state. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the Activity Live composer sends typed guidance through the same steering path used by comments, including active planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Live message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. Feed and Raw Logs do not show the composer. On a `done` task, the same composer starts a refinement task using the typed text as feedback and shows a success toast with the new task ID, while the current task detail modal remains on the completed task. The task-detail Activity Live segment keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut. -The top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. It appears after Activity by default, or before Activity when **Settings → Appearance → Open task details with Chat first** is enabled. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no planner-chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the planner chat and sends that prompt as an ordinary chat message through the task-context-aware planner-chat composer/stream path, including for completed tasks. On live tasks, clear bounded implementation-change requests are routed to task steering; on `done` tasks, clear follow-up implementation or improvement requests are routed through a task-scoped planner refinement tool that calls the same refinement creation path as the completed-task Activity composer. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering/refinement composer remain separate. Planner Chat uses the same standard chat bubble, markdown/plain assistant rendering, thinking details, tool-call/question cards, and mobile first-tap send/stop affordance as the main Chat view while keeping task-scoped planner sessions separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control. +The top-level **Chat** tab opens a task-aware Chat conversation for the same task instead of posting steering comments. It targets the project Direct Chat default model and thinking level, with the same model and thinking selectors available in general Chat; it retains the synthetic `task-planner:` session so server-built task context and scoped tools remain intact. It appears after Activity by default, or before Activity when **Settings → Appearance → Open task details with Chat first** is enabled. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no task-Chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the task Chat session and sends that prompt as an ordinary chat message through the task-context-aware task-Chat composer/stream path, including for completed tasks. On live tasks, clear bounded implementation-change requests are routed to task steering; on `done` tasks, clear follow-up implementation or improvement requests are routed through a task-scoped planner refinement tool that calls the same refinement creation path as the completed-task Activity composer. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering/refinement composer remain separate. Task Chat uses the same standard chat bubble, markdown/plain assistant rendering, thinking details, tool-call/question cards, and mobile first-tap send/stop affordance as the main Chat view while keeping task-scoped planner sessions separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control. The **Raw Logs** segment is designed for debugging long-running and tool-heavy sessions, while legacy links that requested the former top-level Logs tab land on Activity → Feed: diff --git a/packages/dashboard/app/api/chat/chat.ts b/packages/dashboard/app/api/chat/chat.ts index 9ca044a60d..dfbb47d34f 100644 --- a/packages/dashboard/app/api/chat/chat.ts +++ b/packages/dashboard/app/api/chat/chat.ts @@ -2,6 +2,7 @@ * FNXC:CodeOrganization 2026-07-19-12:00: * Chat sessions / rooms / streaming client API peeled from legacy.ts. */ +import { THINKING_LEVELS } from "@fusion/core"; import type { ChatAttachment, ChatMessage, @@ -38,6 +39,7 @@ export interface ChatMessageListResponse { export interface TaskPlannerChatSessionInput { modelProvider?: string; modelId?: string; + thinkingLevel?: string; } export interface ChatRoomListResponse { @@ -167,7 +169,11 @@ function normalizeTaskPlannerChatInput(taskId: string, input: TaskPlannerChatSes if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) { throw new Error("Both modelProvider and modelId must be provided together, or neither should be provided"); } - return { normalizedTaskId, normalizedProvider, normalizedModelId }; + const normalizedThinkingLevel = input.thinkingLevel?.trim(); + if (normalizedThinkingLevel && !THINKING_LEVELS.includes(normalizedThinkingLevel as (typeof THINKING_LEVELS)[number])) { + throw new Error(`thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`); + } + return { normalizedTaskId, normalizedProvider, normalizedModelId, normalizedThinkingLevel }; } export function fetchTaskPlannerChatSession( @@ -175,16 +181,13 @@ export function fetchTaskPlannerChatSession( input: TaskPlannerChatSessionInput = {}, projectId?: string, ): Promise<{ session: EnrichedChatSession | null }> { - const { normalizedTaskId, normalizedProvider, normalizedModelId } = normalizeTaskPlannerChatInput(taskId, input); + const { normalizedTaskId } = normalizeTaskPlannerChatInput(taskId, input); /* - FNXC:TaskDetailPlannerChat 2026-06-30-18:20: - Task-detail planner chats are task-local but no longer pre-created by opening the Chat tab. Use lookup-only resume here so global Chat history only receives planner sessions after an explicit user message creates one. + FNXC:TaskChatDefaultModel 2026-08-19-12:12: + Task-detail Chat history is keyed only by the synthetic task target and project scope. A Direct Chat default change must not hide the existing transcript; model changes are applied only by the explicit-send ensure path. */ - return fetchResumeChatSession({ - agentId: `task-planner:${normalizedTaskId}`, - ...(normalizedProvider && normalizedModelId ? { modelProvider: normalizedProvider, modelId: normalizedModelId } : {}), - }, projectId); + return fetchResumeChatSession({ agentId: `task-planner:${normalizedTaskId}` }, projectId); } export function ensureTaskPlannerChatSession( @@ -192,11 +195,11 @@ export function ensureTaskPlannerChatSession( input: TaskPlannerChatSessionInput = {}, projectId?: string, ): Promise { - const { normalizedTaskId, normalizedProvider, normalizedModelId } = normalizeTaskPlannerChatInput(taskId, input); + const { normalizedTaskId, normalizedProvider, normalizedModelId, normalizedThinkingLevel } = normalizeTaskPlannerChatInput(taskId, input); /* - FNXC:TaskDetailPlannerChat 2026-06-30-22:30: - Task planner chat uses a task-scoped session seam instead of the generic agent-chat creator so it can bind the conversation to the task and planning model without requiring a real executor/reviewer agent or turning the message into steering. + FNXC:TaskChatDefaultModel 2026-08-19-12:12: + The task Chat session remains synthetic and task-scoped, but an explicit send applies the current Direct Chat model and thinking target to that one persisted session before streaming. FNXC:TaskDetailPlannerChat 2026-06-30-18:20: This mutating helper is reserved for explicit user sends (composer, starter prompts, and planner-question answers). Tab activation must call fetchTaskPlannerChatSession instead so empty task-detail visits do not create chat history. @@ -207,6 +210,7 @@ export function ensureTaskPlannerChatSession( method: "POST", body: JSON.stringify({ ...(normalizedProvider && normalizedModelId ? { modelProvider: normalizedProvider, modelId: normalizedModelId } : {}), + ...(normalizedThinkingLevel ? { thinkingLevel: normalizedThinkingLevel } : {}), }), }, ); diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index d45872ad49..57e94a59c3 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -91,7 +91,7 @@ import { copyTextToClipboard } from "../utils/copyToClipboard"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; import { isReviewBudgetExhaustedApproval, isTaskAwaitingPlanApproval } from "../utils/reviewBudgetApproval"; import { getTaskStatusBadgeLabel, hasTaskStatusBadge, isTaskPlanningActive } from "../utils/taskStatusBadgeLabel"; -import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution"; +import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveTaskChat, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution"; import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel } from "./TaskContextMenu"; import type { TaskContextMenuColumnFlags, TaskContextMenuColumnMetadata } from "./TaskContextMenu"; import { FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT } from "./FloatingWindow"; @@ -7074,7 +7074,7 @@ export function TaskDetailContent({ active={active && activeTab === "planner-chat"} expanded={isPlannerChatExpanded} onExpandedChange={setPlannerChatExpanded} - planningModel={resolveEffectivePlanning(workingTask, agentLogEntries, settings)} + taskChatModel={resolveEffectiveTaskChat(settings)} addToast={addToast} onTaskUpdated={onTaskUpdated} /> diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.css b/packages/dashboard/app/components/TaskPlannerChatTab.css index 3bcd0cc292..72c20f374f 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.css +++ b/packages/dashboard/app/components/TaskPlannerChatTab.css @@ -210,6 +210,31 @@ Mobile Planner Chat should match regular task chat: keep the composer as a singl FNXC:TaskDetailPlannerChat 2026-07-07-00:00: The Planner Chat streaming Stop button must occupy the same width footprint as the Send button it replaces (no shift/shrink on swap) and mirror the regular Chat view's stop-button sizing (`.chat-input-row`'s `--chat-input-control-size` in ChatView.css). The shared `.chat-input-send` / `.chat-input-stop` classes read that custom property from `.chat-input-row`, which the Planner composer never renders inside of, so the property was undefined here and `width` fell back to `auto`, sizing each button from its own content only. Declare the same control-size formula scoped to `.task-planner-chat-composer` and give `.task-planner-chat-send` (present on both the send and stop button variants) a matching `min-inline-size` floor so neither button can render narrower than the other on desktop, without touching ChatView.css's own token. */ +/* +FNXC:TaskChatDefaultModel 2026-08-19-12:12: +Task Chat keeps model and thinking controls reachable beside the composer, reusing the Direct Chat primitives instead of adding a task-only selector style. The compact control row wraps on narrow layouts so the task transcript and send affordance remain usable. +*/ +.task-planner-chat-target-controls { + display: flex; + flex: 0 1 auto; + align-items: center; + gap: var(--space-xs); + min-width: 0; +} + +.task-planner-chat-target-controls .model-combobox { + min-width: 0; + max-width: calc(var(--space-2xl) * 8); +} + +.task-planner-chat-target-controls .model-combobox-trigger { + max-width: 100%; +} + +.task-planner-chat-target-controls .chat-thinking-level-root { + flex: 0 0 auto; +} + .task-planner-chat-composer { /* FN-7634: same formula as ChatView.css's `.chat-input-row { --chat-input-control-size: … }` so the Planner stop/send buttons share the regular Chat view's control-size floor. */ --chat-input-control-size: calc(var(--space-lg) * 2.5); @@ -270,11 +295,22 @@ height rather than relying on the global button minimum. .task-planner-chat-composer { flex-direction: row; - flex-wrap: nowrap; + flex-wrap: wrap; align-items: flex-end; gap: var(--space-xs); } + .task-planner-chat-target-controls { + flex: 1 1 100%; + order: 0; + justify-content: flex-end; + } + + .task-planner-chat-target-controls .model-combobox { + flex: 1 1 auto; + max-width: none; + } + .task-planner-chat-input { flex: 1 1 auto; min-width: 0; diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index 3dfeed9b88..41968184f7 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -9,11 +9,14 @@ import { useComposerDictation } from "../hooks/useComposerDictation"; import { getPersistedPendingChatMessages, setPersistedPendingChatMessages } from "../hooks/chatPendingMessageStorage"; import { MicButton } from "./MicButton"; import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes"; -import { attachChatStream, cancelChatResponse, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api"; +import { attachChatStream, cancelChatResponse, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, updateChatSession, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api"; import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall"; import { ChatQuestionResponse } from "./ChatQuestionResponse"; import { PendingChatMessageQueue } from "./PendingChatMessageQueue"; import { ProviderIcon } from "./ProviderIcon"; +import { CustomModelDropdown } from "./CustomModelDropdown"; +import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl"; +import { useModelsCache } from "../hooks/useModelsCache"; import { StandardChatActionButton, StandardChatMessageItem, StandardStreamingMessage, formatModelTag } from "./StandardChatSurface"; import { CHAT_COMMANDS, filterChatCommands, getSlashTriggerMatch, matchChatCommand, type ChatCommand } from "./chat-commands"; import { useChatMessageLayout } from "../context/ChatMessageLayoutContext"; @@ -31,7 +34,7 @@ interface TaskPlannerChatTabProps { active: boolean; expanded?: boolean; onExpandedChange?: (expanded: boolean) => void; - planningModel: ResolvedModelSelection; + taskChatModel: ResolvedModelSelection & { thinkingLevel?: string }; addToast: (msg: string, type?: ToastType) => void; onTaskUpdated?: (task: Task) => void; } @@ -326,7 +329,7 @@ function buildPlannerQuestionRenderStates(messages: readonly ChatMessage[]): Map return states; } -export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expanded = false, onExpandedChange, planningModel, addToast, onTaskUpdated }: TaskPlannerChatTabProps) { +export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expanded = false, onExpandedChange, taskChatModel, addToast, onTaskUpdated }: TaskPlannerChatTabProps) { const { t } = useTranslation("app"); const chatMessageLayout = useChatMessageLayout(); const [sessionId, setSessionId] = useState(null); @@ -368,22 +371,106 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan const streamRequestRef = useRef(0); const addToastRef = useRef(addToast); const onTaskUpdatedRef = useRef(onTaskUpdated); + const taskChatModelRef = useRef(taskChatModel); useEffect(() => { addToastRef.current = addToast; onTaskUpdatedRef.current = onTaskUpdated; - }, [addToast, onTaskUpdated]); + taskChatModelRef.current = taskChatModel; + }, [addToast, onTaskUpdated, taskChatModel]); - const planningModelProvider = isUsableModel(planningModel) ? planningModel.provider : undefined; - const planningModelId = isUsableModel(planningModel) ? planningModel.modelId : undefined; - const planningModelLabel = planningModelProvider && planningModelId ? `${planningModelProvider}/${planningModelId}` : ""; - const activeModelTag = formatModelTag(planningModelProvider, planningModelId); + const [sessionModel, setSessionModel] = useState(taskChatModel); + const hasLocalTargetOverrideRef = useRef(false); + const { models, favoriteProviders, favoriteModels } = useModelsCache(); + const displayedModel = sessionModel; + const displayedModelProvider = isUsableModel(displayedModel) ? displayedModel.provider : undefined; + const displayedModelId = isUsableModel(displayedModel) ? displayedModel.modelId : undefined; + const displayedModelLabel = displayedModelProvider && displayedModelId ? `${displayedModelProvider}/${displayedModelId}` : ""; + const activeModelTag = formatModelTag(displayedModelProvider, displayedModelId); const modelPayload = useMemo(() => { - return planningModelProvider && planningModelId - ? { modelProvider: planningModelProvider, modelId: planningModelId } + return displayedModelProvider && displayedModelId + ? { + modelProvider: displayedModelProvider, + modelId: displayedModelId, + ...(displayedModel.thinkingLevel ? { thinkingLevel: displayedModel.thinkingLevel } : {}), + } : {}; - }, [planningModelId, planningModelProvider]); - const plannerChatScopeKey = `${task.id}\u0000${projectId ?? ""}\u0000${planningModelProvider ?? ""}\u0000${planningModelId ?? ""}`; + }, [displayedModel, displayedModelId, displayedModelProvider]); + + /* + FNXC:TaskChatDefaultModel 2026-08-19-12:12: + Task Chat exposes the same model and thinking controls as Direct Chat, but keeps model-only targeting so a selection never impersonates a durable agent or bypasses the synthetic task authorization contract. Before the first send selections stay local; an existing session is patched in its project scope. + */ + const handleTaskChatModelChange = useCallback(async (value: string) => { + const slashIndex = value.indexOf("/"); + const useProjectDefault = value === ""; + if (!useProjectDefault && (slashIndex <= 0 || slashIndex === value.length - 1)) return; + const modelProvider = useProjectDefault ? taskChatModel.provider : value.slice(0, slashIndex); + const modelId = useProjectDefault ? taskChatModel.modelId : value.slice(slashIndex + 1); + if (!modelProvider || !modelId) return; + hasLocalTargetOverrideRef.current = !useProjectDefault; + setSessionModel((current) => ({ + ...current, + provider: modelProvider, + modelId, + ...(useProjectDefault ? { thinkingLevel: taskChatModel.thinkingLevel } : {}), + })); + const resolvedSessionId = sessionIdRef.current; + if (!resolvedSessionId) return; + try { + const { session } = await updateChatSession( + resolvedSessionId, + { + modelProvider, + modelId, + thinkingLevel: useProjectDefault ? taskChatModel.thinkingLevel ?? null : displayedModel.thinkingLevel ?? null, + }, + projectId, + ); + if (sessionIdRef.current !== resolvedSessionId) return; + setSessionModel({ + ...(session.modelProvider && session.modelId ? { provider: session.modelProvider, modelId: session.modelId } : {}), + ...(session.thinkingLevel ? { thinkingLevel: session.thinkingLevel } : {}), + }); + } catch (err) { + const message = getErrorMessage(err) || t("taskDetail.plannerChat.modelChangeFailed", "Failed to change task chat model"); + setError(message); + addToastRef.current(message, "error"); + } + }, [displayedModel.thinkingLevel, projectId, t, taskChatModel]); + + const handleTaskChatThinkingChange = useCallback(async (thinkingLevel: string) => { + hasLocalTargetOverrideRef.current = true; + setSessionModel((current) => ({ ...current, ...(thinkingLevel ? { thinkingLevel } : { thinkingLevel: undefined }) })); + const resolvedSessionId = sessionIdRef.current; + if (!resolvedSessionId) return; + try { + const { session } = await updateChatSession(resolvedSessionId, { thinkingLevel: thinkingLevel || null }, projectId); + if (sessionIdRef.current !== resolvedSessionId) return; + setSessionModel((current) => ({ + ...current, + ...(session.thinkingLevel ? { thinkingLevel: session.thinkingLevel } : { thinkingLevel: undefined }), + })); + } catch (err) { + const message = getErrorMessage(err) || t("taskDetail.plannerChat.thinkingChangeFailed", "Failed to change task chat thinking level"); + setError(message); + addToastRef.current(message, "error"); + } + }, [projectId, t]); + + const plannerChatScopeKey = `${task.id}\u0000${projectId ?? ""}`; + + useEffect(() => { + if ( + !sessionId + && !hasLocalTargetOverrideRef.current + && (sessionModel.provider !== taskChatModel.provider + || sessionModel.modelId !== taskChatModel.modelId + || sessionModel.thinkingLevel !== taskChatModel.thinkingLevel) + ) { + setSessionModel(taskChatModel); + } + }, [sessionId, sessionModel, taskChatModel]); const handleComposerRef = useCallback((textarea: HTMLTextAreaElement | null) => { autosizeRef.current?.destroy(); @@ -460,7 +547,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan setHistoryLoaded(true); } catch (refreshError) { if (!isCurrentRequest()) return; - const message = getErrorMessage(refreshError) || t("taskDetail.plannerChat.loadFailed", "Failed to load planner chat"); + const message = getErrorMessage(refreshError) || t("taskDetail.plannerChat.loadFailed", "Failed to load task chat"); setError(message); addToastRef.current(message, "error"); } @@ -606,7 +693,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan }, onError: (streamError: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => { if (!isCurrentStreamRequest()) return; - const message = normalizeChatFailureSummary(streamError, t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond")); + const message = normalizeChatFailureSummary(streamError, t("taskDetail.plannerChat.sendFailed", "Task chat failed to respond")); setError(message); composerStateRef.current = "idle"; setComposerState("idle"); @@ -659,11 +746,12 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan setHistoryLoaded(false); setError(null); try { - const { session: lookupSession } = await fetchTaskPlannerChatSession(task.id, modelPayload, projectId); + const { session: lookupSession } = await fetchTaskPlannerChatSession(task.id, {}, projectId); if (loadRequestRef.current !== requestId) return; if (!lookupSession) { sessionIdRef.current = null; setSessionId(null); + setSessionModel(taskChatModelRef.current); replacePendingMessages([], null); setMessages([]); setHistoryLoaded(true); @@ -678,6 +766,15 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan ]); if (loadRequestRef.current !== requestId) return; const resolvedSession = refreshedSessionResult.session; + setSessionModel( + resolvedSession.modelProvider && resolvedSession.modelId + ? { + provider: resolvedSession.modelProvider, + modelId: resolvedSession.modelId, + ...(resolvedSession.thinkingLevel ? { thinkingLevel: resolvedSession.thinkingLevel } : {}), + } + : taskChatModelRef.current, + ); setMessages(sortMessages(loadedMessages)); setHistoryLoaded(true); if (resolvedSession.isGenerating || resolvedSession.inFlightGeneration) { @@ -694,7 +791,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan } } catch (err) { if (loadRequestRef.current !== requestId) return; - const message = getErrorMessage(err) || t("taskDetail.plannerChat.loadFailed", "Failed to load planner chat"); + const message = getErrorMessage(err) || t("taskDetail.plannerChat.loadFailed", "Failed to load task chat"); setError(message); setHistoryLoaded(false); } finally { @@ -702,7 +799,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan setLoading(false); } } - }, [modelPayload, projectId, replacePendingMessages, startPlannerStream, task.id, t]); + }, [projectId, replacePendingMessages, startPlannerStream, task.id, t]); useEffect(() => { loadRequestRef.current += 1; @@ -711,6 +808,8 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan streamRef.current = null; sessionIdRef.current = null; setSessionId(null); + hasLocalTargetOverrideRef.current = false; + setSessionModel(taskChatModelRef.current); pendingMessagesRef.current = []; setPendingMessages([]); setQueueActionPending(false); @@ -825,7 +924,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan restorePendingQueueReservation(reservation); composerStateRef.current = "idle"; setComposerState("idle"); - const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"); + const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Task chat failed to respond"); setError(message); addToastRef.current(message, "error"); } @@ -851,11 +950,18 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan setError(null); try { - const { session } = sessionIdRef.current - ? { session: { id: sessionIdRef.current } } - : await ensureTaskPlannerChatSession(task.id, modelPayload, projectId); + const { session } = await ensureTaskPlannerChatSession(task.id, modelPayload, projectId); if (!isCurrentStreamRequest()) return; const resolvedSessionId = session.id; + setSessionModel( + session.modelProvider && session.modelId + ? { + provider: session.modelProvider, + modelId: session.modelId, + ...(session.thinkingLevel ? { thinkingLevel: session.thinkingLevel } : {}), + } + : taskChatModel, + ); sessionIdRef.current = resolvedSessionId; setSessionId(resolvedSessionId); // FNXC:TaskPlannerChatQueue 2026-08-18-23:13: @@ -872,14 +978,14 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan }); } catch (err) { if (!isCurrentStreamRequest()) return; - const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"); + const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Task chat failed to respond"); setError(message); addToast(message, "error"); composerStateRef.current = "idle"; setComposerState("idle"); setStreamingThinking(""); } - }, [addToast, enqueuePendingMessage, modelPayload, projectId, replacePendingMessages, startPlannerStream, task.id, t]); + }, [addToast, enqueuePendingMessage, modelPayload, projectId, replacePendingMessages, startPlannerStream, task.id, taskChatModel, t]); const refreshTaskAfterEdit = useCallback(async (hadDiscardedSideEffect: boolean) => { try { @@ -942,7 +1048,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan replacementMessage, onAccepted: resolve, onRejected: (message) => { - const failureMessage = message || t("taskDetail.plannerChat.editFailed", "Failed to edit planner chat message"); + const failureMessage = message || t("taskDetail.plannerChat.editFailed", "Failed to edit task chat message"); setError(failureMessage); addToastRef.current(failureMessage, "error"); void refreshMessagesForSession(resolvedSessionId, () => true).finally(() => reject(new Error(failureMessage))); @@ -1313,13 +1419,13 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan The planner Chat tab owns an in-view expand/collapse button so mobile users can reclaim vertical room while keeping close/back/task identity controls reachable. This state is independent from Activity Live expansion because Activity still represents operational steering/history, not planner-model conversation. */ return ( -
+
{onExpandedChange && (