Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,16 @@ storage.json
.dev.log
.dev.pid
/run.sh
/.claude/
/temp_examples/
/test-results/
_workspace*
_workspace
/graphify-out
_workspace*

# Local runtime data stores (Brain / collab)
.nexus-brain/
.nexus-collab/
/.nexus-brain/
/.nexus-collab/

# nexus-acp-bridge vendored agent installs
/packages/nexus-acp-bridge/vendor/
/.adw/config.yaml
/.adw/templates/.prefs.json
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ The timer starts after the initial refresh on server startup completes. Manual r
| `Ctrl/Cmd + Alt + E` | Export workflow JSON |
| `Ctrl/Cmd + Alt + G` | Open generate/export dialog |
| `Ctrl/Cmd + Alt + A` | AI generate workflow |
| `Ctrl/Cmd + Alt + I` | Toggle AI side-kick |
| `Ctrl/Cmd + Alt + P` | Preview generated output |
| `H` / `V` | Hand tool / Selection tool |
| `?` | View all shortcuts |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# E2E Specification — AI Side-Kick ACP UX

## User Story

Validate that a workflow author can open the persistent AI side-kick, ask it to inspect the canvas, apply Nexus workflow actions, approve or deny destructive actions, and respond to forwarded ACP permission requests without leaving the editor.

## Test Steps

> Browser driving is reserved for the E2E pipeline and should use `playwright-cli` there only.

1. Open the side-kick via the header button and capture screenshot `sidekick-open-empty`.
2. Send `What can you tell me about this canvas?` and assert an assistant text response appears with no Nexus action card.
3. Send `Add a Prompt node named Draft Prompt and connect it after Start` using a mocked/deterministic bridge response, then assert an `addNode` card is `done`, a `connectNodes` card is `done`, and the canvas contains `Draft Prompt`; capture `sidekick-action-success`.
4. Select the created node and send `Delete this node`, assert a destructive action card appears with `Allow once`, `Allow always`, and `Deny`; capture `sidekick-approval-card`.
5. Click `Deny`, assert the node remains and the card status is `denied`.
6. Repeat delete, click `Allow once`, assert the node is removed and status is `done`.
7. Trigger a mocked forwarded ACP permission request, assert the permission card displays option buttons, choose `allow_once`, and assert it becomes resolved; capture `sidekick-permission-resolved`.
8. Click `New conversation`, assert message history clears and side-kick remains open; capture `sidekick-new-conversation`.

## Success Criteria

- Side-kick opens from the header and remains anchored in the bottom-right editor area.
- Text-only assistant responses render as assistant messages with no action cards.
- Nexus action cards show action names, arguments, and terminal `done` or `error` state.
- Destructive actions show exactly `Allow once`, `Allow always`, and `Deny` while awaiting approval.
- Denying a destructive action leaves the target node on the canvas and marks the card `denied`.
- Allowing once removes the target node and marks the card `done`.
- Forwarded ACP permission cards show option buttons and transition to resolved after choosing an option.
- New conversation clears visible message history, resets approvals, and keeps the panel open.

## Screenshot Capture Points

- `sidekick-open-empty`
- `sidekick-action-success`
- `sidekick-approval-card`
- `sidekick-permission-resolved`
- `sidekick-new-conversation`

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions packages/nexus-acp-bridge/src/server/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const PromptPayloadSchema = z.object({

const CreateSessionSchema = z.object({
title: z.string().optional(),
permissionMode: z.enum(["auto", "forward"]).optional(),
}).partial();

const CommandPayloadSchema = z.object({
Expand Down Expand Up @@ -460,6 +461,14 @@ export class NexusACPBridgeServer {
return withCors(json(true), this.config);
}

const sessionPermissionMatch = pathname.match(/^\/session\/([^/]+)\/permission$/);
if (request.method === "POST" && sessionPermissionMatch) {
const sessionId = decodeURIComponent(sessionPermissionMatch[1] ?? "");
this.requireSession(sessionId);
await request.json().catch(() => ({}));
return withCors(json(true), this.config);
}

const sessionAbortMatch = pathname.match(/^\/session\/([^/]+)\/abort$/);
if (request.method === "POST" && sessionAbortMatch) {
const sessionId = decodeURIComponent(sessionAbortMatch[1] ?? "");
Expand Down
3 changes: 3 additions & 0 deletions packages/nexus-acp-bridge/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ export type OpenCodeEvent =
| { type: "message.updated"; properties: { info: Message } }
| { type: "message.part.delta"; properties: { sessionID: string; messageID: string; partID: string; field: string; delta: string } }
| { type: "session.updated"; properties: { info: Session } }
| { type: "tool.call"; properties: { sessionID?: string; id?: string; callID?: string; name?: string; tool?: string; status?: string; input?: unknown } }
| { type: "tool.call.updated"; properties: { sessionID?: string; id?: string; callID?: string; name?: string; tool?: string; status?: string; input?: unknown; output?: unknown; error?: string } }
| { type: "permission.requested"; properties: { sessionID?: string; requestId?: string; id?: string; title?: string; description?: string; options?: Array<{ id: string; label: string; description?: string; outcome?: string }> } }
| { type: "session.idle"; properties: { sessionID: string } }
| { type: "session.error"; properties: { sessionID?: string; error?: { name: string; data?: { message?: string } } } };

Expand Down
13 changes: 13 additions & 0 deletions src/components/workflow/header/use-header-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { useSavedWorkflowsStore } from "@/store/library";
import { useOpenCodeStore } from "@/store/opencode";
import { useWorkflowGenStore } from "@/store/workflow-gen";
import { useSidekickStore } from "@/store/sidekick";
import { useWorkflowStore } from "@/store/workflow";
import { useCollabStore, createRoomId } from "@/store/collaboration";
import { buildCollabRoomUrl, buildCollabShareUrl, CollabDoc } from "@/lib/collaboration";
Expand All @@ -25,6 +26,7 @@ interface HeaderController {
getWorkflowJSON: () => WorkflowJSON;
isOpenCodeConnected: boolean;
isWorkflowGenOpen: boolean;
isSidekickOpen: boolean;
importDialogOpen: boolean;
setImportDialogOpen: (open: boolean) => void;
previewOpen: boolean;
Expand All @@ -44,6 +46,7 @@ interface HeaderController {
handleGenerate: () => void;
handleView: () => void;
toggleWorkflowGen: () => void;
toggleSidekick: () => void;
// Collaboration
collabRoomId: string | null;
isCollabActive: boolean;
Expand All @@ -64,6 +67,7 @@ export function useHeaderController(): HeaderController {
const openCodeStatus = useOpenCodeStore((state) => state.status);
const isOpenCodeConnected = openCodeStatus === "connected";
const isWorkflowGenOpen = useWorkflowGenStore((state) => state.floating);
const isSidekickOpen = useSidekickStore((state) => state.panelOpen);
const collabRoomId = useCollabStore((state) => state.roomId);
const isCollabActive = collabRoomId !== null;
const isCollabInitializing = useCollabStore((state) => state.isInitializing);
Expand Down Expand Up @@ -141,6 +145,10 @@ export function useHeaderController(): HeaderController {
store.setFloating(!store.floating);
}, []);

const toggleSidekick = useCallback(() => {
useSidekickStore.getState().togglePanel();
}, []);

useEffect(() => {
const onOpenImport = () => setImportDialogOpen(true);
const onOpenPreview = () => handleView();
Expand All @@ -150,19 +158,22 @@ export function useHeaderController(): HeaderController {
const store = useWorkflowGenStore.getState();
store.setFloating(!store.floating);
};
const onToggleSidekick = () => useSidekickStore.getState().togglePanel();

window.addEventListener("nexus:open-import", onOpenImport);
window.addEventListener("nexus:open-preview", onOpenPreview);
window.addEventListener("nexus:generate", onGenerate);
window.addEventListener("nexus:new-workflow-request", onNewWorkflow);
window.addEventListener("nexus:open-workflow-gen", onOpenWorkflowGen);
window.addEventListener("nexus:toggle-sidekick", onToggleSidekick);

return () => {
window.removeEventListener("nexus:open-import", onOpenImport);
window.removeEventListener("nexus:open-preview", onOpenPreview);
window.removeEventListener("nexus:generate", onGenerate);
window.removeEventListener("nexus:new-workflow-request", onNewWorkflow);
window.removeEventListener("nexus:open-workflow-gen", onOpenWorkflowGen);
window.removeEventListener("nexus:toggle-sidekick", onToggleSidekick);
};
}, [handleGenerate, handleView, requestNewWorkflow]);

Expand All @@ -175,6 +186,7 @@ export function useHeaderController(): HeaderController {
getWorkflowJSON,
isOpenCodeConnected,
isWorkflowGenOpen,
isSidekickOpen,
importDialogOpen,
setImportDialogOpen,
previewOpen,
Expand All @@ -194,6 +206,7 @@ export function useHeaderController(): HeaderController {
handleGenerate,
handleView,
toggleWorkflowGen,
toggleSidekick,
collabRoomId,
isCollabActive,
isCollabInitializing,
Expand Down
3 changes: 2 additions & 1 deletion src/components/workflow/header/workflow-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
Upload,
} from "lucide-react";
import { ProjectSwitcher } from "../project-switcher";
import { BrainToggleButton, LibraryToggleButton } from "../shared-header-actions";
import { BrainToggleButton, LibraryToggleButton, SidekickToggleButton } from "../shared-header-actions";
import { ActionRail } from "./primitives";

interface HeaderWorkflowActionsProps {
Expand Down Expand Up @@ -51,6 +51,7 @@ export function HeaderWorkflowActions({
<ActionRail>
<LibraryToggleButton variant="compact" />
<BrainToggleButton variant="compact" />
<SidekickToggleButton variant="compact" />

<DropdownMenu>
<DropdownMenuTrigger asChild>
Expand Down
9 changes: 8 additions & 1 deletion src/components/workflow/properties-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { X, Trash2, SlidersHorizontal } from "lucide-react";
import { useWorkflowStore } from "@/store/workflow";
import { useSidekickStore } from "@/store/sidekick";
import { WorkflowNodeType } from "@/types/workflow";
import {
TEXT_MUTED,
Expand Down Expand Up @@ -57,6 +58,7 @@ export default function PropertiesPanel() {
const updateSubNodeData = useWorkflowStore((s) => s.updateSubNodeData);
const setDeleteTarget = useWorkflowStore((s) => s.setDeleteTarget);
const activeSubWorkflowNodeId = useWorkflowStore((s) => s.activeSubWorkflowNodeId);
const sidekickOpen = useSidekickStore((s) => s.panelOpen);
const { selectedNodeId, nodeData, isSubNode } = useSelectedWorkflowNode();
const {
register,
Expand Down Expand Up @@ -105,11 +107,16 @@ export default function PropertiesPanel() {
}

const Icon = registryEntry!.icon;
const panelHeight = activeSubWorkflowNodeId
? "calc(100% - 32px)"
: sidekickOpen
? "calc(50vh - 24px)"
: "calc(100vh - 112px)";

return (
<div
className={`${PANEL_SHELL_CLASS} select-text animate-in slide-in-from-top-4 fade-in-0 duration-200`}
style={{ width: "min(380px, calc(100vw - 32px))", height: activeSubWorkflowNodeId ? "calc(100% - 32px)" : "calc(100vh - 112px)" }}
style={{ width: "min(380px, calc(100vw - 32px))", height: panelHeight }}
>
{/* Header */}
<div className="relative shrink-0 border-b border-zinc-800/80 px-3 py-3">
Expand Down
47 changes: 47 additions & 0 deletions src/components/workflow/shared-header-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ import {
Keyboard,
Radio,
Brain,
Sparkles,
} from "lucide-react";
import { toast } from "sonner";
import { TEXT_MUTED } from "@/lib/theme";
import { useOpenCodeStore } from "@/store/opencode";
import { useKnowledgeStore } from "@/store/knowledge-store";
import { useSidekickStore } from "@/store/sidekick";
import { cn } from "@/lib/utils";
import ShortcutsDialog from "./shortcuts-dialog";
import AboutDialog from "./about-dialog";
Expand Down Expand Up @@ -137,6 +139,51 @@ export function BrainToggleButton({ className, variant = "default" }: BrainToggl
);
}

/* ── AI Side-kick Toggle Button ─────────────────────────────────────────── */

interface SidekickToggleButtonProps {
className?: string;
variant?: "compact" | "default";
}

export function SidekickToggleButton({ className, variant = "default" }: SidekickToggleButtonProps) {
const panelOpen = useSidekickStore((s) => s.panelOpen);
const isCompact = variant === "compact";

return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => useSidekickStore.getState().togglePanel()}
aria-label="Toggle AI side-kick"
aria-pressed={panelOpen}
title="AI side-kick"
className={cn(
chromeButtonClass(isCompact),
panelOpen
? "border-violet-500/25 bg-violet-500/10 text-violet-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
: `${TEXT_MUTED} hover:border-zinc-700/70 hover:bg-zinc-800/80 hover:text-zinc-100`,
className,
)}
>
<span
className={cn(
"flex size-5.5 shrink-0 items-center justify-center transition-colors",
panelOpen ? "text-violet-300" : "text-zinc-400 group-hover:text-zinc-200",
)}
>
<Sparkles className="h-3.5 w-3.5" />
</span>
<span className={cn(isCompact ? "text-xs font-medium" : "")}>Side-kick</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Toggle AI side-kick</TooltipContent>
</Tooltip>
);
}

/* ── Help Menu (dropdown + shortcuts dialog) ─────────────────────────────── */

interface HelpMenuProps {
Expand Down
1 change: 1 addition & 0 deletions src/components/workflow/shortcuts-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default function ShortcutsDialog({ open, onOpenChange }: ShortcutsDialogP
<Row keys={[MOD, ALT, "O"]} label="Import workflow" />
<Row keys={[MOD, ALT, "G"]} label="Open generate/export dialog" />
<Row keys={[MOD, ALT, "A"]} label="AI generate workflow" />
<Row keys={[MOD, ALT, "I"]} label="Toggle AI side-kick" />
<Row keys={[MOD, ALT, "P"]} label="Preview output" />
</Section>

Expand Down
3 changes: 3 additions & 0 deletions src/components/workflow/sidekick/acp-tool-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"use client";
import type { SidekickMessage } from "@/store/sidekick";
export function AcpToolCard({ message }: { message: Extract<SidekickMessage, { kind: "acp-tool" }> }) { return <details className="rounded-xl border border-sky-800/70 bg-sky-950/30 p-3 text-xs text-sky-100"><summary className="cursor-pointer font-medium">ACP tool: {message.tool.name} <span className="text-sky-300">{message.tool.status}</span></summary><pre className="mt-2 max-h-40 overflow-auto text-[11px] text-sky-200/80">{JSON.stringify({ input: message.tool.input, output: message.tool.output, error: message.tool.error }, null, 2)}</pre></details>; }
8 changes: 8 additions & 0 deletions src/components/workflow/sidekick/action-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"use client";
import { ShieldAlert } from "lucide-react";
import type { SidekickMessage } from "@/store/sidekick";
import { useSidekickStore } from "@/store/sidekick";
export function ActionCard({ message }: { message: Extract<SidekickMessage, { kind: "action" }> }) {
const approve = useSidekickStore((s) => s.approve); const deny = useSidekickStore((s) => s.deny); const awaiting = message.call.status === "awaiting-approval";
return <div className="rounded-xl border border-zinc-700/70 bg-zinc-900/80 p-3 text-xs text-zinc-200"><div className="flex items-center gap-2"><ShieldAlert size={14}/><b>{message.call.name}</b><span className="ml-auto rounded bg-zinc-800 px-2 py-0.5">{message.call.status}</span></div><pre className="mt-2 max-h-24 overflow-auto text-[11px] text-zinc-400">{JSON.stringify(message.call.args, null, 2)}</pre>{message.result?.error && <p className="mt-2 text-red-300">{message.result.error.code}: {message.result.error.message}</p>}{awaiting && <div className="mt-3 flex gap-2"><button className="rounded bg-emerald-600 px-2 py-1" onClick={() => approve(false)}>Allow once</button><button className="rounded bg-emerald-700 px-2 py-1" onClick={() => approve(true)}>Allow always</button><button className="rounded bg-red-700 px-2 py-1" onClick={deny}>Deny</button></div>}</div>;
}
5 changes: 5 additions & 0 deletions src/components/workflow/sidekick/input-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"use client";
import { useState } from "react";
import { Send, Square } from "lucide-react";
import { useSidekickStore } from "@/store/sidekick";
export function SidekickInputBar() { const [text, setText] = useState(""); const send = useSidekickStore((s) => s.send); const cancel = useSidekickStore((s) => s.cancel); const status = useSidekickStore((s) => s.status); const busy = ["creating-session", "streaming", "running-tools"].includes(status); const submit = () => { if (!text.trim() || busy) return; const value = text; setText(""); void send(value); }; return <div className="border-t border-zinc-800 p-3"><textarea aria-label="Message AI side-kick" value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submit(); }} className="min-h-16 w-full resize-none rounded-xl border border-zinc-700 bg-zinc-950 p-2 text-sm text-zinc-100 outline-none focus:border-violet-500" placeholder="Ask about or edit this workflow…"/><div className="mt-2 flex justify-end gap-2">{busy && <button aria-label="Cancel side-kick response" onClick={() => void cancel()} className="rounded-lg border border-zinc-700 px-3 py-1 text-zinc-200"><Square size={14}/></button>}<button aria-label="Send side-kick message" disabled={!text.trim() || busy} onClick={submit} className="inline-flex items-center gap-2 rounded-lg bg-violet-600 px-3 py-1 text-sm text-white disabled:opacity-50"><Send size={14}/>Send</button></div></div>; }
6 changes: 6 additions & 0 deletions src/components/workflow/sidekick/messages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"use client";
import { ActionCard } from "./action-card";
import { AcpToolCard } from "./acp-tool-card";
import { PermissionCard } from "./permission-card";
import type { SidekickMessage } from "@/store/sidekick";
export function SidekickMessages({ messages }: { messages: SidekickMessage[] }) { return <div className="flex-1 space-y-3 overflow-auto p-3">{messages.length === 0 && <p className="rounded-xl border border-dashed border-zinc-700 p-4 text-sm text-zinc-400">Ask the AI side-kick about the current canvas or request a workflow edit.</p>}{messages.map((m) => { if (m.kind === "action") return <ActionCard key={m.id} message={m}/>; if (m.kind === "acp-tool") return <AcpToolCard key={m.id} message={m}/>; if (m.kind === "permission") return <PermissionCard key={m.id} message={m}/>; return <div key={m.id} className={`rounded-2xl px-3 py-2 text-sm ${m.role === "user" ? "ml-8 bg-violet-600 text-white" : "mr-8 bg-zinc-800 text-zinc-100"}`}><div className="whitespace-pre-wrap">{m.text}</div></div>; })}</div>; }
Loading