feat(web): collapse chat input toolbar items into overflow menu when narrow (#459)

This commit is contained in:
Carlos Florêncio
2026-03-25 00:33:28 +00:00
committed by GitHub
parent fb5886600a
commit edf4583b90
3 changed files with 378 additions and 65 deletions
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={isExpanded ? "Collapse toolbar" : "More toolbar actions"}
className="h-7 w-7 cursor-pointer hover:bg-muted/40"
data-testid="toolbar-overflow-menu"
onClick={onToggle}
>
{isExpanded ? <IconChevronsLeft className="h-4 w-4" /> : <IconDots className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{isExpanded ? "Collapse" : "More actions"}</TooltipContent>
</Tooltip>
);
}
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 (
<div className="flex items-center gap-0.5 shrink-0">
{showResetContext && <ResetContextButton sessionId={sessionId} />}
{!hideSessionsDropdown && (
<SessionsDropdown
taskId={taskId}
activeSessionId={sessionId}
taskTitle={taskTitle}
taskDescription={taskDescription}
/>
)}
{!showCollapsed && <CollapsibleItems items={rightItems} testIdPrefix="toolbar-item-" />}
<TokenUsageDisplay sessionId={sessionId} />
<ModelSelector sessionId={sessionId} />
{/* AuthMethodsIndicator hidden — UX needs rethinking */}
{showEnhance && (
<EnhancePromptButton
onClick={onEnhancePrompt ?? (() => {})}
isLoading={isEnhancingPrompt ?? false}
isConfigured={isUtilityConfigured}
/>
{planModeEnabled && !isAgentBusy && onImplementPlan && (
<ImplementPlanButton onClick={onImplementPlan} />
)}
{showImplement && <ImplementPlanButton onClick={onImplementPlan} />}
<div className="ml-1">
<SubmitButton
isAgentBusy={isAgentBusy}
@@ -392,6 +400,88 @@ function ToolbarRightSection({
);
}
function buildCollapsibleItems(props: {
mcpServers: string[];
sessionId: string | null;
taskId: string | null;
taskTitle?: string;
taskDescription: string;
hideSessionsDropdown?: boolean;
isAgentBusy: boolean;
onEnhancePrompt?: () => void;
isEnhancingPrompt: boolean;
isUtilityConfigured: boolean;
}): ToolbarItemConfig[] {
return [
{
id: "mcp",
section: "left",
collapsible: true,
render: () => <McpIndicator mcpServers={props.mcpServers} />,
},
{
id: "mode",
section: "left",
collapsible: true,
render: () => <ModeSelector sessionId={props.sessionId} />,
},
{
id: "reset-context",
section: "right",
collapsible: true,
visible: !!props.sessionId && !props.isAgentBusy,
render: () => <ResetContextButton sessionId={props.sessionId!} />,
},
{
id: "sessions",
section: "right",
collapsible: true,
visible: !props.hideSessionsDropdown,
render: () => (
<SessionsDropdown
taskId={props.taskId}
activeSessionId={props.sessionId}
taskTitle={props.taskTitle}
taskDescription={props.taskDescription}
/>
),
},
{
id: "model",
section: "right",
collapsible: true,
render: () => <ModelSelector sessionId={props.sessionId} />,
},
{
id: "enhance",
section: "right",
collapsible: true,
visible: !props.isAgentBusy,
render: () => (
<EnhancePromptButton
onClick={props.onEnhancePrompt ?? (() => {})}
isLoading={props.isEnhancingPrompt}
isConfigured={props.isUtilityConfigured}
/>
),
},
];
}
function CollapsibleItems({
items,
testIdPrefix,
}: {
items: ToolbarItemConfig[];
testIdPrefix: string;
}) {
return items.map((i) => (
<div key={i.id} data-testid={`${testIdPrefix}${i.id}`}>
{i.render()}
</div>
));
}
function AttachFilesButton({ onClick }: { onClick: () => void }) {
return (
<Tooltip>
@@ -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<HTMLDivElement>(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 (
<div className="flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border">
<div className="flex items-center gap-0.5">
<div
ref={toolbarRef}
data-testid="chat-input-toolbar"
className={cn(
"flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border",
isCollapsed ? "overflow-x-auto scrollbar-hide" : "overflow-visible",
)}
>
<div className="flex items-center gap-0.5 shrink-0">
{!props.hidePlanMode && (
<PlanToggleButton
planModeEnabled={props.planModeEnabled}
@@ -478,11 +583,8 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp
onPlanModeChange={props.onPlanModeChange}
/>
)}
<McpIndicator mcpServers={props.mcpServers} />
{!showCollapsed && <CollapsibleItems items={leftItems} testIdPrefix="toolbar-item-" />}
{props.onAttachFiles && <AttachFilesButton onClick={props.onAttachFiles} />}
<ContextPopover
open={props.contextPopoverOpen}
onOpenChange={props.onContextPopoverOpenChange ?? (() => {})}
@@ -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"
>
<IconAt className="h-4 w-4" />
{props.contextCount > 0 && (
<span className="absolute -top-1 -right-1 h-4 min-w-4 rounded-full bg-muted-foreground/80 text-[10px] text-background flex items-center justify-center px-0.5">
{props.contextCount > 0 && !isCollapsed && (
<span className="absolute -top-1 -right-1 h-4 min-w-4 rounded-full bg-muted-foreground/80 text-[10px] text-background flex items-center justify-center px-0.5 pointer-events-none">
{props.contextCount}
</span>
)}
@@ -506,27 +608,25 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp
contextFiles={props.contextFiles}
onToggleFile={props.onToggleFile ?? (() => {})}
/>
<ModeSelector sessionId={props.sessionId} />
{isCollapsed && (
<ToolbarExpandToggle isExpanded={isExpanded} onToggle={() => setIsExpanded((v) => !v)} />
)}
</div>
<div className="flex-1" />
<ToolbarRightSection
taskId={props.taskId}
showCollapsed={showCollapsed}
rightItems={rightItems}
sessionId={props.sessionId}
taskTitle={props.taskTitle}
taskDescription={props.taskDescription}
planModeEnabled={props.planModeEnabled}
isAgentBusy={props.isAgentBusy}
onImplementPlan={props.onImplementPlan}
isDisabled={props.isDisabled}
isSending={props.isSending}
onCancel={props.onCancel}
onSubmit={props.onSubmit}
submitShortcut={submitShortcut}
onEnhancePrompt={props.onEnhancePrompt}
isEnhancingPrompt={props.isEnhancingPrompt}
isUtilityConfigured={props.isUtilityConfigured}
onImplementPlan={props.onImplementPlan}
hideSessionsDropdown={props.hideSessionsDropdown}
/>
</div>
);
+146
View File
@@ -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<SessionPage> {
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();
});
});
+67
View File
@@ -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<HTMLDivElement | null>): 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;
}