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:
7
.changeset/fn-099-task-chat-model-menu-width.md
Normal file
7
.changeset/fn-099-task-chat-model-menu-width.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
BIN
screenshots/fn-099-task-chat-model-menu-desktop.png
Normal file
BIN
screenshots/fn-099-task-chat-model-menu-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
BIN
screenshots/fn-099-task-chat-model-menu-mobile.png
Normal file
BIN
screenshots/fn-099-task-chat-model-menu-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Reference in New Issue
Block a user