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;
+}