fix: improve mobile chat input controls (#1477)

This commit is contained in:
Carlos Florêncio
2026-06-27 16:11:39 +01:00
committed by GitHub
parent 211e738ec0
commit 7728ddb3bc
37 changed files with 1969 additions and 575 deletions
@@ -9,9 +9,23 @@ import { PR_CI_DESKTOP_POPOVER_SCROLL_CLASS } from "./pr-ci-popover";
import type { AppState } from "@/lib/state/store";
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
const isMobileMock = vi.fn(() => false);
vi.mock("@/hooks/use-mobile", () => ({
useIsMobile: () => isMobileMock(),
const responsiveMock = vi.hoisted(() => ({
breakpoint: "desktop" as "mobile" | "tablet" | "compactDesktop" | "desktop",
isFinePointer: true,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({
breakpoint: responsiveMock.breakpoint,
isMobile: responsiveMock.breakpoint === "mobile",
isTablet: responsiveMock.breakpoint === "tablet",
isDesktop:
responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop",
isCompactDesktop: responsiveMock.breakpoint === "compactDesktop",
isFullDesktop: responsiveMock.breakpoint === "desktop",
isFinePointer: responsiveMock.isFinePointer,
usesDesktopWorkbench:
responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop",
}),
}));
vi.mock("@/lib/api/domains/github-api", async (importOriginal) => {
@@ -95,12 +109,12 @@ function makeCIOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCI
}
beforeEach(() => {
isMobileMock.mockReturnValue(false);
responsiveMock.breakpoint = "desktop";
responsiveMock.isFinePointer = true;
});
afterEach(() => {
cleanup();
isMobileMock.mockReset();
});
const CHIP_TESTID = "pr-status-chip";
@@ -156,7 +170,10 @@ describe("PRStatusChip", () => {
});
describe("PRStatusChip desktop branch", () => {
beforeEach(() => isMobileMock.mockReturnValue(false));
beforeEach(() => {
responsiveMock.breakpoint = "desktop";
responsiveMock.isFinePointer = true;
});
it("renders the chip button without a Drawer", () => {
renderWithStore(seededState, <PRStatusChip taskId="task-1" />);
@@ -170,6 +187,18 @@ describe("PRStatusChip desktop branch", () => {
expect(document.querySelector(DRAWER_SELECTOR)).toBeNull();
});
it("keeps the hovercard path on fine-pointer tablets", () => {
responsiveMock.breakpoint = "tablet";
responsiveMock.isFinePointer = true;
renderWithStore(seededState, <PRStatusChip taskId="task-1" />);
const chip = screen.getByTestId(CHIP_TESTID);
act(() => {
fireEvent.click(chip);
});
expect(document.querySelector(DRAWER_SELECTOR)).toBeNull();
});
it("constrains the hover popover to the available viewport height", async () => {
renderWithStore(seededState, <PRStatusChip taskId="task-1" />);
await expectDesktopHoverPopoverConstrained();
@@ -208,7 +237,10 @@ describe("PRStatusChip desktop branch", () => {
});
describe("PRStatusChip mobile branch", () => {
beforeEach(() => isMobileMock.mockReturnValue(true));
beforeEach(() => {
responsiveMock.breakpoint = "mobile";
responsiveMock.isFinePointer = false;
});
it("renders the chip closed and opens the drawer on click", () => {
renderWithStore(seededState, <PRStatusChip taskId="task-1" />);
@@ -298,6 +330,26 @@ describe("PRStatusChip mobile branch", () => {
});
});
describe("PRStatusChip touch tablet branch", () => {
beforeEach(() => {
responsiveMock.breakpoint = "tablet";
responsiveMock.isFinePointer = false;
});
it("opens the drawer instead of the hovercard on coarse-pointer tablets", () => {
renderWithStore(seededState, <PRStatusChip taskId="task-1" />);
expect(document.querySelector(DRAWER_SELECTOR)).toBeNull();
act(() => {
fireEvent.click(screen.getByTestId(CHIP_TESTID));
});
expect(document.querySelector(DRAWER_SELECTOR)).not.toBeNull();
expect(document.querySelector("[data-testid='pr-topbar-popover-inner']")).not.toBeNull();
expect(screen.getByTestId("pr-popover-title").textContent).toBe("#42 Test PR");
});
});
describe("PRStatusChip — mergeability", () => {
it("is 'conflict' (not 'passed') for a dirty PR even with green checks + approval", () => {
// Regression: the chip read mergeable_state-blind and showed the green
@@ -379,7 +431,10 @@ describe("PRStatusChip — mergeability", () => {
});
describe("PRStatusChip CI automation mobile parity", () => {
beforeEach(() => isMobileMock.mockReturnValue(true));
beforeEach(() => {
responsiveMock.breakpoint = "mobile";
responsiveMock.isFinePointer = false;
});
it("renders controls and prompt editing inside the drawer", async () => {
renderWithStore(seededState, <PRStatusChip taskId="task-1" />);
@@ -477,7 +532,10 @@ describe("PRStatusChip — multiple PRs", () => {
});
describe("mobile drawer", () => {
beforeEach(() => isMobileMock.mockReturnValue(true));
beforeEach(() => {
responsiveMock.breakpoint = "mobile";
responsiveMock.isFinePointer = false;
});
it("opens a drawer with the tabbed multi-PR popover", () => {
renderWithStore(multiState(TWO_OPEN), <PRStatusChip taskId="task-1" />);
@@ -34,7 +34,7 @@ import {
isPRWaitingOnBranchProtection,
pickDefaultPR,
} from "@/components/github/pr-task-icon";
import { useIsMobile } from "@/hooks/use-mobile";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import type { TaskPR } from "@/lib/types/github";
const HOVER_OPEN_DELAY_MS = 150;
@@ -248,8 +248,8 @@ function AutomationFlagBadges({ automation }: { automation: AutomationFlags }) {
}
function PRStatusChipInner({ pr, automation }: { pr: TaskPR; automation: AutomationFlags }) {
const isMobile = useIsMobile();
if (isMobile) return <PRStatusChipDrawer pr={pr} automation={automation} />;
const usesMobileDrawer = useTouchDrawer();
if (usesMobileDrawer) return <PRStatusChipDrawer pr={pr} automation={automation} />;
return <PRStatusChipHoverCard pr={pr} automation={automation} />;
}
@@ -305,8 +305,8 @@ function PRStatusChipMultiInner({
prs: TaskPR[];
automation: AutomationFlags;
}) {
const isMobile = useIsMobile();
if (isMobile) return <PRStatusChipMultiDrawer prs={prs} automation={automation} />;
const usesMobileDrawer = useTouchDrawer();
if (usesMobileDrawer) return <PRStatusChipMultiDrawer prs={prs} automation={automation} />;
return <PRStatusChipMultiHoverCard prs={prs} automation={automation} />;
}
@@ -23,7 +23,7 @@ import {
import { useDockviewStore } from "@/lib/state/dockview-store";
import { useTaskPR } from "@/hooks/domains/github/use-task-pr";
import { useHoverPopover } from "@/hooks/domains/github/use-hover-popover";
import { useIsMobile } from "@/hooks/use-mobile";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import {
aggregatePRStatusColor,
getPRStatusColor,
@@ -113,13 +113,13 @@ export const PRTopbarButton = memo(function PRTopbarButton() {
* {@link useHoverPopover} hook so the chip and this button stay in sync.
*/
function usePopoverInteractions() {
const isMobile = useIsMobile();
const usesTouchDrawer = useTouchDrawer();
const hover = useHoverPopover({
openDelayMs: POPOVER_OPEN_DELAY_MS,
closeDelayMs: POPOVER_CLOSE_DELAY_MS,
disabled: isMobile,
disabled: usesTouchDrawer,
});
return { isMobile, ...hover };
return { usesTouchDrawer, ...hover };
}
function PRSingleButton({ pr }: { pr: TaskPR }) {
@@ -127,7 +127,7 @@ function PRSingleButton({ pr }: { pr: TaskPR }) {
const activeSessionId = useAppStore((s) => s.tasks.activeSessionId);
const tooltip = `${pr.owner}/${pr.repo} #${pr.pr_number} — ${pr.pr_title}`;
const {
isMobile,
usesTouchDrawer,
open,
onOpenChange,
onTriggerEnter,
@@ -169,7 +169,7 @@ function PRSingleButton({ pr }: { pr: TaskPR }) {
</Button>
);
if (isMobile) {
if (usesTouchDrawer) {
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
@@ -205,7 +205,7 @@ function PRMultiButton({ prs }: { prs: TaskPR[] }) {
const addPRPanel = useDockviewStore((s) => s.addPRPanel);
const activeSessionId = useAppStore((s) => s.tasks.activeSessionId);
const {
isMobile,
usesTouchDrawer,
open,
onOpenChange,
onTriggerEnter,
@@ -257,7 +257,7 @@ function PRMultiButton({ prs }: { prs: TaskPR[] }) {
>
<Tooltip>
<TooltipTrigger asChild>
{isMobile ? triggerButton : <PopoverAnchor asChild>{triggerButton}</PopoverAnchor>}
{usesTouchDrawer ? triggerButton : <PopoverAnchor asChild>{triggerButton}</PopoverAnchor>}
</TooltipTrigger>
<TooltipContent>{prs.length} pull requests linked to this task — open one</TooltipContent>
</Tooltip>
@@ -265,7 +265,7 @@ function PRMultiButton({ prs }: { prs: TaskPR[] }) {
</DropdownMenu>
);
if (isMobile) return dropdown;
if (usesTouchDrawer) return dropdown;
return (
<Popover open={open} onOpenChange={onOpenChange}>
@@ -0,0 +1,69 @@
import { cleanup, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { KanbanHeaderMobile } from "./kanban-header-mobile";
vi.mock("@/components/page-topbar", () => ({
PageTopbar: ({
backLabel,
leftActions,
actions,
}: {
backLabel?: string;
leftActions?: ReactNode;
actions?: ReactNode;
}) => (
<header>
<span>{backLabel}</span>
<div data-testid="topbar-left-actions">{leftActions}</div>
<div>{actions}</div>
</header>
),
}));
vi.mock("@/components/system-metrics/topbar-metrics", () => ({
TopbarMetrics: () => <div data-testid="topbar-metrics" />,
}));
vi.mock("./mobile-menu-sheet", () => ({
MobileMenuSheet: () => null,
}));
const LEFT_ACTIONS_TEST_ID = "topbar-left-actions";
afterEach(() => {
cleanup();
});
function renderHeader(title: string) {
return render(
<StateProvider>
<KanbanHeaderMobile
title={title}
workspaceLabel="/root/kandev"
showHealthIndicator={false}
onOpenHealthDialog={() => undefined}
/>
</StateProvider>,
);
}
describe("KanbanHeaderMobile", () => {
it("renders the Home title in compact root chrome", () => {
renderHeader("Home");
expect(screen.getByText("Kandev")).toBeTruthy();
expect(screen.getByTestId(LEFT_ACTIONS_TEST_ID).textContent).toContain("Home");
expect(screen.getByTestId(LEFT_ACTIONS_TEST_ID).textContent).not.toContain("/root/kandev");
});
it("renders page title and workspace label for non-Home pages", () => {
renderHeader("Tasks");
const leftActions = screen.getByTestId(LEFT_ACTIONS_TEST_ID);
expect(leftActions.textContent).toContain("Tasks");
expect(leftActions.textContent).toContain("/root/kandev");
expect(leftActions.firstElementChild?.className).toContain("max-w-[38vw]");
});
});
@@ -3,6 +3,7 @@
import { Button } from "@kandev/ui/button";
import { IconMenu2, IconSearch } from "@tabler/icons-react";
import { PageTopbar } from "@/components/page-topbar";
import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics";
import { MobileMenuSheet } from "./mobile-menu-sheet";
import { useAppStore } from "@/components/state-provider";
@@ -33,6 +34,7 @@ export function KanbanHeaderMobile({
const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen);
const isSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen);
const setSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen);
const isHome = title === "Home";
const toggleSearch = () => {
const next = !isSearchOpen;
@@ -43,14 +45,26 @@ export function KanbanHeaderMobile({
return (
<>
{/* Keep mobile root chrome compact so metrics and actions stay visible. */}
<PageTopbar
title={title}
subtitle={workspaceLabel}
backLabel={title === "Home" ? "" : "Kandev"}
backLabel="Kandev"
className="h-10 px-3 py-1"
variant={title === "Home" ? "root" : "breadcrumb"}
variant="root"
leftActions={
<span className="flex min-w-0 max-w-[38vw] flex-col leading-tight">
<span className="truncate text-sm font-medium text-muted-foreground">{title}</span>
{!isHome && (
<span className="truncate text-[10px] text-muted-foreground/60">
{workspaceLabel}
</span>
)}
</span>
}
actionsClassName="gap-2"
actions={
<>
<TopbarMetrics />
{onSearchChange && (
<Button
variant={isSearchOpen ? "secondary" : "outline"}
@@ -0,0 +1,25 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { ModelConfigSelector } from "@/components/model-config-selector";
afterEach(() => {
cleanup();
});
describe("ModelConfigSelector", () => {
it("passes custom trigger classes to the button", () => {
render(
<ModelConfigSelector
modelOptions={[{ id: "gpt-5.5", name: "GPT-5.5" }]}
currentModel="gpt-5.5"
onModelChange={() => {}}
triggerClassName="max-w-[56vw]"
/>,
);
expect(screen.getByRole("button", { name: "Model settings" }).className).toContain(
"max-w-[56vw]",
);
});
});
@@ -189,6 +189,7 @@ export type ModelConfigSelectorProps = {
ariaLabel?: string;
variant?: "compact" | "field";
popoverSide?: "top" | "bottom";
triggerClassName?: string;
};
export const ModelConfigSelector = memo(function ModelConfigSelector({
@@ -202,6 +203,7 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
ariaLabel = "Model settings",
variant = "field",
popoverSide = "bottom",
triggerClassName: customTriggerClassName,
}: ModelConfigSelectorProps) {
const [open, setOpen] = useState(false);
const selectConfigOptions = usableConfigOptions(configOptions);
@@ -219,7 +221,7 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
}
};
const triggerClassName =
const baseTriggerClassName =
variant === "compact"
? "h-7 max-w-[min(18rem,70vw)] cursor-pointer gap-1 px-2 text-xs hover:bg-muted/40"
: "w-full justify-between font-normal cursor-pointer";
@@ -231,7 +233,7 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
type="button"
variant={variant === "compact" ? "ghost" : "outline"}
size={variant === "compact" ? "sm" : "default"}
className={triggerClassName}
className={cn(baseTriggerClassName, customTriggerClassName)}
aria-label={ariaLabel}
disabled={disabled}
>
@@ -130,9 +130,9 @@ function MetricsDisplayToggle({
return (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="show-system-metrics">Show in desktop topbars</Label>
<Label htmlFor="show-system-metrics">Show in topbars</Label>
<p className="text-xs text-muted-foreground">
Collection starts only while at least one desktop or tablet client displays metrics.
Collection starts only while at least one client displays metrics.
</p>
</div>
<Switch id="show-system-metrics" checked={checked} onCheckedChange={onCheckedChange} />
@@ -0,0 +1,144 @@
import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice";
import { defaultSystemState } from "@/lib/state/slices/system/system-slice";
import type { AppState } from "@/lib/state/store";
import { TopbarMetrics } from "./topbar-metrics";
const responsiveState = vi.hoisted(() => ({ isMobile: true }));
const subscribeMock = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({
breakpoint: responsiveState.isMobile ? "mobile" : "desktop",
isMobile: responsiveState.isMobile,
isTablet: false,
isDesktop: !responsiveState.isMobile,
isCompactDesktop: false,
isFullDesktop: !responsiveState.isMobile,
isFinePointer: !responsiveState.isMobile,
usesDesktopWorkbench: !responsiveState.isMobile,
}),
}));
vi.mock("@/hooks/use-system-metrics-subscription", () => ({
useSystemMetricsSubscription: subscribeMock,
}));
function renderMetrics(initialState: Partial<AppState>, activeSessionId?: string | null) {
return render(
<StateProvider initialState={initialState}>
<TooltipProvider>
<TopbarMetrics activeSessionId={activeSessionId} />
</TooltipProvider>
</StateProvider>,
);
}
function initialState(showInTopbar: boolean): Partial<AppState> {
return {
userSettings: {
...defaultSettingsState.userSettings,
loaded: true,
systemMetricsDisplay: { showInTopbar },
},
system: {
...defaultSystemState.system,
metrics: {
timestamp: "2026-06-23T10:00:00Z",
interval_seconds: 5,
sources: [
{
id: "backend",
label: "Host",
kind: "backend",
metrics: [
{
id: "cpu_percent",
label: "CPU",
unit: "%",
value: 42,
available: true,
},
{
id: "memory_percent",
label: "Memory",
unit: "%",
value: 68,
available: true,
},
{
id: "disk_percent",
label: "Disk",
unit: "%",
value: 12,
available: true,
},
],
},
{
id: "execution-session-1",
label: "Executor",
kind: "execution",
session_id: "session-1",
metrics: [
{
id: "cpu_percent",
label: "CPU",
unit: "%",
value: 17,
available: true,
},
{
id: "memory_percent",
label: "Memory",
unit: "%",
value: 33,
available: true,
},
],
},
],
},
},
};
}
describe("TopbarMetrics", () => {
beforeEach(() => {
responsiveState.isMobile = true;
subscribeMock.mockClear();
});
afterEach(() => {
cleanup();
});
it("renders a compact metrics pill on mobile when enabled", () => {
renderMetrics(initialState(true));
expect(screen.getByTestId("mobile-topbar-metrics")).toBeTruthy();
expect(screen.getByLabelText("CPU 42%")).toBeTruthy();
expect(screen.getByLabelText("Memory 68%")).toBeTruthy();
expect(screen.queryByLabelText("Disk 12%")).toBeNull();
expect(subscribeMock).toHaveBeenCalledWith(true);
});
it("prefers the active executor source in compact task topbars", () => {
renderMetrics(initialState(true), "session-1");
expect(screen.getByLabelText("Executor metrics")).toBeTruthy();
expect(screen.getByLabelText("CPU 17%")).toBeTruthy();
expect(screen.getByLabelText("Memory 33%")).toBeTruthy();
expect(screen.queryByLabelText("CPU 42%")).toBeNull();
});
it("does not subscribe or render when topbar metrics are disabled", () => {
renderMetrics(initialState(false));
expect(screen.queryByTestId("mobile-topbar-metrics")).toBeNull();
expect(subscribeMock).toHaveBeenCalledWith(false);
});
});
@@ -24,20 +24,28 @@ type TopbarMetricsProps = {
export function TopbarMetrics({ activeSessionId }: TopbarMetricsProps) {
const enabled = useAppStore((s) => s.userSettings.systemMetricsDisplay.showInTopbar);
const snapshot = useAppStore((s) => s.system.metrics);
const { isMobile } = useResponsiveBreakpoint();
const shouldRender = enabled && !isMobile;
const { usesDesktopWorkbench } = useResponsiveBreakpoint();
const shouldRender = enabled;
useSystemMetricsSubscription(shouldRender);
if (!shouldRender) return null;
const sources = selectSources(snapshot?.sources ?? [], activeSessionId);
if (sources.length === 0) {
return (
<div className="hidden md:flex h-7 items-center gap-1 rounded border border-border px-2 text-xs text-muted-foreground">
<div className="flex h-8 items-center gap-1 rounded border border-border px-2 text-xs text-muted-foreground md:h-7">
<IconActivity className="h-3.5 w-3.5" />
<span>Metrics</span>
<span className="hidden sm:inline">Metrics</span>
</div>
);
}
if (!usesDesktopWorkbench) {
return (
<CompactSourceMetrics
source={selectCompactSource(sources, activeSessionId)}
updatedAt={snapshot?.timestamp}
/>
);
}
return (
<div className="hidden md:flex max-w-[42vw] items-center gap-1 overflow-hidden">
{sources.map((source) => (
@@ -59,6 +67,10 @@ function selectSources(sources: SystemMetricsSource[], activeSessionId?: string
return [backend, execution].filter(Boolean) as SystemMetricsSource[];
}
function selectCompactSource(sources: SystemMetricsSource[], activeSessionId?: string | null) {
return sources.find((source) => source.session_id === activeSessionId) ?? sources[0];
}
function SourceMetrics({
source,
updatedAt,
@@ -84,6 +96,32 @@ function SourceMetrics({
);
}
function CompactSourceMetrics({
source,
updatedAt,
}: {
source: SystemMetricsSource;
updatedAt?: string;
}) {
const metrics = source.metrics.slice(0, 2);
return (
<div
className="flex h-9 max-w-[34vw] items-center gap-1 overflow-hidden rounded border border-border px-1.5 text-xs"
data-testid="mobile-topbar-metrics"
>
<SourceBadge source={source} updatedAt={updatedAt} />
{metrics.length > 0 ? (
metrics.map((metric) => (
<MetricChip key={metric.id} metric={metric} source={source} updatedAt={updatedAt} />
))
) : (
<span className="px-1 text-muted-foreground">-</span>
)}
</div>
);
}
function SourceBadge({ source, updatedAt }: { source: SystemMetricsSource; updatedAt?: string }) {
const isHost = source.kind === "backend";
const label = isHost ? "Host" : "Executor";
@@ -40,11 +40,6 @@ vi.mock("@/components/task/share/share-button", () => ({
shareableSessionStateClient: () => false,
}));
vi.mock("@/components/task/chat/queued-ghost-list", () => ({
QueueAffordance: ({ children }: { children: (queueChip: React.ReactNode) => React.ReactNode }) =>
children(null),
}));
vi.mock("@/components/task/chat/chat-input-container", () => ({
ChatInputContainer: () => null,
}));
@@ -82,7 +77,8 @@ vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ send: vi.fn() }),
}));
vi.mock("@/lib/local-storage", () => ({
vi.mock("@/lib/local-storage", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/local-storage")>()),
markPRClosedBannerDismissed: vi.fn(),
markPRMergedBannerDismissed: vi.fn(),
wasPRClosedBannerDismissed: () => false,
@@ -441,7 +441,7 @@ function ChatStatusBar({
{taskId && <PRMergedBanner key={`${taskId}-merged`} taskId={taskId} />}
{taskId && <PRClosedBanner key={`${taskId}-closed`} taskId={taskId} />}
{canShare && taskId && sessionId && (
<div className="ml-auto">
<div className="ml-auto shrink-0">
<ShareButton taskId={taskId} sessionId={sessionId} iconOnly />
</div>
)}
@@ -0,0 +1,100 @@
import { createRef } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatInputBody, type ChatInputBodyProps } from "./chat-input-body";
vi.mock("./tiptap-input", () => ({
TipTapInput: () => <div data-testid="mock-tiptap-input" />,
}));
vi.mock("./chat-input-toolbar", () => ({
ChatInputToolbar: () => <div data-testid="mock-chat-input-toolbar" />,
}));
vi.mock("./context-items/context-zone", () => ({
ContextZone: () => <div data-testid="mock-context-zone" />,
}));
afterEach(() => {
cleanup();
});
function props(overrides: Partial<ChatInputBodyProps> = {}): ChatInputBodyProps {
return {
containerRef: createRef<HTMLDivElement>(),
height: 120,
resizeHandleProps: { onMouseDown: vi.fn(), onDoubleClick: vi.fn() },
isStarting: false,
isAgentBusy: false,
hasClarification: false,
showRequestChangesTooltip: false,
hasPendingComments: false,
planModeEnabled: false,
showFocusHint: false,
needsRecovery: false,
addFiles: vi.fn().mockResolvedValue(undefined),
contextAreaProps: {
hasContextZone: false,
allItems: [],
sessionId: "session-1",
},
editorAreaProps: {
inputRef: createRef(),
value: "",
handleChange: vi.fn(),
handleSubmitWithReset: vi.fn(),
inputPlaceholder: "Ask to make changes",
isDisabled: false,
submitDisabled: false,
hasClarification: false,
planModeEnabled: false,
planModeAvailable: true,
mcpServers: [],
submitKey: "cmd_enter",
setIsInputFocused: vi.fn(),
sessionId: "session-1",
taskId: "task-1",
planContextEnabled: false,
handleAgentCommand: vi.fn(),
addFiles: vi.fn().mockResolvedValue(undefined),
fileInputRef: createRef(),
showRequestChangesTooltip: false,
isAgentBusy: false,
onPlanModeChange: vi.fn(),
taskDescription: "",
isSending: false,
onCancel: vi.fn(),
contextCount: 0,
contextPopoverOpen: false,
setContextPopoverOpen: vi.fn(),
contextFiles: [],
},
...overrides,
};
}
describe("ChatInputBody", () => {
it("reserves right-side editable space while the focus hint is visible", () => {
render(
<TooltipProvider>
<ChatInputBody {...props({ showFocusHint: true })} />
</TooltipProvider>,
);
expect(screen.getByText("to focus")).toBeTruthy();
expect(screen.getByTestId("chat-input-editor-shell").className).not.toContain("pr-28");
expect(screen.getByTestId("mock-tiptap-input").parentElement?.className).toContain("pr-28");
});
it("does not reserve focus-hint space when the hint is hidden", () => {
render(
<TooltipProvider>
<ChatInputBody {...props({ showFocusHint: false })} />
</TooltipProvider>,
);
expect(screen.getByTestId("chat-input-editor-shell").className).not.toContain("pr-28");
expect(screen.getByTestId("mock-tiptap-input").parentElement?.className).not.toContain("pr-28");
});
});
@@ -49,6 +49,7 @@ export type ChatInputEditorAreaProps = {
contextPopoverOpen: boolean;
setContextPopoverOpen: (open: boolean) => void;
contextFiles: ContextFile[];
editorClassName?: string;
onImplementPlan?: (fresh: boolean) => void;
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
@@ -62,10 +63,12 @@ export type ChatInputEditorAreaProps = {
function EditorWithTooltip({
showTooltip,
isEnhancingPrompt,
className,
children,
}: {
showTooltip: boolean;
isEnhancingPrompt?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
@@ -75,6 +78,7 @@ function EditorWithTooltip({
className={cn(
"flex-1 min-h-0 transition-opacity",
isEnhancingPrompt && "opacity-50 pointer-events-none",
className,
)}
>
{children}
@@ -142,6 +146,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
<EditorWithTooltip
showTooltip={showRequestChangesTooltip}
isEnhancingPrompt={isEnhancingPrompt}
className={p.editorClassName}
>
<TipTapInput
ref={inputRef}
@@ -337,9 +342,13 @@ export function ChatInputBody({
<div
ref={containerRef}
style={{ height }}
data-testid="chat-input-editor-shell"
className="flex flex-col min-h-0 overflow-hidden"
>
<ChatInputEditorArea {...editorAreaProps} />
<ChatInputEditorArea
{...editorAreaProps}
editorClassName={cn(editorAreaProps.editorClassName, showFocusHint && "pr-28")}
/>
</div>
</div>
</div>
@@ -0,0 +1,263 @@
"use client";
import { useRef, useState, type ReactNode } from "react";
import { IconAt, IconChevronsLeft, IconDots } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
import { SessionsDropdown } from "@/components/task/sessions-dropdown";
import { ModelSelector } from "@/components/task/model-selector";
import { ModeSelector } from "@/components/task/mode-selector";
import { TokenUsageDisplay } from "@/components/task/chat/token-usage-display";
import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed";
import { cn } from "@/lib/utils";
import { ResetContextButton } from "./reset-context-button";
import { ImplementPlanButton } from "./implement-plan-button";
import { VoiceInputButton } from "./voice-input-button";
import { ContextPopover } from "./context-popover";
import {
AttachFilesButton,
McpIndicator,
PlanToggleButton,
SubmitButton,
} from "./chat-input-toolbar-primitives";
import { type ChatInputToolbarProps } from "./chat-input-toolbar";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { SHORTCUTS } from "@/lib/keyboard/constants";
type ToolbarItemConfig = {
id: string;
section: "left" | "right";
render: () => ReactNode;
visible?: boolean;
};
type DesktopToolbarProps = ChatInputToolbarProps & {
planModeAvailable: boolean;
mcpServers: string[];
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
contextCount: number;
contextPopoverOpen: boolean;
planContextEnabled: boolean;
contextFiles: ContextFile[];
isEnhancingPrompt: boolean;
isUtilityConfigured: boolean;
hideAgentControls: boolean;
hidePlanMode: boolean;
};
function ToolbarExpandToggle(props: { isExpanded: boolean; onToggle: () => void }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={props.isExpanded ? "Collapse toolbar" : "More toolbar actions"}
aria-expanded={props.isExpanded}
className="h-7 w-7 cursor-pointer hover:bg-muted/40"
data-testid="toolbar-overflow-menu"
onClick={props.onToggle}
>
{props.isExpanded ? (
<IconChevronsLeft className="h-4 w-4" />
) : (
<IconDots className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{props.isExpanded ? "Collapse" : "More actions"}</TooltipContent>
</Tooltip>
);
}
function CollapsibleItems(props: { items: ToolbarItemConfig[]; testIdPrefix: string }) {
return props.items.map((i) => (
<div key={i.id} data-testid={`${props.testIdPrefix}${i.id}`}>
{i.render()}
</div>
));
}
function buildCollapsibleItems(props: DesktopToolbarProps): ToolbarItemConfig[] {
if (props.hideAgentControls) return [];
return [
{
id: "mcp",
section: "left",
render: () => <McpIndicator mcpServers={props.mcpServers} />,
},
{
id: "mode",
section: "left",
render: () => <ModeSelector sessionId={props.sessionId} />,
},
{
id: "reset-context",
section: "right",
visible: !!props.sessionId && !props.isAgentBusy,
render: () => <ResetContextButton sessionId={props.sessionId!} />,
},
{
id: "sessions",
section: "right",
visible: !props.hideSessionsDropdown,
render: () => (
<SessionsDropdown
taskId={props.taskId}
activeSessionId={props.sessionId}
taskTitle={props.taskTitle}
/>
),
},
{
id: "model",
section: "right",
render: () => <ModelSelector sessionId={props.sessionId} />,
},
{
id: "enhance",
section: "right",
visible: !props.isAgentBusy,
render: () => (
<EnhancePromptButton
onClick={props.onEnhancePrompt ?? (() => {})}
isLoading={props.isEnhancingPrompt}
isConfigured={props.isUtilityConfigured}
/>
),
},
];
}
function DesktopRightSection(props: {
showCollapsed: boolean;
rightItems: ToolbarItemConfig[];
sessionId: string | null;
planModeEnabled: boolean;
isAgentBusy: boolean;
hasContent: boolean;
onImplementPlan?: (fresh: boolean) => void;
isDisabled: boolean;
submitDisabledReason?: string;
isSending: boolean;
onCancel: () => void | Promise<void>;
onSubmit: () => void;
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
onVoiceTranscript?: (text: string) => void;
onVoiceAutoSend?: () => void;
}) {
return (
<div className="flex items-center gap-0.5 shrink-0">
{!props.showCollapsed && (
<CollapsibleItems items={props.rightItems} testIdPrefix="toolbar-item-" />
)}
<TokenUsageDisplay sessionId={props.sessionId} />
{props.planModeEnabled && !props.isAgentBusy && props.onImplementPlan && (
<ImplementPlanButton onClick={props.onImplementPlan} />
)}
<div className="ml-1 flex items-center gap-1">
{props.onVoiceTranscript && (
<VoiceInputButton
onTranscript={props.onVoiceTranscript}
onAutoSend={props.onVoiceAutoSend}
disabled={props.isDisabled}
/>
)}
<SubmitButton
isAgentBusy={props.isAgentBusy}
hasContent={props.hasContent}
isDisabled={props.isDisabled}
submitDisabledReason={props.submitDisabledReason}
isSending={props.isSending}
planModeEnabled={props.planModeEnabled}
onCancel={props.onCancel}
onSubmit={props.onSubmit}
submitShortcut={props.submitShortcut}
/>
</div>
</div>
);
}
export function DesktopChatInputToolbar(props: DesktopToolbarProps) {
const toolbarRef = useRef<HTMLDivElement>(null);
const isCollapsed = useToolbarCollapsed(toolbarRef);
const [isExpanded, setIsExpanded] = useState(false);
const showCollapsed = isCollapsed && !isExpanded;
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
ref={toolbarRef}
data-testid="chat-input-toolbar"
className={cn(
"hidden items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border md:flex",
isCollapsed ? "overflow-x-auto scrollbar-hide" : "overflow-visible",
)}
>
<div className="flex items-center gap-0.5 shrink-0">
{!props.hidePlanMode && (
<PlanToggleButton
planModeEnabled={props.planModeEnabled}
planModeAvailable={props.planModeAvailable}
onPlanModeChange={props.onPlanModeChange}
/>
)}
{!showCollapsed && <CollapsibleItems items={leftItems} testIdPrefix="toolbar-item-" />}
{props.onAttachFiles && <AttachFilesButton onClick={props.onAttachFiles} />}
<ContextPopover
open={props.contextPopoverOpen}
onOpenChange={props.onContextPopoverOpenChange ?? (() => {})}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 relative"
data-testid="chat-context-button"
aria-label="Session context"
>
<IconAt className="h-4 w-4" />
{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>
)}
</Button>
}
sessionId={props.sessionId}
planContextEnabled={props.planContextEnabled}
contextFiles={props.contextFiles}
onToggleFile={props.onToggleFile ?? (() => {})}
/>
{isCollapsed && (
<ToolbarExpandToggle isExpanded={isExpanded} onToggle={() => setIsExpanded((v) => !v)} />
)}
</div>
<div className="flex-1" />
<DesktopRightSection
showCollapsed={showCollapsed}
rightItems={rightItems}
sessionId={props.sessionId}
planModeEnabled={props.planModeEnabled}
isAgentBusy={props.isAgentBusy}
hasContent={props.hasContent ?? false}
onImplementPlan={props.onImplementPlan}
isDisabled={props.isDisabled}
submitDisabledReason={props.submitDisabledReason}
isSending={props.isSending}
onCancel={props.onCancel}
onSubmit={props.onSubmit}
submitShortcut={props.submitShortcut}
onVoiceTranscript={props.onVoiceTranscript}
onVoiceAutoSend={props.onVoiceAutoSend}
/>
</div>
);
}
@@ -0,0 +1,188 @@
"use client";
import { IconAt } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { ModelSelector } from "@/components/task/model-selector";
import { ModeSelector } from "@/components/task/mode-selector";
import { SessionsDropdown } from "@/components/task/sessions-dropdown";
import { TokenUsageDisplay } from "@/components/task/chat/token-usage-display";
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
import { VoiceInputButton } from "./voice-input-button";
import { ContextPopover } from "./context-popover";
import { ImplementPlanButton } from "./implement-plan-button";
import { ResetContextButton } from "./reset-context-button";
import {
AttachFilesButton,
McpIndicator,
PlanToggleButton,
SubmitButton,
} from "./chat-input-toolbar-primitives";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { SHORTCUTS } from "@/lib/keyboard/constants";
type MobileToolbarProps = {
planModeEnabled: boolean;
planModeAvailable: boolean;
onPlanModeChange: (enabled: boolean) => void;
hidePlanMode: boolean;
hideAgentControls: boolean;
hideSessionsDropdown: boolean;
mcpServers: string[];
sessionId: string | null;
taskId: string | null;
taskTitle?: string;
onAttachFiles?: () => void;
contextPopoverOpen: boolean;
onContextPopoverOpenChange: (open: boolean) => void;
contextCount: number;
planContextEnabled: boolean;
contextFiles: ContextFile[];
onToggleFile: (file: ContextFile) => void;
isAgentBusy: boolean;
hasContent: boolean;
onImplementPlan?: (fresh: boolean) => void;
onEnhancePrompt?: () => void;
isEnhancingPrompt: boolean;
isUtilityConfigured: boolean;
isDisabled: boolean;
submitDisabledReason?: string;
isSending: boolean;
onCancel: () => void | Promise<void>;
onSubmit: () => void;
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
onVoiceTranscript?: (text: string) => void;
onVoiceAutoSend?: () => void;
};
function mobileContextButton(contextCount: number) {
return (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 relative"
data-testid="chat-context-button"
aria-label="Session context"
>
<IconAt className="h-4 w-4" />
{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 pointer-events-none">
{contextCount}
</span>
)}
</Button>
);
}
function MobileLeftActions(props: MobileToolbarProps) {
return (
<div className="relative min-w-0 flex-1">
<div
data-testid="mobile-chat-toolbar-left-actions"
className="min-w-0 overflow-x-auto overscroll-x-contain scrollbar-hide pr-8"
>
<div className="flex w-max items-center gap-0.5 pr-3">
{!props.hidePlanMode && (
<PlanToggleButton
planModeEnabled={props.planModeEnabled}
planModeAvailable={props.planModeAvailable}
onPlanModeChange={props.onPlanModeChange}
/>
)}
{!props.hideAgentControls && (
<>
<div data-testid="toolbar-item-mcp">
<McpIndicator mcpServers={props.mcpServers} />
</div>
<div data-testid="toolbar-item-mode">
<ModeSelector sessionId={props.sessionId} triggerClassName="max-w-[46vw]" />
</div>
<div data-testid="toolbar-item-model">
<ModelSelector
sessionId={props.sessionId}
triggerClassName="max-w-[56vw] min-w-0 overflow-hidden"
/>
</div>
{!props.hideSessionsDropdown && (
<div data-testid="toolbar-item-sessions">
<SessionsDropdown
taskId={props.taskId}
activeSessionId={props.sessionId}
taskTitle={props.taskTitle}
/>
</div>
)}
</>
)}
{props.onAttachFiles && <AttachFilesButton onClick={props.onAttachFiles} />}
<div data-testid="toolbar-item-context">
<ContextPopover
open={props.contextPopoverOpen}
onOpenChange={props.onContextPopoverOpenChange}
trigger={mobileContextButton(props.contextCount)}
sessionId={props.sessionId}
planContextEnabled={props.planContextEnabled}
contextFiles={props.contextFiles}
onToggleFile={props.onToggleFile}
/>
</div>
{!props.hideAgentControls && props.sessionId && !props.isAgentBusy && (
<div data-testid="toolbar-item-reset-context">
<ResetContextButton sessionId={props.sessionId} />
</div>
)}
{!props.hideAgentControls && !props.isAgentBusy && (
<div data-testid="toolbar-item-enhance">
<EnhancePromptButton
onClick={props.onEnhancePrompt ?? (() => {})}
isLoading={props.isEnhancingPrompt}
isConfigured={props.isUtilityConfigured}
/>
</div>
)}
</div>
</div>
<div
aria-hidden="true"
data-testid="mobile-chat-toolbar-scroll-fade"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-gradient-to-l from-background via-background/80 to-transparent"
/>
</div>
);
}
export function MobileChatInputToolbar(props: MobileToolbarProps) {
return (
<div
data-testid="mobile-chat-input-toolbar"
data-legacy-testid="chat-input-toolbar"
className="flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border"
>
<MobileLeftActions {...props} />
<div className="flex shrink-0 items-center gap-1">
<TokenUsageDisplay sessionId={props.sessionId} />
{props.planModeEnabled && !props.isAgentBusy && props.onImplementPlan && (
<ImplementPlanButton onClick={props.onImplementPlan} />
)}
{props.onVoiceTranscript && (
<VoiceInputButton
onTranscript={props.onVoiceTranscript}
onAutoSend={props.onVoiceAutoSend}
disabled={props.isDisabled}
/>
)}
<SubmitButton
isAgentBusy={props.isAgentBusy}
hasContent={props.hasContent}
isDisabled={props.isDisabled}
submitDisabledReason={props.submitDisabledReason}
isSending={props.isSending}
planModeEnabled={props.planModeEnabled}
onCancel={props.onCancel}
onSubmit={props.onSubmit}
submitShortcut={props.submitShortcut}
/>
</div>
</div>
);
}
@@ -0,0 +1,244 @@
"use client";
import { useCallback, useRef, useState } from "react";
import {
IconArrowUp,
IconFileTextSpark,
IconPaperclip,
IconPlayerPauseFilled,
IconPlugConnected,
IconPlugConnectedX,
} from "@tabler/icons-react";
import { GridSpinner } from "@/components/grid-spinner";
import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip";
import { useAppStore } from "@/components/state-provider";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import { formatShortcut } from "@/lib/keyboard/utils";
import { cn } from "@/lib/utils";
type SubmitButtonProps = {
isAgentBusy: boolean;
hasContent: boolean;
isDisabled: boolean;
submitDisabledReason?: string;
isSending: boolean;
planModeEnabled: boolean;
onCancel: () => void | Promise<void>;
onSubmit: () => void;
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
};
type SendSubmitButtonProps = Pick<
SubmitButtonProps,
"isDisabled" | "isSending" | "planModeEnabled" | "onSubmit" | "submitShortcut"
> & {
tooltipDescription?: string;
};
function submitTooltipDescription(
isAgentBusy: boolean,
planModeEnabled: boolean,
submitDisabledReason?: string,
) {
if (submitDisabledReason) return submitDisabledReason;
if (isAgentBusy) return "Queue message";
if (planModeEnabled) return "Request plan changes";
return undefined;
}
function SendSubmitButton({
isDisabled,
isSending,
planModeEnabled,
onSubmit,
submitShortcut,
tooltipDescription,
}: SendSubmitButtonProps) {
return (
<KeyboardShortcutTooltip
shortcut={submitShortcut}
description={tooltipDescription}
enabled={!isDisabled || !!tooltipDescription}
>
<span
className="inline-flex"
tabIndex={isDisabled && !!tooltipDescription ? 0 : undefined}
aria-label={isDisabled ? (tooltipDescription ?? "Submit unavailable") : undefined}
>
<Button
type="button"
variant="default"
size="icon"
className={cn(
"h-7 w-7 rounded-full cursor-pointer",
planModeEnabled && "bg-violet-600 hover:bg-violet-500",
)}
disabled={isDisabled}
onClick={onSubmit}
data-testid="submit-message-button"
>
{isSending && <GridSpinner className="text-primary-foreground" />}
{!isSending && planModeEnabled && <IconFileTextSpark className="h-4 w-4" />}
{!isSending && !planModeEnabled && <IconArrowUp className="h-4 w-4" />}
</Button>
</span>
</KeyboardShortcutTooltip>
);
}
export function SubmitButton({
isAgentBusy,
hasContent,
isDisabled,
submitDisabledReason,
isSending,
planModeEnabled,
onCancel,
onSubmit,
submitShortcut,
}: SubmitButtonProps) {
const showSendButton = !isAgentBusy || hasContent;
const [isCancelling, setIsCancelling] = useState(false);
const tooltipDescription = submitTooltipDescription(
isAgentBusy,
planModeEnabled,
submitDisabledReason,
);
const cancellingRef = useRef(false);
const handleCancelClick = useCallback(async () => {
if (cancellingRef.current) return;
cancellingRef.current = true;
setIsCancelling(true);
try {
await onCancel();
} catch (error) {
console.error("Failed to cancel agent turn:", error);
} finally {
cancellingRef.current = false;
setIsCancelling(false);
}
}, [onCancel]);
return (
<div className="flex items-center gap-1">
{isAgentBusy && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7 rounded-full cursor-pointer bg-destructive/10 text-destructive hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-70"
onClick={handleCancelClick}
disabled={isCancelling}
data-testid="cancel-agent-button"
>
{isCancelling ? (
<GridSpinner className="text-destructive" />
) : (
<IconPlayerPauseFilled className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isCancelling ? "Cancelling..." : "Cancel agent"}</TooltipContent>
</Tooltip>
)}
{showSendButton && (
<SendSubmitButton
isDisabled={isDisabled}
isSending={isSending}
planModeEnabled={planModeEnabled}
onSubmit={onSubmit}
submitShortcut={submitShortcut}
tooltipDescription={tooltipDescription}
/>
)}
</div>
);
}
export function PlanToggleButton({
planModeEnabled,
planModeAvailable,
onPlanModeChange,
}: {
planModeEnabled: boolean;
planModeAvailable: boolean;
onPlanModeChange: (enabled: boolean) => void;
}) {
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
const planModeShortcutLabel = formatShortcut(getShortcut("TOGGLE_PLAN_MODE", keyboardShortcuts));
const tooltip = planModeAvailable
? `Toggle plan mode (${planModeShortcutLabel}) — Agent collaborates on the plan without implementing changes`
: `Toggle plan layout (${planModeShortcutLabel}) — View and edit the plan (agent cannot read/write it without MCP)`;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="plan-mode-toggle-button"
data-plan-available={planModeAvailable}
data-plan-enabled={planModeEnabled}
className={cn(
"h-7 gap-1.5 px-2 hover:bg-muted/40 cursor-pointer",
planModeEnabled && planModeAvailable && "bg-violet-500/15 text-violet-400",
)}
onClick={() => onPlanModeChange(!planModeEnabled)}
>
<IconFileTextSpark className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
</Tooltip>
);
}
export function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
const hasMcp = mcpServers.length > 0;
const tooltipText = hasMcp
? `MCP Servers: ${mcpServers.join(", ")}`
: "Agent does not support MCP";
const Icon = hasMcp ? IconPlugConnected : IconPlugConnectedX;
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"h-7 w-7 flex items-center justify-center rounded-md",
hasMcp ? "text-foreground" : "text-muted-foreground/40",
)}
>
<Icon className="h-4 w-4" />
</div>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
}
export function AttachFilesButton({ onClick }: { onClick: () => void }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40"
onClick={onClick}
data-testid="chat-attachments-button"
>
<IconPaperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
);
}
@@ -1,10 +1,33 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
const responsiveMock = vi.hoisted(() => ({
breakpoint: "desktop" as "mobile" | "tablet" | "compactDesktop" | "desktop",
}));
afterEach(() => {
cleanup();
});
beforeEach(() => {
responsiveMock.breakpoint = "desktop";
});
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({
breakpoint: responsiveMock.breakpoint,
isMobile: responsiveMock.breakpoint === "mobile",
isTablet: responsiveMock.breakpoint === "tablet",
isDesktop:
responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop",
isCompactDesktop: responsiveMock.breakpoint === "compactDesktop",
isFullDesktop: responsiveMock.breakpoint === "desktop",
isFinePointer: true,
usesDesktopWorkbench:
responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop",
}),
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@@ -27,7 +50,58 @@ vi.mock("@/components/keyboard-shortcut-tooltip", () => ({
),
}));
vi.mock("@/components/task/model-selector", () => ({
ModelSelector: ({ triggerClassName }: { triggerClassName?: string }) => (
<button type="button" data-testid="mock-model-selector" className={triggerClassName}>
model
</button>
),
}));
vi.mock("@/components/task/mode-selector", () => ({
ModeSelector: ({ triggerClassName }: { triggerClassName?: string }) => (
<button type="button" data-testid="mock-mode-selector" className={triggerClassName}>
mode
</button>
),
}));
vi.mock("@/components/task/sessions-dropdown", () => ({
SessionsDropdown: () => (
<button type="button" data-testid="mock-sessions-dropdown">
sessions
</button>
),
}));
vi.mock("@/components/task/chat/token-usage-display", () => ({
TokenUsageDisplay: () => <span data-testid="mock-token-usage" />,
}));
vi.mock("@/components/enhance-prompt-button", () => ({
EnhancePromptButton: () => <button type="button">Enhance</button>,
}));
vi.mock("./context-popover", () => ({
ContextPopover: ({ trigger }: { trigger: React.ReactNode }) => <>{trigger}</>,
}));
vi.mock("./implement-plan-button", () => ({
ImplementPlanButton: () => <button type="button">Implement plan</button>,
}));
vi.mock("./reset-context-button", () => ({
ResetContextButton: () => <button type="button">Reset context</button>,
}));
vi.mock("./voice-input-button", () => ({
VoiceInputButton: () => <button type="button">Voice</button>,
}));
import { ChatInputToolbar } from "./chat-input-toolbar";
import type { ChatInputToolbarProps } from "./chat-input-toolbar";
const MOBILE_TOOLBAR_TEST_ID = "mobile-chat-input-toolbar";
function deferred<T>() {
let resolve!: (v: T) => void;
@@ -55,6 +129,31 @@ function renderToolbar(onCancel: () => void | Promise<void>) {
);
}
function renderFullToolbar(overrides: Partial<ChatInputToolbarProps> = {}) {
return render(
<ChatInputToolbar
planModeEnabled={false}
onPlanModeChange={() => {}}
sessionId="s1"
taskId="t1"
taskDescription=""
isAgentBusy={false}
hasContent
isDisabled={false}
isSending={false}
onCancel={() => {}}
onSubmit={() => {}}
hidePlanMode
mcpServers={["filesystem"]}
contextCount={2}
onAttachFiles={() => {}}
onEnhancePrompt={() => {}}
isUtilityConfigured
{...overrides}
/>,
);
}
// The cancel button must disable itself while a cancel request is in flight.
// Without this guard, an impatient user clicking it repeatedly while the agent
// tears down a long-running tool (Claude Monitor, etc.) sends N cancel requests
@@ -135,3 +234,60 @@ describe("ChatInputToolbar submit button", () => {
expect(screen.getByText("The agent is still being set up.")).toBeTruthy();
});
});
describe("ChatInputToolbar responsive wrapper", () => {
it("routes mobile breakpoints to the compact toolbar without a duplicate sessions control", () => {
responsiveMock.breakpoint = "mobile";
renderFullToolbar();
const mobileToolbar = screen.getByTestId(MOBILE_TOOLBAR_TEST_ID);
expect(mobileToolbar).toBeTruthy();
expect(mobileToolbar.getAttribute("data-legacy-testid")).toBe("chat-input-toolbar");
expect(screen.getByTestId("mobile-chat-toolbar-left-actions")).toBeTruthy();
expect(screen.getByTestId("mobile-chat-toolbar-left-actions").className).toContain("pr-8");
expect(screen.getByTestId("mobile-chat-toolbar-scroll-fade").className).toContain(
"bg-gradient-to-l",
);
expect(screen.getByTestId("toolbar-item-mcp")).toBeTruthy();
expect(screen.getByTestId("toolbar-item-mode")).toBeTruthy();
expect(screen.getByTestId("toolbar-item-model")).toBeTruthy();
expect(screen.queryByTestId("toolbar-item-sessions")).toBeNull();
expect(screen.queryByTestId("mock-sessions-dropdown")).toBeNull();
expect(screen.getByTestId("toolbar-item-context")).toBeTruthy();
expect(screen.getByTestId("toolbar-item-reset-context")).toBeTruthy();
expect(screen.getByTestId("toolbar-item-enhance")).toBeTruthy();
expect(screen.getByTestId("mock-mode-selector").className).toContain("max-w-[46vw]");
expect(screen.getByTestId("mock-model-selector").className).toContain("max-w-[56vw]");
expect(screen.getByTestId("mock-model-selector").className).toContain("min-w-0");
expect(screen.getByTestId("mock-model-selector").className).toContain("overflow-hidden");
});
it("keeps the compact sessions control on tablet layouts", () => {
responsiveMock.breakpoint = "tablet";
renderFullToolbar();
expect(screen.getByTestId(MOBILE_TOOLBAR_TEST_ID)).toBeTruthy();
expect(screen.getByTestId("toolbar-item-sessions")).toBeTruthy();
expect(screen.getByTestId("mock-sessions-dropdown")).toBeTruthy();
});
it("hides the compact sessions control when requested", () => {
responsiveMock.breakpoint = "tablet";
renderFullToolbar({ hideSessionsDropdown: true });
expect(screen.getByTestId(MOBILE_TOOLBAR_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId("toolbar-item-sessions")).toBeNull();
expect(screen.queryByTestId("mock-sessions-dropdown")).toBeNull();
});
it.each(["compactDesktop", "desktop"] as const)(
"routes %s breakpoints to the desktop toolbar",
(breakpoint) => {
responsiveMock.breakpoint = breakpoint;
renderFullToolbar();
expect(screen.getByTestId("chat-input-toolbar")).toBeTruthy();
expect(screen.queryByTestId(MOBILE_TOOLBAR_TEST_ID)).toBeNull();
},
);
});
@@ -1,37 +1,13 @@
"use client";
import { memo, useCallback, useRef, useState, type ReactNode } from "react";
import {
IconArrowUp,
IconChevronsLeft,
IconDots,
IconFileTextSpark,
IconPlayerPauseFilled,
IconAt,
IconPlugConnected,
IconPlugConnectedX,
IconPaperclip,
} from "@tabler/icons-react";
import { memo } from "react";
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
import { GridSpinner } from "@/components/grid-spinner";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
import { formatShortcut } from "@/lib/keyboard/utils";
import { useAppStore } from "@/components/state-provider";
import { TokenUsageDisplay } from "@/components/task/chat/token-usage-display";
import { SessionsDropdown } from "@/components/task/sessions-dropdown";
import { ModelSelector } from "@/components/task/model-selector";
import { ModeSelector } from "@/components/task/mode-selector";
import { ContextPopover } from "./context-popover";
import { ResetContextButton } from "./reset-context-button";
import { ImplementPlanButton } from "./implement-plan-button";
import { VoiceInputButton } from "./voice-input-button";
import { DesktopChatInputToolbar } from "./chat-input-toolbar-desktop";
import { MobileChatInputToolbar } from "./chat-input-toolbar-mobile";
import { SubmitButton } from "./chat-input-toolbar-primitives";
import { shouldUseCompactTaskChrome } from "@/hooks/use-compact-task-chrome";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { ContextFile } from "@/lib/state/context-files-store";
export type ChatInputToolbarProps = {
@@ -84,392 +60,6 @@ export type ChatInputToolbarProps = {
hidePlanMode?: boolean;
};
type SubmitButtonProps = {
isAgentBusy: boolean;
hasContent: boolean;
isDisabled: boolean;
submitDisabledReason?: string;
isSending: boolean;
planModeEnabled: boolean;
onCancel: () => void | Promise<void>;
onSubmit: () => void;
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
};
function submitTooltipDescription(
isAgentBusy: boolean,
planModeEnabled: boolean,
submitDisabledReason?: string,
) {
if (submitDisabledReason) return submitDisabledReason;
if (isAgentBusy) return "Queue message";
if (planModeEnabled) return "Request plan changes";
return undefined;
}
function SubmitButton({
isAgentBusy,
hasContent,
isDisabled,
submitDisabledReason,
isSending,
planModeEnabled,
onCancel,
onSubmit,
submitShortcut,
}: SubmitButtonProps) {
// When agent is busy and there's nothing to send, show only the cancel button.
// When there's content to queue, show both cancel and send buttons side-by-side.
const showSendButton = !isAgentBusy || hasContent;
// Track cancel-in-flight locally so impatient retries are blocked at the
// button itself: cancelling a long-running tool (e.g. Claude Monitor) can
// take several seconds, and without this the user clicks repeatedly and
// each click hits the backend.
const [isCancelling, setIsCancelling] = useState(false);
const tooltipDescription = submitTooltipDescription(
isAgentBusy,
planModeEnabled,
submitDisabledReason,
);
// Re-entrancy guard via ref: `disabled={isCancelling}` already blocks DOM
// clicks, but the ref makes the guard effective for any programmatic caller
// and keeps `isCancelling` out of the useCallback deps so the handler
// identity is stable across the false→true→false flips.
const cancellingRef = useRef(false);
const handleCancelClick = useCallback(async () => {
if (cancellingRef.current) return;
cancellingRef.current = true;
setIsCancelling(true);
try {
await onCancel();
} catch (error) {
// Parent handlers normally swallow errors, but a rejected onCancel
// must not propagate as an unhandled rejection — and the button must
// re-enable so the user can retry.
console.error("Failed to cancel agent turn:", error);
} finally {
cancellingRef.current = false;
setIsCancelling(false);
}
}, [onCancel]);
return (
<div className="flex items-center gap-1">
{isAgentBusy && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7 rounded-full cursor-pointer bg-destructive/10 text-destructive hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-70"
onClick={handleCancelClick}
disabled={isCancelling}
data-testid="cancel-agent-button"
>
{isCancelling ? (
<GridSpinner className="text-destructive" />
) : (
<IconPlayerPauseFilled className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isCancelling ? "Cancelling..." : "Cancel agent"}</TooltipContent>
</Tooltip>
)}
{showSendButton && (
<KeyboardShortcutTooltip
shortcut={submitShortcut}
description={tooltipDescription}
enabled={!isDisabled || !!tooltipDescription}
>
<span className="inline-flex">
<Button
type="button"
variant="default"
size="icon"
className={cn(
"h-7 w-7 rounded-full cursor-pointer",
planModeEnabled && "bg-violet-600 hover:bg-violet-500",
)}
disabled={isDisabled}
onClick={onSubmit}
data-testid="submit-message-button"
>
{isSending && <GridSpinner className="text-primary-foreground" />}
{!isSending && planModeEnabled && <IconFileTextSpark className="h-4 w-4" />}
{!isSending && !planModeEnabled && <IconArrowUp className="h-4 w-4" />}
</Button>
</span>
</KeyboardShortcutTooltip>
)}
</div>
);
}
function PlanToggleButton({
planModeEnabled,
planModeAvailable,
onPlanModeChange,
}: {
planModeEnabled: boolean;
planModeAvailable: boolean;
onPlanModeChange: (enabled: boolean) => void;
}) {
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
const planModeShortcutLabel = formatShortcut(getShortcut("TOGGLE_PLAN_MODE", keyboardShortcuts));
const tooltip = planModeAvailable
? `Toggle plan mode (${planModeShortcutLabel}) — Agent collaborates on the plan without implementing changes`
: `Toggle plan layout (${planModeShortcutLabel}) — View and edit the plan (agent cannot read/write it without MCP)`;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="plan-mode-toggle-button"
data-plan-available={planModeAvailable}
data-plan-enabled={planModeEnabled}
className={cn(
"h-7 gap-1.5 px-2 hover:bg-muted/40 cursor-pointer",
planModeEnabled && planModeAvailable && "bg-violet-500/15 text-violet-400",
)}
onClick={() => onPlanModeChange(!planModeEnabled)}
>
<IconFileTextSpark className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
</Tooltip>
);
}
function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
const hasMcp = mcpServers.length > 0;
const tooltipText = hasMcp
? `MCP Servers: ${mcpServers.join(", ")}`
: "Agent does not support MCP";
const Icon = hasMcp ? IconPlugConnected : IconPlugConnectedX;
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"h-7 w-7 flex items-center justify-center rounded-md",
hasMcp ? "text-foreground" : "text-muted-foreground/40",
)}
>
<Icon className="h-4 w-4" />
</div>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
}
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({
showCollapsed,
rightItems,
sessionId,
planModeEnabled,
isAgentBusy,
hasContent,
onImplementPlan,
isDisabled,
submitDisabledReason,
isSending,
onCancel,
onSubmit,
submitShortcut,
onVoiceTranscript,
onVoiceAutoSend,
}: {
showCollapsed: boolean;
rightItems: ToolbarItemConfig[];
sessionId: string | null;
planModeEnabled: boolean;
isAgentBusy: boolean;
hasContent: boolean;
onImplementPlan?: (fresh: boolean) => void;
isDisabled: boolean;
submitDisabledReason?: string;
isSending: boolean;
onCancel: () => void;
onSubmit: () => void;
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
onVoiceTranscript?: (text: string) => void;
onVoiceAutoSend?: () => void;
}) {
return (
<div className="flex items-center gap-0.5 shrink-0">
{!showCollapsed && <CollapsibleItems items={rightItems} testIdPrefix="toolbar-item-" />}
<TokenUsageDisplay sessionId={sessionId} />
{planModeEnabled && !isAgentBusy && onImplementPlan && (
<ImplementPlanButton onClick={onImplementPlan} />
)}
<div className="ml-1 flex items-center gap-1">
{onVoiceTranscript && (
<VoiceInputButton
onTranscript={onVoiceTranscript}
onAutoSend={onVoiceAutoSend}
disabled={isDisabled}
/>
)}
<SubmitButton
isAgentBusy={isAgentBusy}
hasContent={hasContent}
isDisabled={isDisabled}
submitDisabledReason={submitDisabledReason}
isSending={isSending}
planModeEnabled={planModeEnabled}
onCancel={onCancel}
onSubmit={onSubmit}
submitShortcut={submitShortcut}
/>
</div>
</div>
);
}
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;
hideAgentControls?: boolean;
}): ToolbarItemConfig[] {
if (props.hideAgentControls) return [];
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}
/>
),
},
{
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>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40"
onClick={onClick}
data-testid="chat-attachments-button"
>
<IconPaperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
);
}
function MinimalToolbar({
isAgentBusy,
hasContent,
@@ -525,10 +115,8 @@ 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;
const responsiveBreakpoint = useResponsiveBreakpoint();
const usesCompactTaskChrome = shouldUseCompactTaskChrome(responsiveBreakpoint);
if (props.minimalToolbar) {
return (
@@ -545,68 +133,34 @@ 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
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}
planModeAvailable={props.planModeAvailable}
onPlanModeChange={props.onPlanModeChange}
/>
)}
{!showCollapsed && <CollapsibleItems items={leftItems} testIdPrefix="toolbar-item-" />}
{props.onAttachFiles && <AttachFilesButton onClick={props.onAttachFiles} />}
<ContextPopover
open={props.contextPopoverOpen}
onOpenChange={props.onContextPopoverOpenChange ?? (() => {})}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 relative"
data-testid="chat-context-button"
>
<IconAt className="h-4 w-4" />
{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>
)}
</Button>
}
sessionId={props.sessionId}
planContextEnabled={props.planContextEnabled}
contextFiles={props.contextFiles}
onToggleFile={props.onToggleFile ?? (() => {})}
/>
{isCollapsed && (
<ToolbarExpandToggle isExpanded={isExpanded} onToggle={() => setIsExpanded((v) => !v)} />
)}
</div>
<div className="flex-1" />
<ToolbarRightSection
showCollapsed={showCollapsed}
rightItems={rightItems}
sessionId={props.sessionId}
if (usesCompactTaskChrome) {
return (
<MobileChatInputToolbar
planModeEnabled={props.planModeEnabled}
planModeAvailable={props.planModeAvailable}
onPlanModeChange={props.onPlanModeChange}
hidePlanMode={props.hidePlanMode}
hideAgentControls={props.hideAgentControls}
hideSessionsDropdown={
(props.hideSessionsDropdown ?? false) || responsiveBreakpoint.isMobile
}
mcpServers={props.mcpServers}
sessionId={props.sessionId}
taskId={props.taskId}
taskTitle={props.taskTitle}
onAttachFiles={props.onAttachFiles}
contextPopoverOpen={props.contextPopoverOpen}
onContextPopoverOpenChange={props.onContextPopoverOpenChange ?? (() => {})}
contextCount={props.contextCount}
planContextEnabled={props.planContextEnabled}
contextFiles={props.contextFiles}
onToggleFile={props.onToggleFile ?? (() => {})}
isAgentBusy={props.isAgentBusy}
hasContent={props.hasContent ?? false}
onImplementPlan={props.onImplementPlan}
onEnhancePrompt={props.onEnhancePrompt}
isEnhancingPrompt={props.isEnhancingPrompt}
isUtilityConfigured={props.isUtilityConfigured}
isDisabled={props.isDisabled}
submitDisabledReason={props.submitDisabledReason}
isSending={props.isSending}
@@ -616,6 +170,9 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp
onVoiceTranscript={props.onVoiceTranscript}
onVoiceAutoSend={props.onVoiceAutoSend}
/>
</div>
);
);
}
const { submitKey: _submitKey, ...desktopProps } = props;
return <DesktopChatInputToolbar {...desktopProps} submitShortcut={submitShortcut} />;
});
@@ -1,8 +1,10 @@
"use client";
import { useEffect, useRef } from "react";
import { forwardRef, useEffect, useRef, useState, type ComponentPropsWithoutRef } from "react";
import { IconCheck, IconCircle, IconCircleFilled, IconListCheck, IconX } from "@tabler/icons-react";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@kandev/ui/hover-card";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card";
import { Popover, PopoverTrigger, PopoverContent } from "@kandev/ui/popover";
import { useCompactTaskChrome } from "@/hooks/use-compact-task-chrome";
import { cn } from "@/lib/utils";
type TodoDisplayItem = {
@@ -60,48 +62,108 @@ function TodoList({ todos }: { todos: TodoDisplayItem[] }) {
);
}
function TodoIndicatorContent({
todos,
completed,
progress,
}: {
todos: TodoDisplayItem[];
completed: number;
progress: number;
}) {
return (
<div data-testid="todo-indicator-popover">
<div className="flex items-center justify-between text-xs mb-2">
<span className="font-medium text-foreground">Todos</span>
<span className="text-muted-foreground">
{completed}/{todos.length} completed
</span>
</div>
<div className="h-1.5 rounded-full bg-muted/70 mb-3">
<div
className="h-full rounded-full bg-primary/80 transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
<TodoList todos={todos} />
</div>
);
}
type TodoIndicatorButtonProps = {
allComplete: boolean;
completed: number;
total: number;
open?: boolean;
} & ComponentPropsWithoutRef<"button">;
const TodoIndicatorTrigger = forwardRef<HTMLButtonElement, TodoIndicatorButtonProps>(
function TodoIndicatorTrigger({ allComplete, completed, total, open, ...buttonProps }, ref) {
return (
<button
{...buttonProps}
ref={ref}
type="button"
data-testid="todo-indicator"
aria-expanded={open}
className={cn(
"flex items-center gap-1.5 px-2 py-0.5 text-xs transition-colors rounded cursor-pointer",
allComplete
? "text-green-500 hover:text-green-400"
: "text-muted-foreground hover:text-foreground",
)}
>
{allComplete ? <IconCheck className="h-3 w-3" /> : <IconListCheck className="h-3 w-3" />}
<span>
{completed}/{total}
</span>
</button>
);
},
);
export function TodoIndicator({ todos }: TodoIndicatorProps) {
const usesCompactTaskChrome = useCompactTaskChrome();
const [open, setOpen] = useState(false);
if (!todos.length) return null;
const completed = todos.filter((t) => resolveStatus(t) === "completed").length;
const allComplete = completed === todos.length;
const progress = Math.round((completed / todos.length) * 100);
const content = <TodoIndicatorContent todos={todos} completed={completed} progress={progress} />;
if (!usesCompactTaskChrome) {
return (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<TodoIndicatorTrigger
allComplete={allComplete}
completed={completed}
total={todos.length}
/>
</HoverCardTrigger>
<HoverCardContent side="top" align="start" className="w-72 p-3">
{content}
</HoverCardContent>
</HoverCard>
);
}
return (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
data-testid="todo-indicator"
className={cn(
"flex items-center gap-1.5 px-2 py-0.5 text-xs transition-colors rounded cursor-pointer",
allComplete
? "text-green-500 hover:text-green-400"
: "text-muted-foreground hover:text-foreground",
)}
>
{allComplete ? <IconCheck className="h-3 w-3" /> : <IconListCheck className="h-3 w-3" />}
<span>
{completed}/{todos.length}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent side="top" align="start" className="w-72 p-3">
<div className="flex items-center justify-between text-xs mb-2">
<span className="font-medium text-foreground">Todos</span>
<span className="text-muted-foreground">
{completed}/{todos.length} completed
</span>
</div>
<div className="h-1.5 rounded-full bg-muted/70 mb-3">
<div
className="h-full rounded-full bg-primary/80 transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
<TodoList todos={todos} />
</HoverCardContent>
</HoverCard>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<TodoIndicatorTrigger
allComplete={allComplete}
completed={completed}
total={todos.length}
open={open}
/>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-72 p-3">
{content}
</PopoverContent>
</Popover>
);
}
@@ -1,7 +1,7 @@
import { createRef } from "react";
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useChatInputContainer } from "./use-chat-input-container";
import { shouldShowChatFocusHint, useChatInputContainer } from "./use-chat-input-container";
import type { ChatInputContainerHandle } from "./chat-input-container";
function renderInputState(overrides: Partial<Parameters<typeof useChatInputContainer>[0]> = {}) {
@@ -55,3 +55,35 @@ describe("useChatInputContainer", () => {
expect(result.current.submitDisabledReason).toBe("The agent is still being set up.");
});
});
describe("shouldShowChatFocusHint", () => {
it("hides the hint when a blurred editor has draft text", () => {
expect(
shouldShowChatFocusHint({
isInputFocused: false,
value: "halle from chat input",
hasClarification: false,
hasPendingComments: false,
}),
).toBe(false);
});
it("shows the hint only for an empty blurred editor without blocking overlays", () => {
expect(
shouldShowChatFocusHint({
isInputFocused: false,
value: " ",
hasClarification: false,
hasPendingComments: false,
}),
).toBe(true);
expect(
shouldShowChatFocusHint({
isInputFocused: true,
value: "",
hasClarification: false,
hasPendingComments: false,
}),
).toBe(false);
});
});
@@ -95,6 +95,20 @@ function getInputPlaceholder(
return "Ask to make changes, @mention files";
}
export function shouldShowChatFocusHint(args: {
isInputFocused: boolean;
value: string;
hasClarification: boolean;
hasPendingComments: boolean;
}) {
return (
!args.isInputFocused &&
args.value.trim().length === 0 &&
!args.hasClarification &&
!args.hasPendingComments
);
}
function computeDerivedState(params: {
isStarting: boolean;
isPreparingEnvironment: boolean;
@@ -108,6 +122,7 @@ function computeDerivedState(params: {
pendingCommentsByFile: Record<string, DiffComment[]> | undefined;
allItemsLength: number;
isInputFocused: boolean;
value: string;
placeholder: string | undefined;
isAgentBusy: boolean;
hasAgentCommands: boolean;
@@ -137,7 +152,12 @@ function computeDerivedState(params: {
params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0
);
const hasContextZone = params.allItemsLength > 0;
const showFocusHint = !params.isInputFocused && !hasClarification && !hasPendingComments;
const showFocusHint = shouldShowChatFocusHint({
isInputFocused: params.isInputFocused,
value: params.value,
hasClarification,
hasPendingComments,
});
const inputPlaceholder = getInputPlaceholder(
params.placeholder,
params.isAgentBusy,
@@ -222,6 +242,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) {
pendingCommentsByFile,
allItemsLength: allItems.length,
isInputFocused,
value,
placeholder,
isAgentBusy,
hasAgentCommands,
@@ -1,7 +1,15 @@
import { describe, expect, it } from "vitest";
import { decideSubmitShortcut } from "./use-tiptap-editor";
import { TIPTAP_EDITOR_TEXT_SIZE_CLASS, decideSubmitShortcut } from "./use-tiptap-editor";
import { decideHistoryNav } from "./tiptap-editor-history";
describe("TIPTAP_EDITOR_TEXT_SIZE_CLASS", () => {
it("keeps 16px text until the lg breakpoint", () => {
expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).toContain("text-base");
expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).toContain("lg:text-sm");
expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).not.toContain("md:text-sm");
});
});
describe("decideSubmitShortcut", () => {
describe("submitKey=enter", () => {
it("submits on Enter when no suggestion menu is open", () => {
@@ -40,6 +40,7 @@ export type TipTapInputHandle = {
};
const lowlightInstance = createLowlight(common);
export const TIPTAP_EDITOR_TEXT_SIZE_CLASS = "text-base leading-relaxed lg:text-sm";
/**
* Custom Placeholder extension that reads the current placeholder from
@@ -224,7 +225,7 @@ function buildEditorProps(args: {
attributes: {
class: cn(
"w-full h-full resize-none bg-transparent px-2 py-2 overflow-y-auto",
"text-sm leading-relaxed",
TIPTAP_EDITOR_TEXT_SIZE_CLASS,
"placeholder:text-muted-foreground",
"focus:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
@@ -0,0 +1,92 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ToastProvider } from "@/components/toast-provider";
import { MobileTaskList } from "@/components/task/mobile/session-task-switcher-sheet";
import type { TaskSwitcherItem } from "@/components/task/task-switcher";
const mocks = vi.hoisted(() => ({
toggleSidebarGroupCollapsed: vi.fn(),
appState: {
taskPRs: { byTaskId: {} },
comments: { byTaskId: {} },
},
}));
type MockAppState = typeof mocks.appState & {
toggleSidebarGroupCollapsed: typeof mocks.toggleSidebarGroupCollapsed;
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: MockAppState) => unknown) =>
selector({
...mocks.appState,
toggleSidebarGroupCollapsed: mocks.toggleSidebarGroupCollapsed,
}),
}));
vi.mock("@/hooks/domains/sidebar/use-effective-sidebar-view", () => ({
useEffectiveSidebarView: () => ({
id: "default",
name: "Default",
filters: [],
sort: { key: "updatedAt", direction: "desc" },
group: "workflowStep",
collapsedGroups: [],
}),
}));
vi.mock("@/hooks/domains/sidebar/use-sidebar-task-prefs", () => ({
useSidebarTaskPrefs: () => ({
pinnedTaskIds: [],
orderedTaskIds: [],
subtaskOrderByParentId: {},
togglePinnedTask: vi.fn(),
handleReorderGroup: vi.fn(),
handleReorderSubtasks: vi.fn(),
}),
}));
function task(id: string): TaskSwitcherItem {
return {
id,
title: `Task ${id}`,
state: "TODO",
workflowId: "workflow-1",
workflowName: "Workflow",
workflowStepId: "step-1",
workflowStepTitle: "Build",
};
}
describe("MobileTaskList", () => {
beforeEach(() => {
mocks.toggleSidebarGroupCollapsed.mockClear();
});
it("toggles workflow-step groups from the mobile sidebar", () => {
render(
<ToastProvider>
<MobileTaskList
tasks={["1", "2", "3", "4", "5"].map(task)}
workflows={[]}
stepsByWorkflowId={{}}
activeTaskId={null}
selectedTaskId={null}
onSelectTask={vi.fn()}
onArchiveTask={vi.fn()}
onDeleteTask={vi.fn()}
deletingTaskId={null}
/>
</ToastProvider>,
);
const header = screen.getByTestId("sidebar-group-header");
expect(header.textContent).toContain("Build");
expect(header.textContent).toContain("5");
fireEvent.click(header);
expect(mocks.toggleSidebarGroupCollapsed).toHaveBeenCalledWith("default", "step-1");
});
});
@@ -26,7 +26,7 @@ type SessionTaskSwitcherSheetProps = {
workflowId: string | null;
};
function MobileTaskList({
export function MobileTaskList({
tasks,
workflows,
stepsByWorkflowId,
+11 -3
View File
@@ -15,6 +15,7 @@ import { useAppStore } from "@/components/state-provider";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { setSessionMode } from "@/lib/api/domains/session-api";
import { cn } from "@/lib/utils";
import type { Agent, AgentProfile, AvailableAgent } from "@/lib/types/http";
type ModeOption = {
@@ -25,6 +26,7 @@ type ModeOption = {
type ModeSelectorProps = {
sessionId: string | null;
triggerClassName?: string;
};
function resolveSnapshotMode(snapshot: unknown): string | null {
@@ -121,7 +123,10 @@ function useModeSelectorState(sessionId: string | null) {
);
}
export const ModeSelector = memo(function ModeSelector({ sessionId }: ModeSelectorProps) {
export const ModeSelector = memo(function ModeSelector({
sessionId,
triggerClassName,
}: ModeSelectorProps) {
const modeState = useModeSelectorState(sessionId);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
@@ -174,9 +179,12 @@ export const ModeSelector = memo(function ModeSelector({ sessionId }: ModeSelect
variant="ghost"
size="sm"
data-testid="session-mode-selector"
className="h-7 gap-1 px-2 cursor-pointer hover:bg-muted/40 whitespace-nowrap"
className={cn(
"h-7 min-w-0 gap-1 overflow-hidden px-2 cursor-pointer hover:bg-muted/40 whitespace-nowrap",
triggerClassName,
)}
>
<span className="text-xs">{displayName}</span>
<span className="truncate text-xs">{displayName}</span>
<IconChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
</Button>
</DropdownMenuTrigger>
+6 -1
View File
@@ -29,6 +29,7 @@ type SessionModelsEntry = {
type ModelSelectorProps = {
sessionId: string | null;
triggerClassName?: string;
};
function resolveSnapshotModel(snapshot: unknown): string | null {
@@ -276,7 +277,10 @@ function useModelSelectorState(sessionId: string | null) {
return { currentModel, modelOptions, configOptions, handleModelChange, handleConfigChange };
}
export const ModelSelector = memo(function ModelSelector({ sessionId }: ModelSelectorProps) {
export const ModelSelector = memo(function ModelSelector({
sessionId,
triggerClassName,
}: ModelSelectorProps) {
const { currentModel, modelOptions, configOptions, handleModelChange, handleConfigChange } =
useModelSelectorState(sessionId);
const modelConfig = configOptions.find(isModelConfigOption);
@@ -310,6 +314,7 @@ export const ModelSelector = memo(function ModelSelector({ sessionId }: ModelSel
ariaLabel="Session model settings"
variant="compact"
popoverSide="top"
triggerClassName={triggerClassName}
/>
);
});
@@ -0,0 +1,89 @@
import { render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { describe, expect, it, vi } from "vitest";
import { ModelSelector } from "@/components/task/model-selector";
import { ModeSelector } from "@/components/task/mode-selector";
const mocks = vi.hoisted(() => {
const appState = {
activeModel: { bySessionId: {} },
sessionModels: {
bySessionId: {
"session-1": {
currentModelId: "gpt-5.5",
models: [{ modelId: "gpt-5.5", name: "GPT-5.5" }],
configOptions: [],
},
},
},
sessionMode: {
bySessionId: {
"session-1": {
currentModeId: "full-access",
availableModes: [
{ id: "full-access", name: "Full access" },
{ id: "read-only", name: "Read only" },
],
},
},
},
settingsAgents: { items: [] },
taskSessions: {
items: {
"session-1": {
agent_profile_id: "profile-1",
agent_profile_snapshot: {},
},
},
},
setActiveModel: vi.fn(),
setSessionModels: vi.fn(),
};
return {
appState,
};
});
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof mocks.appState) => unknown) => selector(mocks.appState),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
vi.mock("@/hooks/domains/settings/use-available-agents", () => ({
useAvailableAgents: () => ({ items: [] }),
}));
vi.mock("@/hooks/domains/settings/use-settings-data", () => ({
useSettingsData: vi.fn(),
}));
vi.mock("@/lib/api/domains/session-api", () => ({
setSessionConfigOption: vi.fn(),
setSessionMode: vi.fn(),
setSessionModel: vi.fn(),
}));
describe("task selector trigger styling", () => {
it("forwards custom trigger classes to the model selector trigger", () => {
render(<ModelSelector sessionId="session-1" triggerClassName="max-w-model" />);
expect(screen.getByRole("button", { name: "Session model settings" }).className).toContain(
"max-w-model",
);
});
it("forwards custom trigger classes to the mode selector trigger", () => {
render(
<TooltipProvider>
<ModeSelector sessionId="session-1" triggerClassName="max-w-mode" />
</TooltipProvider>,
);
expect(screen.getByTestId("session-mode-selector").className).toContain("max-w-mode");
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
DRAG_ACTIVATION_DISTANCE,
TOUCH_DRAG_ACTIVATION_DELAY_MS,
TOUCH_DRAG_ACTIVATION_TOLERANCE_PX,
taskSwitcherDragActivationConstraints,
} from "@/components/task/task-switcher-subtask-dnd";
describe("task switcher subtask DnD activation", () => {
it("keeps pointer dragging responsive but delays touch dragging for scrollable mobile lists", () => {
expect(taskSwitcherDragActivationConstraints()).toEqual({
pointer: { distance: DRAG_ACTIVATION_DISTANCE },
touch: {
delay: TOUCH_DRAG_ACTIVATION_DELAY_MS,
tolerance: TOUCH_DRAG_ACTIVATION_TOLERANCE_PX,
},
});
});
it("reuses stable activation constraint references across renders", () => {
expect(taskSwitcherDragActivationConstraints()).toBe(taskSwitcherDragActivationConstraints());
});
});
@@ -3,7 +3,8 @@
import { useCallback, useMemo } from "react";
import {
DndContext,
PointerSensor,
MouseSensor,
TouchSensor,
closestCenter,
type DragEndEvent,
useSensor,
@@ -20,6 +21,20 @@ import { cn } from "@/lib/utils";
import type { TaskSwitcherItem } from "./task-switcher";
export const DRAG_ACTIVATION_DISTANCE = 8;
export const TOUCH_DRAG_ACTIVATION_DELAY_MS = 250;
export const TOUCH_DRAG_ACTIVATION_TOLERANCE_PX = 5;
const TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS = {
pointer: { distance: DRAG_ACTIVATION_DISTANCE },
touch: {
delay: TOUCH_DRAG_ACTIVATION_DELAY_MS,
tolerance: TOUCH_DRAG_ACTIVATION_TOLERANCE_PX,
},
};
export function taskSwitcherDragActivationConstraints() {
return TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS;
}
// Skip layout/paint for rows outside the sidebar scrollport so long task lists
// stay cheap to render and scroll. `auto 52px` seeds the height estimate before
@@ -65,8 +80,8 @@ function DraggableSortableTaskNode({
<div
{...sortableAttributes}
{...listeners}
// Strip dnd-kit's default tabIndex={0}: only PointerSensor is wired,
// so keyboard tab stops here lead nowhere. If KeyboardSensor is
// Strip dnd-kit's default tabIndex={0}: only pointer-based sensors are
// wired, so keyboard tab stops here lead nowhere. If KeyboardSensor is
// added later, drop this override.
tabIndex={undefined}
data-testid="sortable-task-handle"
@@ -129,7 +144,12 @@ export function SortableTaskNode({
*/
function useLevelDnd(tasks: TaskSwitcherItem[], onReorder?: (orderedTaskIds: string[]) => void) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }),
useSensor(MouseSensor, {
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.pointer,
}),
useSensor(TouchSensor, {
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.touch,
}),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
+1 -1
View File
@@ -74,7 +74,7 @@ export class SessionPage {
return this.activeChat().getByTestId("chat-status-bar").getByTestId("pr-status-chip");
}
todoIndicator() {
return this.page.getByTestId("todo-indicator");
return this.activeChat().getByTestId("todo-indicator");
}
/** Span wrapper around the resume button — used to trigger tooltip on disabled state. */
failedSessionResumeWrapper(): Locator {
@@ -0,0 +1,52 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
async function seedAndOpenTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Model Selector",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
return session;
}
test.describe("Mobile chat model selector", () => {
test.describe.configure({ retries: 1, timeout: 60_000 });
test("shows mode and model in the swipeable composer actions", async ({
testPage,
apiClient,
seedData,
}) => {
await seedAndOpenTask(testPage, apiClient, seedData);
const leftActions = testPage.getByTestId("mobile-chat-toolbar-left-actions");
await expect(leftActions).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByTestId("toolbar-overflow-menu")).not.toBeVisible();
await expect(leftActions.getByTestId("toolbar-item-sessions")).toHaveCount(0);
await expect(leftActions.getByTestId("session-mode-selector")).toBeVisible();
const trigger = leftActions.getByRole("button", { name: "Session model settings" });
await expect(trigger).toContainText("Mock Fast", { timeout: 15_000 });
await trigger.tap();
await expect(testPage.getByRole("option", { name: /Mock Smart/ })).toBeVisible({
timeout: 5_000,
});
});
});
@@ -118,7 +118,10 @@ test.describe("mobile CLI mode: passthrough composer", () => {
const editor = composer.locator(".tiptap.ProseMirror");
await expect(editor).toBeVisible({ timeout: 5_000 });
await testPage.getByTestId("chat-context-button").tap();
await composer
.getByTestId("mobile-chat-toolbar-left-actions")
.getByTestId("chat-context-button")
.tap();
const searchInput = testPage.getByPlaceholder("Search files and prompts...");
await expect(searchInput).toBeVisible({ timeout: 5_000 });
await searchInput.fill(promptName);
@@ -21,6 +21,7 @@ type SeedTaskArgs = {
apiClient: ApiClient;
seedData: SeedData;
title: string;
description?: string;
prOverrides?: Partial<Parameters<ApiClient["mockGitHubAssociateTaskPR"]>[0]>;
};
@@ -28,6 +29,7 @@ async function seedTaskWithPR({
apiClient,
seedData,
title,
description = "/e2e:simple-message",
prOverrides = {},
}: SeedTaskArgs): Promise<string> {
await apiClient.mockGitHubReset();
@@ -37,7 +39,7 @@ async function seedTaskWithPR({
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
description,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
@@ -61,6 +63,22 @@ async function seedTaskWithPR({
return task.id;
}
async function seedTaskWithPRAndTodos(args: SeedTaskArgs): Promise<string> {
return seedTaskWithPR({
...args,
description: [
'e2e:plan([{"content":"Review mobile toolbar","status":"completed"},{"content":"Verify todo tap","status":"in_progress"}])',
'e2e:message("mobile todo and ci response")',
].join("\n"),
prOverrides: {
checks_state: "success",
checks_total: 4,
checks_passing: 4,
...args.prOverrides,
},
});
}
async function openTask(testPage: import("@playwright/test").Page, taskId: string) {
await testPage.goto(`/t/${taskId}`);
const session = new SessionPage(testPage);
@@ -69,6 +87,29 @@ async function openTask(testPage: import("@playwright/test").Page, taskId: strin
}
test.describe("mobile PR CI chip drawer", () => {
test("tapping the todo indicator beside the CI chip opens the todo list", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const taskId = await seedTaskWithPRAndTodos({
apiClient,
seedData,
title: "Mobile todo tap-open",
});
const session = await openTask(testPage, taskId);
await expect(session.prStatusChip()).toBeVisible({ timeout: 15_000 });
await expect(session.todoIndicator()).toBeVisible({ timeout: 15_000 });
await session.todoIndicator().tap();
const todoPopover = testPage.getByTestId("todo-indicator-popover");
await expect(todoPopover.getByText("Todos", { exact: true })).toBeVisible();
await expect(todoPopover.getByText("Verify todo tap", { exact: true })).toBeVisible();
});
test("tapping the chip opens the drawer with PR CI content", async ({
testPage,
apiClient,
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { ResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { shouldUseCompactTaskChrome, shouldUseTouchDrawer } from "@/hooks/use-compact-task-chrome";
function breakpoint(overrides: Partial<ResponsiveBreakpoint>): ResponsiveBreakpoint {
return {
breakpoint: "desktop",
isMobile: false,
isTablet: false,
isDesktop: true,
isCompactDesktop: false,
isFullDesktop: true,
isFinePointer: true,
usesDesktopWorkbench: true,
...overrides,
};
}
describe("compact task chrome breakpoint", () => {
it("uses compact task chrome on mobile and tablet layouts", () => {
expect(shouldUseCompactTaskChrome(breakpoint({ breakpoint: "mobile", isMobile: true }))).toBe(
true,
);
expect(shouldUseCompactTaskChrome(breakpoint({ breakpoint: "tablet", isTablet: true }))).toBe(
true,
);
});
it("keeps full task chrome on compact and full desktop layouts", () => {
expect(
shouldUseCompactTaskChrome(
breakpoint({
breakpoint: "compactDesktop",
isDesktop: true,
isCompactDesktop: true,
isFullDesktop: false,
}),
),
).toBe(false);
expect(shouldUseCompactTaskChrome(breakpoint({ breakpoint: "desktop" }))).toBe(false);
});
});
describe("touch drawer breakpoint", () => {
it("uses drawers on coarse-pointer layouts", () => {
expect(shouldUseTouchDrawer(breakpoint({ isFinePointer: false }))).toBe(true);
});
it("keeps hover surfaces on fine-pointer layouts", () => {
expect(shouldUseTouchDrawer(breakpoint({ breakpoint: "tablet", isTablet: true }))).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
"use client";
import type { ResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
export function shouldUseCompactTaskChrome(breakpoint: ResponsiveBreakpoint): boolean {
return breakpoint.isMobile || breakpoint.isTablet;
}
export function useCompactTaskChrome(): boolean {
return shouldUseCompactTaskChrome(useResponsiveBreakpoint());
}
export function shouldUseTouchDrawer(breakpoint: ResponsiveBreakpoint): boolean {
return !breakpoint.isFinePointer;
}
export function useTouchDrawer(): boolean {
return shouldUseTouchDrawer(useResponsiveBreakpoint());
}