FN-099: match task Chat model menu width to Direct Chat

Match the task Chat model selector to Direct Chat sizing so provider and model names remain readable across desktop and mobile.

- Apply the shared readable, viewport-clamped dropdown width to Task Chat.
- Add regression coverage and desktop/mobile screenshots.
- Document the behavior and publish a patch changeset.

Files changed:
 .changeset/fn-099-task-chat-model-menu-width.md    |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 .../app/components/TaskPlannerChatTab.tsx          |   5 ++
 .../ChatThinkingLevelControl.portal.test.tsx       |   3 +-
 .../__tests__/TaskPlannerChatTab.test.tsx           |  92 ++++++++++++++++++++-
 .../fn-099-task-chat-model-menu-desktop.png         | Bin 0 -> 9534 bytes
 screenshots/fn-099-task-chat-model-menu-mobile.png  | Bin 0 -> 7240 bytes
 7 files changed, 105 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-099

Fusion-Task-Lineage: fec67d58-9ca9-4878-8a0a-ac83984a3766

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-21 01:32:48 +00:00
parent 3903d3c383
commit b5e366da62
7 changed files with 105 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep task Chat model names readable by widening its selector menu to match Direct Chat.
category: fix
dev: Reuses the shared readable, viewport-clamped CustomModelDropdown width mode.

View File

@@ -248,7 +248,7 @@ On mobile viewports, the Right Dock never renders. The compact Header actions an
## 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. Task context always comes from the selected project’s authoritative store, including while that project’s engine has not started or is unavailable; another project’s task with the same ID cannot supply its context. 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.
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. Task context always comes from the selected project’s authoritative store, including while that project’s engine has not started or is unavailable; another project’s task with the same ID cannot supply its context. The composer exposes the same model and thinking controls as Direct Chat; its compact model trigger opens the same readable, viewport-clamped menu on desktop and mobile. 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

View File

@@ -1592,6 +1592,11 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
onChange={(value) => void handleTaskChatModelChange(value)}
placeholder={t("model.selectPlaceholder", "Select a model…")}
defaultOptionLabel={t("models.useDefault", "Use project default")}
/*
FNXC:TaskChatModelMenu 2026-08-21-01:12:
Task Chat keeps its compact composer trigger, but long provider/model names need Direct Chat's readable, viewport-clamped portaled menu on desktop and mobile.
*/
menuWidth="readable"
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
disabled={queueActionPending || composerState === "sending"}

View File

