diff --git a/ui/src/components/assistant/ActionStrip.test.tsx b/ui/src/components/assistant/ActionStrip.test.tsx new file mode 100644 index 00000000..601d2545 --- /dev/null +++ b/ui/src/components/assistant/ActionStrip.test.tsx @@ -0,0 +1,178 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ActionStrip } from "./ActionStrip"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + const noop = () => {}; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) container.remove(); + }); + + function render(node: React.ReactNode) { + root = createRoot(container); + act(() => { + root!.render(node); + }); + } + + function buttons() { + return { + promote: container.querySelector('[data-testid="action-promote"]') as HTMLButtonElement, + attach: container.querySelector('[data-testid="action-attach"]') as HTMLButtonElement, + memory: container.querySelector('[data-testid="action-memory"]') as HTMLButtonElement, + history: container.querySelector('[data-testid="action-history"]') as HTMLButtonElement, + }; + } + + it("renders all four actions with aria labels in spec order", () => { + render( + , + ); + const b = buttons(); + expect(b.promote).not.toBeNull(); + expect(b.attach).not.toBeNull(); + expect(b.memory).not.toBeNull(); + expect(b.history).not.toBeNull(); + expect(b.promote.getAttribute("aria-label")).toBe("Promote to project"); + expect(b.attach.getAttribute("aria-label")).toBe("Attach"); + expect(b.memory.getAttribute("aria-label")).toBe("Memory"); + expect(b.history.getAttribute("aria-label")).toBe("History"); + }); + + it("renders inside a toolbar with an accessible name", () => { + render( + , + ); + const strip = container.querySelector('[data-testid="assistant-action-strip"]'); + expect(strip).not.toBeNull(); + expect(strip?.getAttribute("role")).toBe("toolbar"); + expect(strip?.getAttribute("aria-label")).toBe("Assistant actions"); + }); + + it("disables Promote when canPromote is false", () => { + render( + , + ); + const b = buttons(); + expect(b.promote.disabled).toBe(true); + // Other actions remain enabled. + expect(b.attach.disabled).toBe(false); + expect(b.memory.disabled).toBe(false); + expect(b.history.disabled).toBe(false); + }); + + it("enables Promote when canPromote is true", () => { + render( + , + ); + expect(buttons().promote.disabled).toBe(false); + }); + + it("fires the right callback on each click", () => { + const onPromote = vi.fn(); + const onAttach = vi.fn(); + const onOpenMemory = vi.fn(); + const onOpenHistory = vi.fn(); + + render( + , + ); + + const b = buttons(); + act(() => { + b.promote.click(); + b.attach.click(); + b.memory.click(); + b.history.click(); + }); + + expect(onPromote).toHaveBeenCalledTimes(1); + expect(onAttach).toHaveBeenCalledTimes(1); + expect(onOpenMemory).toHaveBeenCalledTimes(1); + expect(onOpenHistory).toHaveBeenCalledTimes(1); + }); + + it("does not fire Promote when disabled", () => { + const onPromote = vi.fn(); + render( + , + ); + act(() => { + buttons().promote.click(); + }); + expect(onPromote).not.toHaveBeenCalled(); + }); + + it("renders Promote with the volt outline variant (text-primary class)", () => { + render( + , + ); + const promote = buttons().promote; + expect(promote.className).toContain("border-primary"); + expect(promote.className).toContain("text-primary"); + }); +}); diff --git a/ui/src/components/assistant/ActionStrip.tsx b/ui/src/components/assistant/ActionStrip.tsx new file mode 100644 index 00000000..7643b189 --- /dev/null +++ b/ui/src/components/assistant/ActionStrip.tsx @@ -0,0 +1,109 @@ +// [nexus] Assistant action strip (Phase 9). +// +// 4-button row that sits beneath the input bar: Promote to project, +// Attach, Memory, History. Phase 9 wires the buttons; the promote +// transition itself lands in Phase 12. +import { Paperclip, Brain, FolderClock, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface ActionStripProps { + /** + * When true, the Promote button is enabled. The caller decides what + * "promotable" means — Phase 9 uses "active conversation with at least + * one user message and one assistant message". + */ + canPromote: boolean; + onPromote: () => void; + onAttach: () => void; + onOpenMemory: () => void; + onOpenHistory: () => void; + className?: string; +} + +interface ActionBtnProps { + label: string; + icon: typeof Paperclip; + onClick: () => void; + disabled?: boolean; + /** + * When true, the button renders as a volt-outline (primary) affordance. + */ + variant?: "outline" | "ghost"; + "data-testid"?: string; +} + +function ActionBtn({ + label, + icon: Icon, + onClick, + disabled, + variant = "ghost", + ...rest +}: ActionBtnProps) { + return ( + + ); +} + +export function ActionStrip({ + canPromote, + onPromote, + onAttach, + onOpenMemory, + onOpenHistory, + className, +}: ActionStripProps) { + return ( +
+ + + + +
+ ); +}