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 (
+
+ );
+}