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:
parent
a2dab5b4f6
commit
cd75772a6a
2 changed files with 287 additions and 0 deletions
178
ui/src/components/assistant/ActionStrip.test.tsx
Normal file
178
ui/src/components/assistant/ActionStrip.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
109
ui/src/components/assistant/ActionStrip.tsx
Normal file
109
ui/src/components/assistant/ActionStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue