docs/plans/2026-04-03-agent-plugin-react-migration-design.md
Migrate the agent plugin (frontend/src/plugins/agent/) from Vue to React. This is a pattern-setting migration — decisions here establish conventions for future Vue-to-React work.
| Decision | Choice | Rationale |
|---|---|---|
| State management | Zustand | Lightweight, similar to Pinia, minimal boilerplate |
| Floating window | Custom pointer events | Vue version already has the logic (~150 lines), no new dep |
| @-mention autocomplete | shadcn Combobox | Consistent with component library, handles filtered list + popover |
| Markdown rendering | react-markdown + remark-gfm | Standard React solution, replaces custom AST-to-VNode pipeline |
| Table library | None (shadcn Table if needed) | Server-side sort/pagination via Connect RPC, tanstack adds little |
frontend/src/react/plugins/agent/
├── index.ts # Export AgentWindow + useAgentShortcut
├── store/
│ └── agent.ts # Zustand store (replaces Pinia store)
├── components/
│ ├── AgentWindow.tsx # Floating panel: drag, resize, minimize, portal
│ ├── AgentChat.tsx # Message list + react-markdown
│ ├── AgentInput.tsx # Input with @-mention via shadcn Combobox
│ └── ToolCallCard.tsx # Collapsible tool call/result display
├── logic/ # COPIED AS-IS from Vue version
│ ├── types.ts
│ ├── prompt.ts
│ ├── context.ts # Reads Pinia stores directly (singletons)
│ ├── agentLoop.ts
│ ├── outboundHistory.ts
│ ├── aiConfiguration.ts
│ ├── tools/ # All tool files copied; navigate.ts imports Vue router singleton
│ └── skills/ # All skill files copied unchanged
└── dom/ # COPIED AS-IS (pure DOM APIs)
├── index.ts
├── domTree.ts
└── actions.ts
Three concerns, same as current Pinia store:
UI state — visible, minimized, position, size, sidebarWidth
bb-agent-windowChat state — chats, messagesByChatId, pendingAskByChatId, currentChatId
bb-agent-state-v2Runtime state — abortControllersByChatId, derived loading/error/runningChatIds
Zustand persist middleware handles the two localStorage keys. Derived values become inline selectors or helper functions.
createPortal(document.body) replaces Vue <Teleport>onPointerDown on header, track delta in useRef, update store on pointermoveuseEffect on window resize eventreact-markdown with remarkGfm pluginuseEffect + scrollIntoView on message count changeButton for retry/interrupt, ScrollArea for containerTextarea for message input@ keystroke, open shadcn Combobox popover with DOM ref suggestions[eN] ref into textareaCollapsible: header = tool name + status, body = pretty-printed JSON| Bridge Point | Approach |
|---|---|
| Mount in Vue app | AgentWindowMount.vue — thin wrapper that mounts React root, placed in BodyLayout.vue |
Pinia stores in context.ts | Import directly — they're singletons, callable outside Vue |
Vue Router in navigate.ts | Import router instance directly — singleton |
| i18n | react-i18next with same locale JSON files |
| Zustand from Vue | useAgentStore.getState().toggle() for keyboard shortcut |
Clean swap: delete Vue version once React version is mounted. No gradual coexistence needed — no other Vue components depend on the agent plugin.
zustandreact-markdown + remark-gfm