FN-033: use default chat model for task chat
Task chat now uses the resolved project default while matching general chat controls and session behavior. - Resolve the effective task-chat model and expose model/thinking selectors in the task chat composer. - Persist per-session model and thinking overrides through scoped task-chat routes. - Refresh task-chat UI, documentation, tests, and release metadata for the new behavior. Files changed: .changeset/fn-033-task-chat-default-model.md | 7 + docs/agents.md | 4 +- docs/dashboard-guide.md | 26 +-- packages/dashboard/app/api/chat/chat.ts | 26 +-- .../dashboard/app/components/TaskDetailModal.tsx | 4 +- .../app/components/TaskPlannerChatTab.css | 38 ++- .../app/components/TaskPlannerChatTab.tsx | 211 +++++++++++++++++---- .../__tests__/TaskDetailModal.test-helpers.ts | 2 + .../components/__tests__/TaskDetailModal.test.tsx | 4 +- .../__tests__/TaskPlannerChatTab.test.tsx | 149 +++++++++------ .../__tests__/effective-model-resolution.test.ts | 32 +++ .../app/components/effective-model-resolution.ts | 30 ++- .../dashboard/src/__tests__/chat-manager.test.ts | 14 +- .../routes-task-planner-chat-session.test.ts | 210 +++++++++++++++++++ .../dashboard/src/routes/register-chat-routes.ts | 116 +++++++---- 15 files changed, 700 insertions(+), 173 deletions(-) Fusion-Task-Id: FN-033 Fusion-Task-Lineage: 10cffb5e-1a38-47e1-9b29-cd3cb0b99b81 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-033-task-chat-default-model.md
Normal file
7
.changeset/fn-033-task-chat-default-model.md
Normal file
@@ -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.
|
||||
@@ -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:<taskId>` 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:<taskId>` 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
|
||||
|
||||
|
||||
@@ -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:<taskId>` 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:<sessionId>` 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:<sessionId>` 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**.
|
||||
|
||||
<!-- FNXC:ChatComposerDocs 2026-08-19-03:02: Primary conversation drafts must stop displacing the transcript while retaining an intentional desktop/tablet escape hatch. -->
|
||||
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.
|
||||
|
||||
<!-- FNXC:NativeStructureEmbed 2026-07-19-20:00: Roadmap-item references now resolve through the roadmap plugin's PostgreSQL-safe read adapter and open the restored hosted Roadmaps destination. -->
|
||||
- Chat recognizes native structure references in both assistant and user messages using the explicit `fusion://<kind>/<id>` 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)
|
||||
<!-- FNXC:ChatCancellation 2026-08-19-05:20: Direct/Quick Chat and task Planner Chat must make an explicit Stop durable for reload and the next model turn, while Chat Rooms and CLI-agent-backed sessions retain their separate cancellation semantics. -->
|
||||
- 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.
|
||||
<!-- FNXC:ChatEmptyMessage 2026-07-10-00:00: Empty final assistant responses can be legitimate provider output (for example a Grok CLI run ending without text). Document the shared Chat/Planner Chat behavior so operators see "No message" instead of interpreting a blank bubble as a rendering failure. -->
|
||||
- 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
|
||||
<!-- FNXC:TaskDetailActivity 2026-07-04-19:10: FN-7536: the opening tap that shows the Activity views menu can itself trigger a same-gesture window resize/scroll echo (Android/mobile Chrome URL-bar collapse or tap-into-view auto-scroll, distinct from the iOS visualViewport echo above). That echo, and scrolling the `.detail-tabs` horizontal tab strip itself, now only reposition the open menu instead of closing it; a later, real viewport change still closes it as before. -->
|
||||
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:<taskId>` 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:
|
||||
|
||||
|
||||
@@ -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<ChatSessionResponse> {
|
||||
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 } : {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null>(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<ResolvedModelSelection & { thinkingLevel?: string }>(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 (
|
||||
<section className={`task-planner-chat${chatMessageLayout === "full-width" ? " task-planner-chat--full-width" : ""}`} aria-label={t("taskDetail.plannerChat.label", "Planner chat")} data-testid="task-planner-chat-panel">
|
||||
<section className={`task-planner-chat${chatMessageLayout === "full-width" ? " task-planner-chat--full-width" : ""}`} aria-label={t("taskDetail.plannerChat.label", "Task-aware chat")} data-testid="task-planner-chat-panel">
|
||||
{onExpandedChange && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm task-planner-chat-expand-toggle task-planner-chat-expand-toggle--overlay"
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
aria-label={expanded ? t("taskDetail.plannerChat.collapse", "Collapse planner chat") : t("taskDetail.plannerChat.expand", "Expand planner chat")}
|
||||
aria-label={expanded ? t("taskDetail.plannerChat.collapse", "Collapse task chat") : t("taskDetail.plannerChat.expand", "Expand task chat")}
|
||||
aria-pressed={expanded}
|
||||
aria-expanded={expanded}
|
||||
data-testid="task-planner-chat-expand-toggle"
|
||||
@@ -1332,26 +1438,26 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
|
||||
{loading ? (
|
||||
<div className="task-planner-chat-state" role="status" aria-live="polite">
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
<span>{t("taskDetail.plannerChat.loading", "Loading planner chat…")}</span>
|
||||
<span>{t("taskDetail.plannerChat.loading", "Loading task chat…")}</span>
|
||||
</div>
|
||||
) : showEmptyState ? (
|
||||
<div className="task-planner-chat-empty" data-testid="task-planner-chat-empty">
|
||||
{isUsableModel(planningModel) && (
|
||||
{isUsableModel(displayedModel) && (
|
||||
<span
|
||||
className="task-planner-chat-empty-model"
|
||||
data-testid="task-planner-chat-model"
|
||||
title={planningModelLabel}
|
||||
aria-label={planningModelLabel}
|
||||
title={displayedModelLabel}
|
||||
aria-label={displayedModelLabel}
|
||||
>
|
||||
<ProviderIcon provider={planningModel.provider} size="sm" />
|
||||
<ProviderIcon provider={displayedModel.provider} size="sm" />
|
||||
</span>
|
||||
)}
|
||||
<div className="task-planner-chat-empty-copy">
|
||||
<h5>{t("taskDetail.plannerChat.emptyTitle", "Start a task-aware chat")}</h5>
|
||||
<p>{t("taskDetail.plannerChat.emptyBody", "Ask planning questions about this task's current status, recent activity, blockers, next steps, or definition. Starter prompts send as normal chat messages.")}</p>
|
||||
<p>{t("taskDetail.plannerChat.emptyBody", "Ask questions about this task's current status, recent activity, blockers, next steps, or definition. Starter prompts send as normal chat messages.")}</p>
|
||||
</div>
|
||||
{starterPrompts.length > 0 && (
|
||||
<div className="task-planner-chat-starters" aria-label={t("taskDetail.plannerChat.startersLabel", "Planner chat starter prompts")}>
|
||||
<div className="task-planner-chat-starters" aria-label={t("taskDetail.plannerChat.startersLabel", "Task chat starter prompts")}>
|
||||
{starterPrompts.map((prompt) => (
|
||||
<button
|
||||
key={prompt.id}
|
||||
@@ -1380,11 +1486,11 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
|
||||
streamingThinking={message.thinkingOutput ?? streamingThinking}
|
||||
streamingToolCalls={streamingToolCalls}
|
||||
forcePlain={false}
|
||||
agentName={t("taskDetail.plannerChat.assistant", "Planner")}
|
||||
agentName={t("taskDetail.plannerChat.assistant", "Task Chat")}
|
||||
hideAssistantIdentity={false}
|
||||
showAssistantModelTag={Boolean(activeModelTag)}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={planningModelProvider ?? null}
|
||||
activeModelProvider={displayedModelProvider ?? null}
|
||||
toolCallRenderer={(toolCall, index) => renderPlannerToolCall(message, toolCall, index)}
|
||||
/>
|
||||
);
|
||||
@@ -1402,11 +1508,11 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
|
||||
key={message.id}
|
||||
message={toStandardChatMessage(message)}
|
||||
forcePlain={false}
|
||||
agentName={t("taskDetail.plannerChat.assistant", "Planner")}
|
||||
agentName={t("taskDetail.plannerChat.assistant", "Task Chat")}
|
||||
hideAssistantIdentity={false}
|
||||
showAssistantModelTag={Boolean(activeModelTag)}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={planningModelProvider ?? null}
|
||||
activeModelProvider={displayedModelProvider ?? null}
|
||||
activeSessionId={sessionId}
|
||||
projectId={projectId}
|
||||
isAwaitingQuestionAnswer={message.role === "assistant"}
|
||||
@@ -1428,11 +1534,11 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
|
||||
streamingThinking={streamingThinking}
|
||||
streamingToolCalls={[]}
|
||||
forcePlain={false}
|
||||
agentName={t("taskDetail.plannerChat.assistant", "Planner")}
|
||||
agentName={t("taskDetail.plannerChat.assistant", "Task Chat")}
|
||||
hideAssistantIdentity={false}
|
||||
showAssistantModelTag={Boolean(activeModelTag)}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={planningModelProvider ?? null}
|
||||
activeModelProvider={displayedModelProvider ?? null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -1483,11 +1589,32 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
|
||||
</div>
|
||||
)}
|
||||
<div className="task-planner-chat-composer">
|
||||
<div className="task-planner-chat-target-controls" data-testid="task-planner-chat-target-controls">
|
||||
<CustomModelDropdown
|
||||
id="task-planner-chat-model-selector"
|
||||
label={t("taskDetail.plannerChat.modelLabel", "Chat model")}
|
||||
models={models}
|
||||
value={displayedModelProvider && displayedModelId ? `${displayedModelProvider}/${displayedModelId}` : ""}
|
||||
onChange={(value) => void handleTaskChatModelChange(value)}
|
||||
placeholder={t("model.selectPlaceholder", "Select a model…")}
|
||||
defaultOptionLabel={t("models.useDefault", "Use project default")}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
disabled={queueActionPending || composerState === "sending"}
|
||||
/>
|
||||
<ChatThinkingLevelControl
|
||||
level={displayedModel.thinkingLevel}
|
||||
defaultThinkingLevel={taskChatModel.thinkingLevel ?? "off"}
|
||||
showTargetSection={false}
|
||||
onChange={(level) => void handleTaskChatThinkingChange(level)}
|
||||
disabled={queueActionPending || composerState === "sending"}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={handleComposerRef}
|
||||
className="input task-planner-chat-input"
|
||||
aria-label={t("taskDetail.plannerChat.inputLabel", "Message planner chat")}
|
||||
placeholder={t("taskDetail.plannerChat.placeholder", "Ask the planner about this task… Type / for commands")}
|
||||
aria-label={t("taskDetail.plannerChat.inputLabel", "Message task chat")}
|
||||
placeholder={t("taskDetail.plannerChat.placeholder", "Ask about this task… Type / for commands")}
|
||||
value={draft}
|
||||
onChange={handleDraftChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -138,6 +138,8 @@ vi.mock("lucide-react", () => ({
|
||||
Square: (props: any) => React.createElement("svg", { "data-testid": "square-icon", ...props }),
|
||||
Info: (props: any) => React.createElement("svg", { "data-testid": "info-icon", ...props }),
|
||||
Bot: () => null,
|
||||
// FNXC:TaskChatDefaultModel 2026-08-19-12:12: Task Chat reuses the Direct Chat thinking-level control, so its Brain icon is part of the shared modal mock surface.
|
||||
Brain: () => null,
|
||||
CircleDot: () => null,
|
||||
XCircle: () => null,
|
||||
Workflow: () => null,
|
||||
|
||||
@@ -240,7 +240,7 @@ describe("TaskDetailModal planner Chat tab", () => {
|
||||
|
||||
const toggle = screen.getByTestId("task-planner-chat-expand-toggle");
|
||||
expect(detail).not.toHaveClass("task-detail-content--planner-chat-expanded");
|
||||
expect(toggle).toHaveAccessibleName("Expand planner chat");
|
||||
expect(toggle).toHaveAccessibleName("Expand task chat");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Activity" })).toBeInTheDocument();
|
||||
@@ -248,7 +248,7 @@ describe("TaskDetailModal planner Chat tab", () => {
|
||||
await user.click(toggle);
|
||||
|
||||
expect(detail).toHaveClass("task-detail-content--planner-chat-expanded");
|
||||
expect(screen.getByTestId("task-planner-chat-expand-toggle")).toHaveAccessibleName("Collapse planner chat");
|
||||
expect(screen.getByTestId("task-planner-chat-expand-toggle")).toHaveAccessibleName("Collapse task chat");
|
||||
});
|
||||
|
||||
it("resets planner Chat expanded mode when switching tasks", async () => {
|
||||
|
||||
@@ -108,7 +108,7 @@ function renderPlannerChat(overrides: Partial<React.ComponentProps<typeof TaskPl
|
||||
<TaskPlannerChatTab
|
||||
task={makeTask("FN-7310")}
|
||||
active
|
||||
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
taskChatModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
addToast={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
@@ -198,11 +198,11 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(emptyState).toHaveTextContent("Start a task-aware chat");
|
||||
expect(document.querySelector(".task-planner-chat-header")).toBeNull();
|
||||
expect(screen.queryByText("Planner Chat")).toBeNull();
|
||||
expect(emptyState).toHaveTextContent("Ask planning questions about this task's current status, recent activity, blockers, next steps, or definition.");
|
||||
expect(emptyState).toHaveTextContent("Ask questions about this task's current status, recent activity, blockers, next steps, or definition.");
|
||||
expect(emptyState).toHaveTextContent("Starter prompts send as normal chat messages.");
|
||||
expect(mockFetchTaskPlannerChatSession).toHaveBeenCalledWith(
|
||||
"FN-7310",
|
||||
{ modelProvider: "anthropic", modelId: "claude-plan" },
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
|
||||
@@ -221,6 +221,33 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(screen.getAllByTestId(/task-planner-chat-starter-/)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("uses the Direct Chat model target and exposes model/thinking controls without losing task scope", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
|
||||
renderPlannerChat({
|
||||
taskChatModel: { provider: "openai", modelId: "gpt-direct", thinkingLevel: "high" },
|
||||
});
|
||||
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
expect(screen.getByRole("button", { name: "Chat model" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-thinking-btn")).toHaveAccessibleName("Thinking level");
|
||||
await user.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
|
||||
|
||||
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
|
||||
"FN-7310",
|
||||
{ modelProvider: "openai", modelId: "gpt-direct", thinkingLevel: "high" },
|
||||
undefined,
|
||||
);
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
"chat-planner",
|
||||
"Summarize the recent activity for this task and call out anything important I should know.",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined,
|
||||
{ taskId: "FN-7310" },
|
||||
);
|
||||
});
|
||||
|
||||
it("caps the loaded planner composer, preserves deliberate expansion, and resets on clear", async () => {
|
||||
mockFetchChatMessages.mockResolvedValueOnce({
|
||||
messages: [{ id: "planner-history", sessionId: "chat-planner", role: "assistant", content: "Loaded planner history", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" }],
|
||||
@@ -228,7 +255,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
|
||||
await screen.findByText("Loaded planner history");
|
||||
const input = await screen.findByLabelText("Message planner chat") as HTMLTextAreaElement;
|
||||
const input = await screen.findByLabelText("Message task chat") as HTMLTextAreaElement;
|
||||
Object.defineProperty(input, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => 500,
|
||||
@@ -309,7 +336,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(emptyState).toHaveTextContent("Start a task-aware chat");
|
||||
expect(mockFetchTaskPlannerChatSession).toHaveBeenCalledWith(
|
||||
"FN-7310",
|
||||
{ modelProvider: "anthropic", modelId: "claude-plan" },
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
|
||||
@@ -350,7 +377,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "What changed in this completed task?");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "What changed in this completed task?");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
|
||||
@@ -399,12 +426,12 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(await screen.findByTestId("task-planner-chat-empty")).toBeInTheDocument();
|
||||
const toggle = screen.getByTestId("task-planner-chat-expand-toggle");
|
||||
const modelBadge = screen.getByTestId("task-planner-chat-model");
|
||||
expect(toggle).toHaveAccessibleName("Collapse planner chat");
|
||||
expect(toggle).toHaveAccessibleName("Collapse task chat");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(toggle).toHaveClass("task-planner-chat-expand-toggle--overlay");
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(toggle);
|
||||
expect(screen.getByTestId("task-planner-chat-empty")).toContainElement(modelBadge);
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(screen.getByLabelText("Message planner chat"));
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(screen.getByLabelText("Message task chat"));
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await userEvent.click(toggle);
|
||||
@@ -412,8 +439,9 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(onExpandedChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("omits model override when the effective planning model is undefined", async () => {
|
||||
renderPlannerChat({ planningModel: {} });
|
||||
it("omits model override when the effective task Chat model is undefined", async () => {
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
|
||||
renderPlannerChat({ taskChatModel: {} });
|
||||
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
expect(mockFetchTaskPlannerChatSession).toHaveBeenCalledWith("FN-7310", {}, undefined);
|
||||
@@ -429,7 +457,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
<TaskPlannerChatTab
|
||||
task={makeTask("FN-7310")}
|
||||
active
|
||||
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
taskChatModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -473,12 +501,15 @@ describe("TaskPlannerChatTab", () => {
|
||||
<TaskPlannerChatTab
|
||||
task={makeTask("FN-7312")}
|
||||
active
|
||||
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
taskChatModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
mockEnsureTaskPlannerChatSession.mockImplementationOnce((taskId: string) => Promise.resolve({
|
||||
session: makePlannerSession({ id: taskId === "FN-7312" ? "chat-new-task" : "chat-planner", agentId: `task-planner:${taskId}` }),
|
||||
}));
|
||||
firstLoad.resolve({
|
||||
session: {
|
||||
id: "chat-old-task",
|
||||
@@ -544,7 +575,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
<TaskPlannerChatTab
|
||||
task={makeTask("FN-7312")}
|
||||
active
|
||||
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
taskChatModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -691,7 +722,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
renderPlannerChat();
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Loading planner chat…");
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Loading task chat…");
|
||||
expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Summarize recent activity/ })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -713,7 +744,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("History unavailable");
|
||||
expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Message planner chat")).toBeEnabled();
|
||||
expect(screen.getByLabelText("Message task chat")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("renders persisted planner-chat messages", async () => {
|
||||
@@ -745,7 +776,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
<TaskPlannerChatTab
|
||||
task={makeTask("FN-7310")}
|
||||
active
|
||||
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
taskChatModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
</ChatMessageLayoutProvider>,
|
||||
@@ -779,7 +810,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
await screen.findByText("Earlier plan");
|
||||
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Keep streaming");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Keep streaming");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
metrics.scrollTop = 120;
|
||||
fireEvent.scroll(screen.getByTestId("task-planner-chat-transcript"));
|
||||
@@ -804,7 +835,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
renderPlannerChat();
|
||||
await screen.findByText("Earlier plan");
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Keep streaming");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Keep streaming");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
metrics.scrollHeight = 1400;
|
||||
@@ -859,7 +890,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Help plan this");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Help plan this");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
@@ -884,7 +915,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
const input = screen.getByLabelText("Message task chat");
|
||||
await user.type(input, "First reply");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
act(() => {
|
||||
@@ -923,7 +954,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat({ projectId: "project-1" });
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
const input = screen.getByLabelText("Message task chat");
|
||||
fireEvent.change(input, { target: { value: "First mobile tap planner message" } });
|
||||
firstTapSendFromFocusedPlannerTextarea(input, screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
@@ -950,17 +981,22 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
const input = screen.getByLabelText("Message task chat");
|
||||
const sendButton = screen.getByRole("button", { name: "Send" });
|
||||
expect(sendButton).toBeDisabled();
|
||||
fireEvent.change(input, { target: { value: " \n " } });
|
||||
expect(sendButton).toBeDisabled();
|
||||
firstTapSendFromFocusedPlannerTextarea(input, sendButton);
|
||||
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
fireEvent.change(input, { target: { value: "Do not duplicate planner tap" } });
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
firstTapSendFromFocusedPlannerTextarea(input, sendButton);
|
||||
await waitFor(() => expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(1));
|
||||
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
|
||||
fireEvent.click(sendButton);
|
||||
|
||||
@@ -985,7 +1021,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Think with an icon");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Think with an icon");
|
||||
const sendButton = screen.getByTestId("chat-send-btn");
|
||||
expect(sendButton).toHaveAccessibleName("Send");
|
||||
expect(sendButton.querySelector("svg")).toBeTruthy();
|
||||
@@ -994,6 +1030,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(sendButton.querySelector("span")).toBeNull();
|
||||
|
||||
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
|
||||
await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(1));
|
||||
act(() => {
|
||||
streamHandlers.onThinking?.("checking the plan");
|
||||
});
|
||||
@@ -1105,7 +1142,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(await screen.findByText("Stored answer")).toBeInTheDocument();
|
||||
expect(screen.getByText("stored plan notes")).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Think about this");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Think about this");
|
||||
fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" });
|
||||
|
||||
expect(await screen.findByText("Thinking…")).toBeInTheDocument();
|
||||
@@ -1117,12 +1154,12 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Mobile first tap");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Mobile first tap");
|
||||
const sendButton = screen.getByTestId("chat-send-btn");
|
||||
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
|
||||
fireEvent.click(sendButton);
|
||||
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(1));
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
"chat-planner",
|
||||
"Mobile first tap",
|
||||
@@ -1152,7 +1189,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Refresh unavailable");
|
||||
expect(addToast).toHaveBeenCalledWith("Refresh unavailable", "error");
|
||||
expect(screen.getByLabelText("Message planner chat")).toBeEnabled();
|
||||
expect(screen.getByLabelText("Message task chat")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("sends manual status/progress questions with the current task identity", async () => {
|
||||
@@ -1160,7 +1197,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat({ task: makeTask("FN-STATUS") });
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "What is the current status and progress?");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "What is the current status and progress?");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
@@ -1225,11 +1262,11 @@ describe("TaskPlannerChatTab", () => {
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
|
||||
renderPlannerChat({
|
||||
task: { ...makeTask("FN-MISSING-CONTEXT"), dependencies: [], prompt: undefined, log: undefined } as any,
|
||||
planningModel: { provider: "openai", modelId: "gpt-planner" },
|
||||
taskChatModel: { provider: "openai", modelId: "gpt-planner" },
|
||||
});
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Explain the current task state with whatever context exists.");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Explain the current task state with whatever context exists.");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
|
||||
@@ -1405,7 +1442,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat({ projectId: "project-1", onTaskUpdated });
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Tell the executor to keep Activity and Chat separate");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Tell the executor to keep Activity and Chat separate");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByTestId("task-planner-chat-steering-confirmation")).toHaveTextContent("Added as steering comment");
|
||||
@@ -1499,7 +1536,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat({ projectId: "project-1", onTaskUpdated });
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Delete the risky parts and rewrite the security flow broadly");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Delete the risky parts and rewrite the security flow broadly");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
const question = await screen.findByTestId("chat-question-response");
|
||||
@@ -1552,7 +1589,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat({ projectId: "project-1", onTaskUpdated });
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Add empty steering");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Add empty steering");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByText("I could not add that as steering.")).toBeInTheDocument();
|
||||
@@ -1641,7 +1678,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "slow planner prompt");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "slow planner prompt");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByText("slow planner prompt")).toBeInTheDocument();
|
||||
@@ -1709,7 +1746,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "hello after 429");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "hello after 429");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByText("hello after 429")).toBeInTheDocument();
|
||||
@@ -1730,7 +1767,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "blocked before persist");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "blocked before persist");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByText("blocked before persist")).toBeInTheDocument();
|
||||
@@ -1749,14 +1786,14 @@ describe("TaskPlannerChatTab", () => {
|
||||
});
|
||||
const { rerender } = renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "old task message");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "old task message");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
rerender(
|
||||
<TaskPlannerChatTab
|
||||
task={makeTask("FN-7312")}
|
||||
active
|
||||
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
taskChatModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -1775,11 +1812,11 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Question");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "Question");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Planner unavailable");
|
||||
await waitFor(() => expect(screen.getByLabelText("Message planner chat")).toBeEnabled());
|
||||
await waitFor(() => expect(screen.getByLabelText("Message task chat")).toBeEnabled());
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -1821,7 +1858,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "In flight");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "In flight");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
const optimisticMessage = await screen.findByText("In flight");
|
||||
@@ -1928,7 +1965,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("chat-message-edit-m1");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "another message");
|
||||
await user.type(screen.getByLabelText("Message task chat"), "another message");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("chat-message-edit-m1")).toBeNull());
|
||||
@@ -2029,7 +2066,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
describe("slash-command /steer", () => {
|
||||
it("shows /steer in the '/' menu, disabled with a hint, when the task's agent is not running", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "todo" }) });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
const textarea = await screen.findByLabelText("Message task chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/" } });
|
||||
|
||||
@@ -2040,7 +2077,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
it("enables /steer in the menu when the task's agent is running (column === in-progress)", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
const textarea = await screen.findByLabelText("Message task chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/" } });
|
||||
|
||||
@@ -2050,7 +2087,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
it("submitting '/steer do X' on a running task calls addSteeringComment and does not start a planner-chat send", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }), projectId: "proj-1" });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
const textarea = await screen.findByLabelText("Message task chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/steer do X" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
@@ -2062,7 +2099,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
it("submitting a normal message still starts a planner-chat send when the task's agent is running", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
const textarea = await screen.findByLabelText("Message task chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "What is the status?" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
@@ -2074,7 +2111,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
it("submitting '/steer ...' with no running agent shows a hint and does not dispatch or send a message", async () => {
|
||||
const addToast = vi.fn();
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "todo" }), addToast });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
const textarea = await screen.findByLabelText("Message task chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/steer do X" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
@@ -2087,7 +2124,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
|
||||
it("does not dispatch when the trigger appears mid-message", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
const textarea = await screen.findByLabelText("Message task chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "please /steer this" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
@@ -2105,7 +2142,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
mockAddSteeringComment.mockReturnValueOnce(runPromise as unknown as ReturnType<typeof mockAddSteeringComment>);
|
||||
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }), projectId: "proj-1" });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
const textarea = await screen.findByLabelText("Message task chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/steer do X" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
@@ -2125,7 +2162,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
// accessible copy must say "command", not the reused skill-menu copy.
|
||||
it("labels the command menu with command-specific copy, not skill copy", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
const textarea = await screen.findByLabelText("Message task chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/" } });
|
||||
|
||||
@@ -2153,7 +2190,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
await user.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
|
||||
await waitFor(() => expect(streamHandlers).toHaveLength(1));
|
||||
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
const input = screen.getByLabelText("Message task chat");
|
||||
for (const message of ["Follow-up A", "Follow-up B", "Follow-up B"]) {
|
||||
await user.type(input, message);
|
||||
await user.keyboard("{Enter}");
|
||||
@@ -2208,7 +2245,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
<TaskPlannerChatTab
|
||||
task={makeTask("FN-7311")}
|
||||
active
|
||||
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
taskChatModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -2237,7 +2274,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
const input = screen.getByLabelText("Message task chat");
|
||||
for (const message of ["First", "Duplicate", "Duplicate"]) {
|
||||
await user.type(input, message);
|
||||
await user.keyboard("{Enter}");
|
||||
@@ -2281,7 +2318,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
const input = screen.getByLabelText("Message task chat");
|
||||
for (const message of ["Keep this first", "Force this second"]) {
|
||||
await user.type(input, message);
|
||||
await user.keyboard("{Enter}");
|
||||
@@ -2318,7 +2355,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
const input = screen.getByLabelText("Message task chat");
|
||||
await user.type(input, "Queued after stop");
|
||||
await user.keyboard("{Enter}");
|
||||
await waitFor(() => expect(streamHandlers).toHaveLength(1));
|
||||
@@ -2339,7 +2376,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
await user.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
|
||||
const input = screen.getByLabelText("Message planner chat");
|
||||
const input = screen.getByLabelText("Message task chat");
|
||||
await user.type(input, "Retain me");
|
||||
await user.keyboard("{Enter}");
|
||||
await waitFor(() => expect(screen.getByTestId("task-planner-chat-pending-force-0")).toBeInTheDocument());
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseRuntimeModelMarker,
|
||||
resolveEffectiveExecutor,
|
||||
resolveEffectivePlanning,
|
||||
resolveEffectiveTaskChat,
|
||||
resolveEffectiveValidator,
|
||||
} from "../effective-model-resolution";
|
||||
|
||||
@@ -149,4 +150,35 @@ describe("effective model resolution", () => {
|
||||
expect(resolveEffectivePlanning({ ...baseTask, planningModelProvider: null, planningModelId: null } as Task, [log("triage", "Planning using model: log-planning/log-planning-model")], settings)).toEqual({ provider: "log-planning", modelId: "log-planning-model" });
|
||||
expect(resolveEffectivePlanning({ ...baseTask, planningModelProvider: null, planningModelId: null } as Task, [], settings)).toEqual({ provider: "settings-planning", modelId: "settings-planning-model" });
|
||||
});
|
||||
|
||||
it("resolves task Chat from the complete Direct Chat model and thinking target", () => {
|
||||
expect(resolveEffectiveTaskChat({
|
||||
...settings,
|
||||
chatDefaultKind: "model",
|
||||
chatDefaultModelProvider: "openai",
|
||||
chatDefaultModelId: "gpt-direct",
|
||||
chatDefaultThinkingLevel: "high",
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-planner",
|
||||
} as Settings)).toEqual({ provider: "openai", modelId: "gpt-direct", thinkingLevel: "high" });
|
||||
});
|
||||
|
||||
it("falls back from incomplete or agent Direct Chat defaults to the effective project model", () => {
|
||||
expect(resolveEffectiveTaskChat({
|
||||
...settings,
|
||||
chatDefaultKind: "model",
|
||||
chatDefaultModelProvider: "openai",
|
||||
chatDefaultModelId: undefined,
|
||||
defaultProviderOverride: "google",
|
||||
defaultModelIdOverride: "gemini-direct",
|
||||
chatDefaultThinkingLevel: "medium",
|
||||
} as Settings)).toEqual({ provider: "google", modelId: "gemini-direct", thinkingLevel: "medium" });
|
||||
expect(resolveEffectiveTaskChat({
|
||||
...settings,
|
||||
chatDefaultKind: "agent",
|
||||
chatDefaultAgentId: "agent-direct",
|
||||
defaultProvider: "mock",
|
||||
defaultModelId: "ignored",
|
||||
} as Settings)).toEqual({ provider: "mock", modelId: "scripted" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,15 @@ import type { Agent, AgentLogEntry, ResolvedModelSelection, Settings, Task, Task
|
||||
import { isWipColumnRole } from "../utils/columnRoles";
|
||||
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:50: these are AGENT ROLE comparisons, not
|
||||
// column guards — the planner LANE keeps the name `triage`; U11 removed only the COLUMN.
|
||||
import { PLANNER_AGENT_ROLE, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
|
||||
import { PLANNER_AGENT_ROLE, resolveProjectDefaultModel, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
|
||||
import { ACTIVE_STATUSES } from "../utils/taskActivity";
|
||||
|
||||
export type ModelSelection = ResolvedModelSelection;
|
||||
|
||||
export type TaskChatModelSelection = ModelSelection & {
|
||||
thinkingLevel?: string;
|
||||
};
|
||||
|
||||
export { ACTIVE_STATUSES };
|
||||
|
||||
const STRING_OBJECT_TAG = "[object String]";
|
||||
@@ -205,3 +210,26 @@ export function resolveEffectivePlanning(
|
||||
}
|
||||
return resolveTaskPlanningModel(task, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve task-detail Chat from the project Direct Chat default, not a workflow
|
||||
* planning lane. Agent-mode Direct Chat defaults intentionally fall through to
|
||||
* the effective project model because task Chat keeps its synthetic task-bound
|
||||
* identity and permissions.
|
||||
*/
|
||||
export function resolveEffectiveTaskChat(settings?: Settings): TaskChatModelSelection {
|
||||
/*
|
||||
FNXC:TaskChatDefaultModel 2026-08-19-12:12:
|
||||
Task-detail Chat follows the project Direct Chat model target while retaining the synthetic task session so server-built task context and scoped tools remain unchanged. A configured Direct Chat agent is not impersonated; test mode continues through resolveProjectDefaultModel.
|
||||
*/
|
||||
const directModel = settings?.chatDefaultKind === "model"
|
||||
&& settings.chatDefaultModelProvider
|
||||
&& settings.chatDefaultModelId
|
||||
? {
|
||||
provider: settings.chatDefaultModelProvider,
|
||||
modelId: settings.chatDefaultModelId,
|
||||
}
|
||||
: resolveProjectDefaultModel(settings);
|
||||
const thinkingLevel = settings?.chatDefaultThinkingLevel ?? settings?.defaultThinkingLevel;
|
||||
return thinkingLevel ? { ...directModel, thinkingLevel } : directModel;
|
||||
}
|
||||
|
||||
@@ -2119,8 +2119,13 @@ describe("ChatManager.sendMessage", () => {
|
||||
id: "chat-001",
|
||||
agentId: "task-planner:FN-7310",
|
||||
status: "active",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-plan",
|
||||
/*
|
||||
FNXC:TaskChatDefaultModel 2026-08-19-12:12:
|
||||
The persisted synthetic task session carries the Direct Chat target into ChatManager. This test keeps the task context and scoped tool assertions alongside the model-loop precedence check.
|
||||
*/
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-direct",
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
|
||||
const createResolvedSession = vi.fn(async () => ({
|
||||
@@ -2164,8 +2169,9 @@ describe("ChatManager.sendMessage", () => {
|
||||
await chatManager.sendMessage("chat-001", "How should I plan this?");
|
||||
|
||||
const createOptions = createResolvedSession.mock.calls[0]?.[0];
|
||||
expect(createOptions.defaultProvider).toBe("anthropic");
|
||||
expect(createOptions.defaultModelId).toBe("claude-plan");
|
||||
expect(createOptions.defaultProvider).toBe("openai");
|
||||
expect(createOptions.defaultModelId).toBe("gpt-direct");
|
||||
expect(createOptions.defaultThinkingLevel).toBe("high");
|
||||
expect(createOptions.systemPrompt).toContain("## Task Planner Chat Context");
|
||||
expect(createOptions.systemPrompt).toContain("Task ID: FN-7310");
|
||||
expect(createOptions.systemPrompt).toContain("Title: Add planner chat");
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { request } from "../test-request.js";
|
||||
import { registerChatRoutes } from "../routes/register-chat-routes.js";
|
||||
|
||||
const PROJECT_ID = "project-a";
|
||||
const TASK_ID = "FN-033";
|
||||
const AGENT_ID = `task-planner:${TASK_ID}`;
|
||||
|
||||
function makeSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "chat-fn-033",
|
||||
agentId: AGENT_ID,
|
||||
title: "FN-033 task chat",
|
||||
status: "active",
|
||||
projectId: PROJECT_ID,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-planner",
|
||||
thinkingLevel: "low",
|
||||
createdAt: "2026-08-19T00:00:00.000Z",
|
||||
updatedAt: "2026-08-19T00:01:00.000Z",
|
||||
pinnedAt: null,
|
||||
cliSessionFile: null,
|
||||
cliExecutorAdapterId: null,
|
||||
inFlightGeneration: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(initialSessions: Array<Record<string, unknown>> = []) {
|
||||
const sessions = [...initialSessions];
|
||||
const lifecycleLocks = new Map<string, Promise<void>>();
|
||||
const withPlanningLifecycleLock = async <T>(id: string, fn: () => Promise<T>): Promise<T> => {
|
||||
const prior = lifecycleLocks.get(id) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => { release = resolve; });
|
||||
lifecycleLocks.set(id, current);
|
||||
await prior;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
if (lifecycleLocks.get(id) === current) lifecycleLocks.delete(id);
|
||||
}
|
||||
};
|
||||
const updateSession = vi.fn(async (id: string, updates: Record<string, unknown>) => {
|
||||
const session = sessions.find((candidate) => candidate.id === id);
|
||||
if (!session) return undefined;
|
||||
Object.assign(session, updates);
|
||||
return session;
|
||||
});
|
||||
const createSession = vi.fn(async (input: Record<string, unknown>) => {
|
||||
const session = makeSession({
|
||||
id: `created-${sessions.length + 1}`,
|
||||
...input,
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
const findLatestActiveSessionForTarget = vi.fn(async (input: { agentId: string; projectId?: string; modelProvider?: string; modelId?: string }) => {
|
||||
return sessions
|
||||
.filter((session) => session.status === "active" && session.agentId === input.agentId)
|
||||
.filter((session) => !input.projectId || session.projectId === input.projectId)
|
||||
.filter((session) => !input.modelProvider || (session.modelProvider === input.modelProvider && session.modelId === input.modelId))
|
||||
.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)))[0];
|
||||
});
|
||||
const chatStore = {
|
||||
createSession,
|
||||
updateSession,
|
||||
findLatestActiveSessionForTarget,
|
||||
getLastMessageForSessions: vi.fn(async (ids: string[]) => new Map(ids.map((id) => [id, { id }]))),
|
||||
};
|
||||
const scopedStore = {
|
||||
getFusionDir: () => "/route-project/.fusion",
|
||||
getAsyncLayer: () => undefined,
|
||||
getSettings: async () => ({ defaultProvider: "global-provider", defaultModelId: "global-model" }),
|
||||
getTask: async (id: string) => id === TASK_ID ? { id, column: "todo" } : null,
|
||||
withPlanningLifecycleLock,
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = express.Router();
|
||||
registerChatRoutes({
|
||||
router,
|
||||
store: scopedStore,
|
||||
options: { chatStore },
|
||||
getProjectContext: async (req: express.Request) => ({
|
||||
store: scopedStore,
|
||||
projectId: typeof req.query.projectId === "string" ? req.query.projectId : PROJECT_ID,
|
||||
engine: undefined,
|
||||
}),
|
||||
rethrowAsApiError: (error: unknown) => { throw error; },
|
||||
} as never, {
|
||||
parseLastEventId: () => undefined,
|
||||
replayBufferedSSE: () => false,
|
||||
validateOptionalModelField: (value: unknown) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "string") throw new Error("model field must be a string");
|
||||
return value.trim() || undefined;
|
||||
},
|
||||
upload: multer(),
|
||||
});
|
||||
app.use("/api", router);
|
||||
app.use((err: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(err?.statusCode ?? 500).json({ error: err?.message ?? "unknown" });
|
||||
});
|
||||
return { app, sessions, chatStore, updateSession, createSession };
|
||||
}
|
||||
|
||||
describe("task Chat session routes", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("looks up a prior task transcript without matching the current model", async () => {
|
||||
const prior = makeSession();
|
||||
const { app, chatStore } = buildApp([prior]);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"GET",
|
||||
`/api/chat/sessions?lookup=resume&agentId=${encodeURIComponent(AGENT_ID)}&projectId=${PROJECT_ID}&modelProvider=openai&modelId=gpt-direct`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.sessions).toHaveLength(1);
|
||||
expect(response.body.sessions[0].id).toBe(prior.id);
|
||||
expect(chatStore.findLatestActiveSessionForTarget).toHaveBeenCalledWith({ agentId: AGENT_ID, projectId: PROJECT_ID });
|
||||
expect(chatStore.getLastMessageForSessions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates the same task session target on an explicit send without creating a duplicate", async () => {
|
||||
const prior = makeSession();
|
||||
const { app, updateSession, createSession } = buildApp([prior]);
|
||||
|
||||
const response = await request(app, "POST", `/api/chat/task-planner/${TASK_ID}/session?projectId=${PROJECT_ID}`, JSON.stringify({
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-direct",
|
||||
thinkingLevel: "high",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.session.id).toBe(prior.id);
|
||||
expect(response.body.session).toMatchObject({ modelProvider: "openai", modelId: "gpt-direct", thinkingLevel: "high" });
|
||||
expect(updateSession).toHaveBeenCalledWith(prior.id, { modelProvider: "openai", modelId: "gpt-direct", thinkingLevel: "high" });
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates one scoped task session with the Direct model and thinking pair", async () => {
|
||||
const { app, createSession } = buildApp([]);
|
||||
|
||||
const response = await request(app, "POST", `/api/chat/task-planner/${TASK_ID}/session?projectId=${PROJECT_ID}`, JSON.stringify({
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-direct",
|
||||
thinkingLevel: "medium",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.session).toMatchObject({
|
||||
agentId: AGENT_ID,
|
||||
projectId: PROJECT_ID,
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-direct",
|
||||
thinkingLevel: "medium",
|
||||
});
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serializes concurrent first sends into one task transcript", async () => {
|
||||
const { app, sessions, createSession } = buildApp([]);
|
||||
const body = JSON.stringify({ modelProvider: "openai", modelId: "gpt-direct", thinkingLevel: "high" });
|
||||
|
||||
const responses = await Promise.all([
|
||||
request(app, "POST", `/api/chat/task-planner/${TASK_ID}/session?projectId=${PROJECT_ID}`, body, { "content-type": "application/json" }),
|
||||
request(app, "POST", `/api/chat/task-planner/${TASK_ID}/session?projectId=${PROJECT_ID}`, body, { "content-type": "application/json" }),
|
||||
]);
|
||||
|
||||
expect(responses.map((response) => response.status).sort()).toEqual([200, 201]);
|
||||
expect(responses[0].body.session.id).toBe(responses[1].body.session.id);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects malformed targets before persistence", async () => {
|
||||
const { app, updateSession, createSession } = buildApp([makeSession()]);
|
||||
|
||||
const halfPair = await request(app, "POST", `/api/chat/task-planner/${TASK_ID}/session?projectId=${PROJECT_ID}`, JSON.stringify({ modelProvider: "openai" }), { "content-type": "application/json" });
|
||||
const badThinking = await request(app, "POST", `/api/chat/task-planner/${TASK_ID}/session?projectId=${PROJECT_ID}`, JSON.stringify({ modelProvider: "openai", modelId: "gpt-direct", thinkingLevel: "extreme" }), { "content-type": "application/json" });
|
||||
|
||||
expect(halfPair.status).toBe(400);
|
||||
expect(badThinking.status).toBe(400);
|
||||
expect(updateSession).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not cross project task scope", async () => {
|
||||
const { app, updateSession, createSession } = buildApp([makeSession({ projectId: "project-b" })]);
|
||||
|
||||
const lookup = await request(app, "GET", `/api/chat/sessions?lookup=resume&agentId=${encodeURIComponent(AGENT_ID)}&projectId=${PROJECT_ID}`);
|
||||
const send = await request(app, "POST", `/api/chat/task-planner/${TASK_ID}/session?projectId=${PROJECT_ID}`, JSON.stringify({ modelProvider: "openai", modelId: "gpt-direct" }), { "content-type": "application/json" });
|
||||
|
||||
expect(lookup.status).toBe(200);
|
||||
expect(lookup.body.sessions).toEqual([]);
|
||||
expect(send.status).toBe(201);
|
||||
expect(updateSession).not.toHaveBeenCalled();
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -175,8 +175,11 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskChatDefaultModel 2026-08-19-12:12:
|
||||
Task-detail Chat keeps the synthetic task-scoped target (`task-planner:<taskId>`) for server-built context and scoped tools, while explicit sends update the persisted Direct Chat model and thinking target on that same session.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
|
||||
Task planner Chat uses a synthetic task-scoped chat target (`task-planner:<taskId>`) so the dashboard can persist/resume a conversation without binding it to an executor/reviewer agent or the Activity steering-comment pipeline. The route validates the task in the scoped project store and stores the effective planning model override on the session.
|
||||
Task planner Chat uses a synthetic task-scoped chat target (`task-planner:<taskId>`) so the dashboard can persist/resume a conversation without binding it to an executor/reviewer agent or the Activity steering-comment pipeline. The route validates the task in the scoped project store and stores the current Chat target on the session.
|
||||
|
||||
FNXC:TaskDetailPlannerChatRetention 2026-06-30-18:45:
|
||||
Planner chats that already have user interaction remain available when a task reaches done, and archived-task cleanup removes existing task-planner sessions through ChatStore deletion so archived tasks stop retaining task-local planner context.
|
||||
@@ -193,6 +196,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
}
|
||||
|
||||
const { modelProvider, modelId } = validateModelPair(req.body?.modelProvider, req.body?.modelId);
|
||||
const thinkingLevel = validateThinkingLevel(req.body?.thinkingLevel);
|
||||
const { store: scopedStore, projectId } = await getProjectContext(req);
|
||||
const { chatStore } = await resolveScopedChatStore(projectId);
|
||||
const task = await scopedStore.getTask(taskId).catch(() => null);
|
||||
@@ -201,50 +205,68 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
}
|
||||
|
||||
const agentId = `${TASK_PLANNER_CHAT_AGENT_ID_PREFIX}${task.id}`;
|
||||
let existing = await chatStore.findLatestActiveSessionForTarget({
|
||||
agentId,
|
||||
...(projectId ? { projectId } : {}),
|
||||
});
|
||||
|
||||
// FNXC:CentralProjectIdentity 2026-07-14-00:15:
|
||||
// ctx projectId now resolves to the launch id, so a projectId-filtered lookup
|
||||
// misses legacy active planner sessions created with a null projectId → we'd
|
||||
// create a duplicate. On a scoped miss, retry unscoped and reuse a matched
|
||||
// legacy (null-projectId) session for this task-specific agent. The projectId
|
||||
// is not stamped onto it: ChatSessionUpdateInput has no projectId field, so no
|
||||
// clean update path exists — reusing it is enough to prevent the duplicate.
|
||||
if (!existing && projectId) {
|
||||
const legacy = await chatStore.findLatestActiveSessionForTarget({ agentId });
|
||||
if (legacy && legacy.projectId == null) {
|
||||
existing = legacy;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const session = modelProvider && modelId
|
||||
? await chatStore.updateSession(existing.id, { modelProvider, modelId })
|
||||
: existing;
|
||||
res.json({ session });
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-06:50 (batch-core):
|
||||
Planner chat is refused for archived tasks. Keyed on the literal, a renamed board started
|
||||
planner sessions against archived cards, whose rows the archive treats as immutable.
|
||||
FNXC:TaskChatDefaultModel 2026-08-19-12:47:
|
||||
Explicit task-chat sends must serialize lookup, retarget, and first creation by task target.
|
||||
The task lifecycle advisory lock is cross-process in PostgreSQL and keeps two tabs from
|
||||
creating divergent transcripts while preserving the synthetic task context boundary.
|
||||
*/
|
||||
if ((await archivedColumnsForTask(scopedStore, task.id)).has(task.column)) {
|
||||
throw badRequest(`Task ${task.id} is archived; planner chat cannot be started for archived tasks`);
|
||||
}
|
||||
const result = await scopedStore.withPlanningLifecycleLock(task.id, async () => {
|
||||
let existing = await chatStore.findLatestActiveSessionForTarget({
|
||||
agentId,
|
||||
...(projectId ? { projectId } : {}),
|
||||
});
|
||||
|
||||
const session = await chatStore.createSession({
|
||||
agentId,
|
||||
title: `${task.id} planner chat`,
|
||||
projectId: projectId ?? null,
|
||||
modelProvider: modelProvider ?? null,
|
||||
modelId: modelId ?? null,
|
||||
// FNXC:CentralProjectIdentity 2026-07-14-00:15:
|
||||
// ctx projectId now resolves to the launch id, so a projectId-filtered lookup
|
||||
// misses legacy active planner sessions created with a null projectId → we'd
|
||||
// create a duplicate. On a scoped miss, retry unscoped and reuse a matched
|
||||
// legacy (null-projectId) session for this task-specific agent. The projectId
|
||||
// is not stamped onto it: ChatSessionUpdateInput has no projectId field, so no
|
||||
// clean update path exists — reusing it is enough to prevent the duplicate.
|
||||
if (!existing && projectId) {
|
||||
const legacy = await chatStore.findLatestActiveSessionForTarget({ agentId });
|
||||
if (legacy && legacy.projectId == null) {
|
||||
existing = legacy;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const updates = {
|
||||
...(modelProvider && modelId ? { modelProvider, modelId } : {}),
|
||||
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
|
||||
};
|
||||
const session = Object.keys(updates).length > 0
|
||||
? await chatStore.updateSession(existing.id, updates)
|
||||
: existing;
|
||||
return { created: false, session };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-06:50 (batch-core):
|
||||
Planner chat is refused for archived tasks. Keyed on the literal, a renamed board started
|
||||
planner sessions against archived cards, whose rows the archive treats as immutable.
|
||||
*/
|
||||
if ((await archivedColumnsForTask(scopedStore, task.id)).has(task.column)) {
|
||||
throw badRequest(`Task ${task.id} is archived; planner chat cannot be started for archived tasks`);
|
||||
}
|
||||
|
||||
const session = await chatStore.createSession({
|
||||
agentId,
|
||||
title: `${task.id} planner chat`,
|
||||
projectId: projectId ?? null,
|
||||
modelProvider: modelProvider ?? null,
|
||||
modelId: modelId ?? null,
|
||||
thinkingLevel: thinkingLevel ?? null,
|
||||
});
|
||||
return { created: true, session };
|
||||
});
|
||||
res.status(201).json({ session });
|
||||
if (result.created) {
|
||||
res.status(201).json({ session: result.session });
|
||||
} else {
|
||||
res.json({ session: result.session });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
@@ -345,6 +367,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
const isContentSearch = hasSearchQuery && !isTitleOnly;
|
||||
|
||||
const isResumeLookup = lookup === "resume";
|
||||
const isTaskPlannerResumeLookup = isResumeLookup
|
||||
&& typeof agentId === "string"
|
||||
&& agentId.trim().startsWith(TASK_PLANNER_CHAT_AGENT_ID_PREFIX);
|
||||
const hasModelProvider = typeof modelProvider === "string" && modelProvider.trim().length > 0;
|
||||
const hasModelId = typeof modelId === "string" && modelId.trim().length > 0;
|
||||
if (hasModelProvider !== hasModelId) {
|
||||
@@ -357,10 +382,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
|
||||
let sessions = isResumeLookup
|
||||
? await (async () => {
|
||||
const matched = await chatStore.findLatestActiveSessionForTarget({
|
||||
let matched = await chatStore.findLatestActiveSessionForTarget({
|
||||
agentId: agentId!.trim(),
|
||||
...(projectId && { projectId }),
|
||||
...(hasModelProvider && hasModelId
|
||||
...(!isTaskPlannerResumeLookup && hasModelProvider && hasModelId
|
||||
? {
|
||||
modelProvider: modelProvider!.trim(),
|
||||
modelId: modelId!.trim(),
|
||||
@@ -368,6 +393,15 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
: {}),
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TaskChatDefaultModel 2026-08-19-12:12:
|
||||
Synthetic task Chat lookup ignores the current Direct model and falls back to a legacy null-project session when needed. This preserves one transcript across settings changes without weakening project scoping for normal Chat sessions.
|
||||
*/
|
||||
if (!matched && isTaskPlannerResumeLookup && projectId) {
|
||||
const legacy = await chatStore.findLatestActiveSessionForTarget({ agentId: agentId!.trim() });
|
||||
if (legacy?.projectId == null) matched = legacy;
|
||||
}
|
||||
|
||||
return matched ? [matched] : [];
|
||||
})()
|
||||
: await chatStore.listSessions({
|
||||
|
||||
Reference in New Issue
Block a user