From edf4583b9073a44d39ca54baade9b878a30d6b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 25 Mar 2026 00:33:28 +0000 Subject: [PATCH] feat(web): collapse chat input toolbar items into overflow menu when narrow (#459) --- .../task/chat/chat-input-toolbar.tsx | 230 +++++++++++++----- apps/web/e2e/tests/toolbar-overflow.spec.ts | 146 +++++++++++ apps/web/hooks/use-toolbar-collapsed.ts | 67 +++++ 3 files changed, 378 insertions(+), 65 deletions(-) create mode 100644 apps/web/e2e/tests/toolbar-overflow.spec.ts create mode 100644 apps/web/hooks/use-toolbar-collapsed.ts diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index b5e19f11c6..8d3174bc8f 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -1,8 +1,10 @@ "use client"; -import { memo, useState, useCallback } from "react"; +import { memo, useRef, useState, useCallback, type ReactNode } from "react"; import { IconArrowUp, + IconChevronsLeft, + IconDots, IconFileTextSpark, IconPlayerPauseFilled, IconAt, @@ -27,6 +29,7 @@ import { AlertDialogTitle, } from "@kandev/ui/alert-dialog"; import { cn } from "@/lib/utils"; +import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed"; import { getWebSocketClient } from "@/lib/ws/connection"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip"; @@ -315,68 +318,73 @@ function ResetContextButton({ sessionId }: { sessionId: string }) { ); } -type ToolbarRightSectionProps = { - taskId: string | null; - sessionId: string | null; - taskTitle?: string; - taskDescription: string; - planModeEnabled: boolean; - isAgentBusy: boolean; - isDisabled: boolean; - isSending: boolean; - onCancel: () => void; - onSubmit: () => void; - submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; - onEnhancePrompt?: () => void; - isEnhancingPrompt?: boolean; - isUtilityConfigured?: boolean; - onImplementPlan?: () => void; - hideSessionsDropdown?: boolean; +type ToolbarItemConfig = { + id: string; + collapsible: boolean; + section: "left" | "right"; + render: () => ReactNode; + visible?: boolean; }; +function ToolbarExpandToggle({ + isExpanded, + onToggle, +}: { + isExpanded: boolean; + onToggle: () => void; +}) { + return ( + + + + + {isExpanded ? "Collapse" : "More actions"} + + ); +} + function ToolbarRightSection({ - taskId, + showCollapsed, + rightItems, sessionId, - taskTitle, - taskDescription, planModeEnabled, isAgentBusy, + onImplementPlan, isDisabled, isSending, onCancel, onSubmit, submitShortcut, - onEnhancePrompt, - isEnhancingPrompt, - isUtilityConfigured, - onImplementPlan, - hideSessionsDropdown, -}: ToolbarRightSectionProps) { - const showEnhance = !isAgentBusy; - const showImplement = planModeEnabled && !isAgentBusy && onImplementPlan; - const showResetContext = !!sessionId && !isAgentBusy; +}: { + showCollapsed: boolean; + rightItems: ToolbarItemConfig[]; + sessionId: string | null; + planModeEnabled: boolean; + isAgentBusy: boolean; + onImplementPlan?: () => void; + isDisabled: boolean; + isSending: boolean; + onCancel: () => void; + onSubmit: () => void; + submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; +}) { return (
- {showResetContext && } - {!hideSessionsDropdown && ( - - )} + {!showCollapsed && } - - {/* AuthMethodsIndicator hidden — UX needs rethinking */} - {showEnhance && ( - {})} - isLoading={isEnhancingPrompt ?? false} - isConfigured={isUtilityConfigured} - /> + {planModeEnabled && !isAgentBusy && onImplementPlan && ( + )} - {showImplement && }
void; + isEnhancingPrompt: boolean; + isUtilityConfigured: boolean; +}): ToolbarItemConfig[] { + return [ + { + id: "mcp", + section: "left", + collapsible: true, + render: () => , + }, + { + id: "mode", + section: "left", + collapsible: true, + render: () => , + }, + { + id: "reset-context", + section: "right", + collapsible: true, + visible: !!props.sessionId && !props.isAgentBusy, + render: () => , + }, + { + id: "sessions", + section: "right", + collapsible: true, + visible: !props.hideSessionsDropdown, + render: () => ( + + ), + }, + { + id: "model", + section: "right", + collapsible: true, + render: () => , + }, + { + id: "enhance", + section: "right", + collapsible: true, + visible: !props.isAgentBusy, + render: () => ( + {})} + isLoading={props.isEnhancingPrompt} + isConfigured={props.isUtilityConfigured} + /> + ), + }, + ]; +} + +function CollapsibleItems({ + items, + testIdPrefix, +}: { + items: ToolbarItemConfig[]; + testIdPrefix: string; +}) { + return items.map((i) => ( +
+ {i.render()} +
+ )); +} + function AttachFilesButton({ onClick }: { onClick: () => void }) { return ( @@ -454,6 +544,10 @@ const toolbarDefaults = { export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInputToolbarProps) { const props = { ...toolbarDefaults, ...rawProps }; const submitShortcut = props.submitKey === "enter" ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT; + const toolbarRef = useRef(null); + const isCollapsed = useToolbarCollapsed(toolbarRef); + const [isExpanded, setIsExpanded] = useState(false); + const showCollapsed = isCollapsed && !isExpanded; if (props.minimalToolbar) { return ( @@ -468,9 +562,20 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp ); } + const items = buildCollapsibleItems(props); + const leftItems = items.filter((i) => i.section === "left" && i.visible !== false); + const rightItems = items.filter((i) => i.section === "right" && i.visible !== false); + return ( -
-
+
+
{!props.hidePlanMode && ( )} - - - + {!showCollapsed && } {props.onAttachFiles && } - {})} @@ -494,8 +596,8 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 relative" > - {props.contextCount > 0 && ( - + {props.contextCount > 0 && !isCollapsed && ( + {props.contextCount} )} @@ -506,27 +608,25 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp contextFiles={props.contextFiles} onToggleFile={props.onToggleFile ?? (() => {})} /> - + {isCollapsed && ( + setIsExpanded((v) => !v)} /> + )}
+
); diff --git a/apps/web/e2e/tests/toolbar-overflow.spec.ts b/apps/web/e2e/tests/toolbar-overflow.spec.ts new file mode 100644 index 0000000000..4656266d0d --- /dev/null +++ b/apps/web/e2e/tests/toolbar-overflow.spec.ts @@ -0,0 +1,146 @@ +import { type Page } from "@playwright/test"; +import { test, expect } from "../fixtures/test-base"; +import type { SeedData } from "../fixtures/test-base"; +import type { ApiClient } from "../helpers/api-client"; +import { SessionPage } from "../pages/session-page"; + +/** + * Seed a task + session and navigate to the session page. + * Waits for the mock agent to finish (idle input visible). + */ +async function seedAndNavigate( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, +): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Toolbar Overflow Test", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + + await testPage.goto(`/s/${task.session_id}`); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + return session; +} + +/** Force the toolbar container to a specific max-width via inline style. */ +async function constrainToolbar(page: Page, maxWidth: string | null) { + await page.evaluate((mw) => { + const el = document.querySelector('[data-testid="chat-input-toolbar"]') as HTMLElement; + if (!el) return; + if (mw) { + el.style.maxWidth = mw; + } else { + el.style.removeProperty("max-width"); + } + }, maxWidth); + // Allow ResizeObserver to fire + await page.waitForTimeout(200); +} + +test.describe("Toolbar overflow menu", () => { + test.describe.configure({ retries: 1 }); + + test("collapses toolbar items and expands inline when toggled", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedAndNavigate(testPage, apiClient, seedData); + + const toolbar = testPage.getByTestId("chat-input-toolbar"); + await expect(toolbar).toBeVisible({ timeout: 10_000 }); + + // At default width, collapsible items should be visible inline + const modelItem = testPage.getByTestId("toolbar-item-model"); + const mcpItem = testPage.getByTestId("toolbar-item-mcp"); + const overflowBtn = testPage.getByTestId("toolbar-overflow-menu"); + + await expect(modelItem).toBeVisible({ timeout: 5_000 }); + await expect(mcpItem).toBeVisible({ timeout: 5_000 }); + await expect(overflowBtn).not.toBeVisible(); + + // Check context badge visibility: add a context item via the popover + const contextBtn = toolbar.locator("button", { has: testPage.locator("svg.tabler-icon-at") }); + await contextBtn.click(); + const searchInput = testPage.getByPlaceholder("Search files and prompts..."); + await expect(searchInput).toBeVisible({ timeout: 5_000 }); + // Toggle the Plan context checkbox to get contextCount > 0 + const planCheckbox = testPage.getByText("Plan", { exact: true }); + await planCheckbox.click(); + // Close the popover + await testPage.locator("body").click({ position: { x: 10, y: 10 } }); + // Badge should be visible at full width + const contextBadge = contextBtn.locator("span.rounded-full"); + await expect(contextBadge).toBeVisible({ timeout: 3_000 }); + + // Constrain toolbar to a narrow width to force overflow + await constrainToolbar(testPage, "300px"); + + // Collapsible items should disappear and expand toggle should appear + await expect(overflowBtn).toBeVisible({ timeout: 5_000 }); + await expect(modelItem).not.toBeVisible(); + + // Context badge should be hidden when collapsed to avoid clipping + await expect(contextBadge).not.toBeVisible(); + + // Submit button should remain visible (always-visible item) + const submitBtn = toolbar.locator("button.rounded-full"); + await expect(submitBtn).toBeVisible(); + + // Click expand toggle — items appear inline (scrollable) + await overflowBtn.click(); + await expect(modelItem).toBeVisible({ timeout: 5_000 }); + await expect(mcpItem).toBeVisible({ timeout: 5_000 }); + + // Collapse button should still be visible to toggle back + await expect(overflowBtn).toBeVisible(); + + // Click again to collapse back + await overflowBtn.click(); + await expect(modelItem).not.toBeVisible({ timeout: 5_000 }); + + // Remove constraint — items should reappear inline normally + await constrainToolbar(testPage, null); + + await expect(modelItem).toBeVisible({ timeout: 5_000 }); + await expect(mcpItem).toBeVisible({ timeout: 5_000 }); + await expect(overflowBtn).not.toBeVisible(); + }); + + test("context popover opens when @ button is clicked", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedAndNavigate(testPage, apiClient, seedData); + + const toolbar = testPage.getByTestId("chat-input-toolbar"); + await expect(toolbar).toBeVisible({ timeout: 10_000 }); + + // Find the @ context button and click it + const contextBtn = toolbar.locator("button", { has: testPage.locator("svg.tabler-icon-at") }); + await expect(contextBtn).toBeVisible({ timeout: 5_000 }); + await contextBtn.click(); + + // The context popover should open with "Context" header and search input + const popoverContent = testPage.getByText("Select files and prompts to include"); + await expect(popoverContent).toBeVisible({ timeout: 5_000 }); + + const searchInput = testPage.getByPlaceholder("Search files and prompts..."); + await expect(searchInput).toBeVisible(); + }); +}); diff --git a/apps/web/hooks/use-toolbar-collapsed.ts b/apps/web/hooks/use-toolbar-collapsed.ts new file mode 100644 index 0000000000..0ed9e9c595 --- /dev/null +++ b/apps/web/hooks/use-toolbar-collapsed.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useRef, useState, type RefObject } from "react"; + +/** + * Measures the total width of a container's direct children and compares it + * to the container's available width. Returns `true` when children overflow. + * + * Uses a cached "full width" (measured when expanded) to prevent toggle loops + * at the collapse/expand boundary. Observes both container resizes and DOM + * mutations (child additions/removals) to stay in sync when toolbar items + * appear or disappear. + */ +export function useToolbarCollapsed(containerRef: RefObject): boolean { + const [isCollapsed, setIsCollapsed] = useState(false); + const collapsedRef = useRef(false); + const fullWidthRef = useRef(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const childrenWidth = () => { + let total = 0; + for (const child of el.children) { + total += (child as HTMLElement).offsetWidth; + } + // Account for gaps between children + const style = getComputedStyle(el); + const gap = parseFloat(style.columnGap || "0"); + const childCount = el.children.length; + if (childCount > 1) total += gap * (childCount - 1); + return total; + }; + + const check = () => { + if (!collapsedRef.current) { + const contentWidth = childrenWidth(); + fullWidthRef.current = contentWidth; + if (contentWidth > el.clientWidth + 1) { + collapsedRef.current = true; + setIsCollapsed(true); + } + } else { + if (el.clientWidth >= fullWidthRef.current) { + collapsedRef.current = false; + setIsCollapsed(false); + } + } + }; + + const resizeObserver = new ResizeObserver(check); + resizeObserver.observe(el); + + // Also observe DOM mutations so we remeasure when children change + // (e.g. isAgentBusy flips and items appear/disappear). + const mutationObserver = new MutationObserver(check); + mutationObserver.observe(el, { childList: true, subtree: true }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [containerRef]); + + return isCollapsed; +}