@@ -25,10 +25,11 @@ const openModelPortal = async () => {
};
describe("ChatThinkingLevelControl with the real CustomModelDropdown portal", () => {
it("keeps the brain popup open for pointerdown inside the portaled model menu, then selects the model normally", async () => {
it("keeps the brain popup open for pointerdown inside the readable portaled model menu, then selects the model normally", async () => {
const onChangeModel = vi.fn();
const portal = await openModelPortalWithRender({ onChangeModel });
expect(portal).toHaveAttribute("data-menu-width", "readable");
fireEvent.pointerDown(portal);
expect(screen.getByTestId("chat-thinking-popover")).toBeInTheDocument();

View File

@@ -13,7 +13,14 @@ const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockCancelChatResponse, mockAddSteeringComment, mockTranslations, mockT } = vi.hoisted(() => {
const mockModelCatalog = vi.hoisted(() => ({
models: [
{ provider: "anthropic", id: "claude-plan", name: "Claude Plan", reasoning: true, contextWindow: 200000 },
{ provider: "enterprise-provider", id: "very-long-production-model", name: "Enterprise Production Model With A Readable Long Name", reasoning: true, contextWindow: 200000 },
],
}));
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockUpdateChatSession, mockStreamChatResponse, mockAttachChatStream, mockCancelChatResponse, mockAddSteeringComment, mockTranslations, mockT } = vi.hoisted(() => {
const translations = new Map<string, string>();
return {
mockEnsureTaskPlannerChatSession: vi.fn(),
@@ -21,15 +28,28 @@ const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockF
mockFetchChatSession: vi.fn(),
mockFetchChatMessages: vi.fn(),
mockFetchTaskDetail: vi.fn(),
mockUpdateChatSession: vi.fn(),
mockStreamChatResponse: vi.fn(),
mockAttachChatStream: vi.fn(),
mockCancelChatResponse: vi.fn(),
mockAddSteeringComment: vi.fn(),
mockTranslations: translations,
mockT: (key: string, fallback: string) => translations.get(key) ?? fallback,
mockT: (key: string, fallback: string | { defaultValue?: string; defaultValue_one?: string; defaultValue_other?: string; count?: number }) => {
if (translations.has(key)) return translations.get(key)!;
if (typeof fallback === "string") return fallback;
return (fallback.count === 1 ? fallback.defaultValue_one : fallback.defaultValue_other) ?? fallback.defaultValue ?? key;
},
};
});
vi.mock("../../hooks/useModelsCache", () => ({
useModelsCache: () => ({
models: mockModelCatalog.models,
favoriteProviders: [],
favoriteModels: [],
}),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: mockT,
@@ -45,6 +65,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchChatSession: mockFetchChatSession,
fetchChatMessages: mockFetchChatMessages,
fetchTaskDetail: mockFetchTaskDetail,
updateChatSession: mockUpdateChatSession,
streamChatResponse: mockStreamChatResponse,
attachChatStream: mockAttachChatStream,
cancelChatResponse: mockCancelChatResponse,
@@ -179,10 +200,15 @@ describe("TaskPlannerChatTab", () => {
mockEnsureTaskPlannerChatSession.mockResolvedValue({ session: plannerSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockFetchTaskDetail.mockResolvedValue(makeTask("FN-7310"));
mockUpdateChatSession.mockResolvedValue({ session: makePlannerSession() });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockCancelChatResponse.mockResolvedValue({ success: true, interrupted: false });
mockAddSteeringComment.mockResolvedValue(makeTask("FN-7310"));
mockModelCatalog.models = [
{ provider: "anthropic", id: "claude-plan", name: "Claude Plan", reasoning: true, contextWindow: 200000 },
{ provider: "enterprise-provider", id: "very-long-production-model", name: "Enterprise Production Model With A Readable Long Name", reasoning: true, contextWindow: 200000 },
];
});
afterEach(() => {
@@ -248,6 +274,68 @@ describe("TaskPlannerChatTab", () => {
);
});
it("uses Direct Chat's readable portal for compact task-chat triggers and keeps long models searchable", async () => {
const user = userEvent.setup();
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
vi.spyOn(window, "innerWidth", "get").mockReturnValue(1000);
Element.prototype.getBoundingClientRect = vi.fn(() => ({
top: 100, left: 50, bottom: 136, width: 200, height: 36, right: 250, x: 50, y: 100, toJSON: () => ({}),
} as DOMRect));
try {
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.click(screen.getByRole("button", { name: "Chat model" }));
const portal = await screen.findByTestId("model-combobox-portal");
expect(portal).toHaveAttribute("data-menu-width", "readable");
expect(Number.parseFloat(portal.style.width)).toBeGreaterThan(200);
await user.type(within(portal).getByPlaceholderText("Filter models…"), "readable long");
expect(within(portal).getByText("Enterprise Production Model With A Readable Long Name")).toBeInTheDocument();
await user.click(within(portal).getByText("Enterprise Production Model With A Readable Long Name"));
await waitFor(() => expect(mockUpdateChatSession).toHaveBeenCalledWith(
"chat-planner",
expect.objectContaining({ modelProvider: "enterprise-provider", modelId: "very-long-production-model" }),
undefined,
));
} finally {
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
}
});
it("keeps readable task-chat menus viewport-clamped for undefined selections and duplicate mobile catalogues", async () => {
const user = userEvent.setup();
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
const originalVisualViewport = window.visualViewport;
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: { width: 320, height: 640, offsetTop: 0, offsetLeft: 20, addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
mockModelCatalog.models = [
{ provider: "anthropic", id: "claude-plan", name: "Claude Plan", reasoning: true, contextWindow: 200000 },
{ provider: "anthropic", id: "claude-plan-copy", name: "Claude Plan", reasoning: true, contextWindow: 200000 },
];
Element.prototype.getBoundingClientRect = vi.fn(() => ({
top: 100, left: 250, bottom: 136, width: 160, height: 36, right: 410, x: 250, y: 100, toJSON: () => ({}),
} as DOMRect));
try {
renderPlannerChat({ taskChatModel: {} });
await screen.findByTestId("task-planner-chat-empty");
await user.click(screen.getByRole("button", { name: "Chat model" }));
const portal = await screen.findByTestId("model-combobox-portal");
const left = Number.parseFloat(portal.style.left);
const width = Number.parseFloat(portal.style.width);
expect(portal).toHaveAttribute("data-menu-width", "readable");
expect(left - 20).toBeGreaterThanOrEqual(16);
expect(left - 20 + width).toBeLessThanOrEqual(320 - 16);
} finally {
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
Object.defineProperty(window, "visualViewport", { configurable: true, value: originalVisualViewport });
}
});
/*
FNXC:ChatStreaming 2026-08-19-13:52:
Task-detail Planner Chat must use the same shared Markdown anchor contract for both loaded history and an in-flight reattached response; this catches a renderer fork that would regress only task-bound Chat.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB