fix: improve mobile chat input controls (#1477)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user