diff --git a/.changeset/fn-099-task-chat-model-menu-width.md b/.changeset/fn-099-task-chat-model-menu-width.md new file mode 100644 index 0000000000..d96184799d --- /dev/null +++ b/.changeset/fn-099-task-chat-model-menu-width.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index b8a5d199a6..4052c61453 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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:` 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:` 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 diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index 088cea1149..bb3d504037 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -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"} diff --git a/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx b/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx index 9230a6e15c..091b01898c 100644 --- a/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx @@ -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(); diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index 9692c56b01..405428d854 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -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(); 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. diff --git a/screenshots/fn-099-task-chat-model-menu-desktop.png b/screenshots/fn-099-task-chat-model-menu-desktop.png new file mode 100644 index 0000000000..490bed5361 Binary files /dev/null and b/screenshots/fn-099-task-chat-model-menu-desktop.png differ diff --git a/screenshots/fn-099-task-chat-model-menu-mobile.png b/screenshots/fn-099-task-chat-model-menu-mobile.png new file mode 100644 index 0000000000..95cdfae257 Binary files /dev/null and b/screenshots/fn-099-task-chat-model-menu-mobile.png differ