feat(web): collapse chat input toolbar items into overflow menu when narrow (#459)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user