feat(nexus): add ActionStrip for assistant actions (phase 9)

Four-button row beneath the input bar — Promote (volt outline, disabled
when the conversation is not promotable), Attach, Memory, History. Pure
presentational component; the caller owns click handlers and the
promotable predicate. Phase 12 will wire the promote animation itself.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:17:46 +00:00
parent a2dab5b4f6
commit cd75772a6a
2 changed files with 287 additions and 0 deletions

View file

@ -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("<ActionStrip />", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | 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(
<ActionStrip
canPromote
onPromote={noop}
onAttach={noop}
onOpenMemory={noop}
onOpenHistory={noop}
/>,
);
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(
<ActionStrip
canPromote={false}
onPromote={noop}
onAttach={noop}
onOpenMemory={noop}
onOpenHistory={noop}
/>,
);
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(
<ActionStrip
canPromote={false}
onPromote={noop}
onAttach={noop}
onOpenMemory={noop}
onOpenHistory={noop}
/>,
);
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(
<ActionStrip
canPromote
onPromote={noop}
onAttach={noop}
onOpenMemory={noop}
onOpenHistory={noop}
/>,
);
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(
<ActionStrip
canPromote
onPromote={onPromote}
onAttach={onAttach}
onOpenMemory={onOpenMemory}
onOpenHistory={onOpenHistory}
/>,
);
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(
<ActionStrip
canPromote={false}
onPromote={onPromote}
onAttach={noop}
onOpenMemory={noop}
onOpenHistory={noop}
/>,
);
act(() => {
buttons().promote.click();
});
expect(onPromote).not.toHaveBeenCalled();
});
it("renders Promote with the volt outline variant (text-primary class)", () => {
render(
<ActionStrip
canPromote
onPromote={noop}
onAttach={noop}
onOpenMemory={noop}
onOpenHistory={noop}
/>,
);
const promote = buttons().promote;
expect(promote.className).toContain("border-primary");
expect(promote.className).toContain("text-primary");
});
});

View file

@ -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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
data-testid={rest["data-testid"]}
className={cn(
"inline-flex items-center gap-2 rounded-[4px] px-3 py-1.5 text-[12px] font-medium uppercase tracking-[0.1em]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"transition-colors duration-100",
variant === "outline"
? "border border-primary text-primary hover:bg-primary/10 disabled:border-border disabled:text-muted-foreground disabled:hover:bg-transparent"
: "text-muted-foreground hover:text-primary disabled:text-muted-foreground/40 disabled:hover:text-muted-foreground/40",
"disabled:cursor-not-allowed",
)}
>
<Icon className="h-4 w-4" strokeWidth={1.5} />
<span>{label}</span>
</button>
);
}
export function ActionStrip({
canPromote,
onPromote,
onAttach,
onOpenMemory,
onOpenHistory,
className,
}: ActionStripProps) {
return (
<div
data-testid="assistant-action-strip"
role="toolbar"
aria-label="Assistant actions"
className={cn("flex items-center gap-2 pt-3", className)}
>
<ActionBtn
label="Promote to project"
icon={Plus}
variant="outline"
disabled={!canPromote}
onClick={onPromote}
data-testid="action-promote"
/>
<ActionBtn
label="Attach"
icon={Paperclip}
onClick={onAttach}
data-testid="action-attach"
/>
<ActionBtn
label="Memory"
icon={Brain}
onClick={onOpenMemory}
data-testid="action-memory"
/>
<ActionBtn
label="History"
icon={FolderClock}
onClick={onOpenHistory}
data-testid="action-history"
/>
</div>
);
}