From aeb4f640c5227b169c45d11c73eab9b6fff97569 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Sat, 28 Feb 2026 21:30:47 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20agent=20panel=20UI=20=E2=80=94=203?= =?UTF-8?q?-layer=20panel=20with=20action=20cards=20and=20WebSocket=20acti?= =?UTF-8?q?vity=20hook=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: AI agent panel UI — 3-layer panel with action cards and WebSocket activity hook * style: rustfmt mcp.rs --------- Co-authored-by: Test --- design/ai-agent-panel-ui.pen | 340 +++++++++++++++++++++++++++ src-tauri/src/mcp.rs | 21 +- src/components/AiActionCard.test.tsx | 62 +++++ src/components/AiActionCard.tsx | 69 ++++++ src/components/AiMessage.test.tsx | 91 +++++++ src/components/AiMessage.tsx | 134 +++++++++++ src/components/AiPanel.test.tsx | 67 ++++++ src/components/AiPanel.tsx | 120 ++++++++++ src/hooks/useAiActivity.test.ts | 115 +++++++++ src/hooks/useAiActivity.ts | 64 +++++ 10 files changed, 1066 insertions(+), 17 deletions(-) create mode 100644 design/ai-agent-panel-ui.pen create mode 100644 src/components/AiActionCard.test.tsx create mode 100644 src/components/AiActionCard.tsx create mode 100644 src/components/AiMessage.test.tsx create mode 100644 src/components/AiMessage.tsx create mode 100644 src/components/AiPanel.test.tsx create mode 100644 src/components/AiPanel.tsx create mode 100644 src/hooks/useAiActivity.test.ts create mode 100644 src/hooks/useAiActivity.ts diff --git a/design/ai-agent-panel-ui.pen b/design/ai-agent-panel-ui.pen new file mode 100644 index 00000000..1014831c --- /dev/null +++ b/design/ai-agent-panel-ui.pen @@ -0,0 +1,340 @@ +{ + "children": [ + { + "type": "frame", + "id": "ai_panel_closed", + "name": "AI Agent Panel — Closed (trigger button in toolbar)", + "x": 0, + "y": 0, + "width": 1280, + "height": 800, + "fill": "$--background", + "layout": "horizontal", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "rectangle", + "id": "ai_closed_editor", + "width": "fill_container", + "height": "fill_container", + "fill": "$--muted", + "cornerRadius": 4 + }, + { + "type": "frame", + "id": "ai_closed_toolbar", + "name": "Right Toolbar", + "layout": "vertical", + "width": 36, + "height": "fill_container", + "fill": "$--background", + "padding": [8, 4], + "gap": 4, + "children": [ + { + "type": "frame", + "id": "ai_trigger_btn", + "name": "AI Trigger Button", + "width": 28, + "height": 28, + "cornerRadius": 6, + "fill": "$--muted", + "layout": "horizontal", + "padding": [6, 6], + "children": [ + { + "type": "text", + "id": "ai_trigger_icon", + "content": "✦", + "fill": "$--muted-foreground", + "fontSize": 14 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ai_panel_open_idle", + "name": "AI Agent Panel — Open, Idle (empty state)", + "x": 1320, + "y": 0, + "width": 1280, + "height": 800, + "fill": "$--background", + "layout": "horizontal", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "rectangle", + "id": "ai_open_editor", + "width": "fill_container", + "height": "fill_container", + "fill": "$--muted", + "cornerRadius": 4 + }, + { + "type": "frame", + "id": "ai_panel_sidebar", + "name": "AI Panel Sidebar", + "layout": "vertical", + "width": 320, + "height": "fill_container", + "fill": "$--background", + "children": [ + { + "type": "frame", + "id": "ai_panel_header", + "name": "Panel Header", + "layout": "horizontal", + "width": "fill_container", + "height": 45, + "padding": [0, 12], + "gap": 8, + "children": [ + { + "type": "text", + "id": "ai_header_label", + "content": "AI", + "fontSize": 13, + "fontWeight": "600", + "fill": "$--muted-foreground", + "width": "fill_container" + }, + { + "type": "text", + "id": "ai_close_icon", + "content": "✕", + "fontSize": 12, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "ai_empty_state", + "name": "Empty State", + "layout": "vertical", + "width": "fill_container", + "height": "fill_container", + "padding": [24, 24], + "children": [ + { + "type": "text", + "id": "ai_empty_hint", + "content": "Ask the AI agent to work on your vault…", + "fontSize": 13, + "fill": "$--muted-foreground", + "textAlign": "center", + "width": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "ai_input_row", + "name": "Input Row", + "layout": "horizontal", + "width": "fill_container", + "padding": [8, 12], + "gap": 8, + "children": [ + { + "type": "rectangle", + "id": "ai_input_field", + "width": "fill_container", + "height": 32, + "cornerRadius": 6, + "fill": "$--muted" + }, + { + "type": "frame", + "id": "ai_send_btn", + "name": "Send Button", + "width": 28, + "height": 28, + "cornerRadius": 6, + "fill": "$--primary", + "layout": "horizontal", + "padding": [6, 6], + "children": [ + { + "type": "text", + "id": "ai_send_icon", + "content": "→", + "fontSize": 12, + "fill": "$--primary-foreground" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ai_panel_active", + "name": "AI Agent Panel — Active (streaming, action cards visible)", + "x": 2640, + "y": 0, + "width": 1280, + "height": 800, + "fill": "$--background", + "layout": "horizontal", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "rectangle", + "id": "ai_active_editor", + "width": "fill_container", + "height": "fill_container", + "fill": "$--muted", + "cornerRadius": 4 + }, + { + "type": "frame", + "id": "ai_active_panel", + "name": "AI Panel — With Messages", + "layout": "vertical", + "width": 320, + "height": "fill_container", + "fill": "$--background", + "children": [ + { + "type": "frame", + "id": "ai_active_header", + "name": "Panel Header", + "layout": "horizontal", + "width": "fill_container", + "height": 45, + "padding": [0, 12], + "gap": 8, + "children": [ + { + "type": "text", + "id": "ai_active_label", + "content": "AI", + "fontSize": 13, + "fontWeight": "600", + "fill": "$--muted-foreground", + "width": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "ai_message_thread", + "name": "Message Thread", + "layout": "vertical", + "width": "fill_container", + "height": "fill_container", + "padding": [12, 12], + "gap": 16, + "children": [ + { + "type": "frame", + "id": "ai_user_msg", + "name": "User Message", + "layout": "vertical", + "width": "fill_container", + "gap": 4, + "children": [ + { + "type": "text", + "content": "You", + "fontSize": 11, + "fontWeight": "600", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "content": "Crea una nota evento per la riunione con Marco domani", + "fontSize": 13, + "fill": "$--foreground", + "width": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "ai_agent_msg", + "name": "Agent Message + Actions", + "layout": "vertical", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "ai_action_done", + "name": "Action Card — Done", + "layout": "horizontal", + "width": "fill_container", + "height": 28, + "padding": [0, 8], + "gap": 6, + "cornerRadius": 4, + "fill": "$--muted", + "children": [ + { + "type": "text", + "content": "✓", + "fontSize": 12, + "fill": "$--primary" + }, + { + "type": "text", + "content": "Loaded vault context", + "fontSize": 12, + "fill": "$--foreground", + "width": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "ai_action_progress", + "name": "Action Card — In Progress", + "layout": "horizontal", + "width": "fill_container", + "height": 28, + "padding": [0, 8], + "gap": 6, + "cornerRadius": 4, + "fill": "$--muted", + "children": [ + { + "type": "text", + "content": "◌", + "fontSize": 12, + "fill": "$--primary" + }, + { + "type": "text", + "content": "Creating: 2026-03-01-meeting-marco.md", + "fontSize": 12, + "fill": "$--foreground", + "width": "fill_container" + } + ] + }, + { + "type": "text", + "content": "Ho creato la nota evento e linkato Marco come partecipante. La trovi già aperta in un nuovo tab.", + "fontSize": 13, + "fill": "$--foreground", + "width": "fill_container" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 2a69d992..fc1e99bb 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -75,10 +75,7 @@ fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value { /// Write MCP registration to a list of config file paths. /// Returns "registered" on first registration, "updated" if already present. -fn register_mcp_to_configs( - entry: &serde_json::Value, - config_paths: &[PathBuf], -) -> String { +fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String { let mut status = "registered"; for config_path in config_paths { match upsert_mcp_config(config_path, entry) { @@ -93,10 +90,7 @@ fn register_mcp_to_configs( /// Register Laputa as an MCP server in Claude Code and Cursor config files. pub fn register_mcp(vault_path: &str) -> Result { let server_dir = mcp_server_dir()?; - let index_js = server_dir - .join("index.js") - .to_string_lossy() - .into_owned(); + let index_js = server_dir.join("index.js").to_string_lossy().into_owned(); let entry = build_mcp_entry(&index_js, vault_path); @@ -112,10 +106,7 @@ pub fn register_mcp(vault_path: &str) -> Result { } /// Insert or update the "laputa" entry in an MCP config file. -fn upsert_mcp_config( - config_path: &Path, - entry: &serde_json::Value, -) -> Result { +fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result { if let Some(parent) = config_path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?; @@ -211,11 +202,7 @@ mod tests { "other-server": { "command": "other", "args": [] } } }); - std::fs::write( - &config_path, - serde_json::to_string(&existing).unwrap(), - ) - .unwrap(); + std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap(); let entry = build_mcp_entry("/test/index.js", "/vault"); upsert_mcp_config(&config_path, &entry).unwrap(); diff --git a/src/components/AiActionCard.test.tsx b/src/components/AiActionCard.test.tsx new file mode 100644 index 00000000..23baf1a2 --- /dev/null +++ b/src/components/AiActionCard.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { AiActionCard } from './AiActionCard' + +describe('AiActionCard', () => { + it('renders label text', () => { + render() + expect(screen.getByText('Created test.md')).toBeTruthy() + }) + + it('shows pending spinner', () => { + render() + expect(screen.getByTestId('status-pending')).toBeTruthy() + }) + + it('shows done check', () => { + render() + expect(screen.getByTestId('status-done')).toBeTruthy() + }) + + it('shows error icon', () => { + render() + expect(screen.getByTestId('status-error')).toBeTruthy() + }) + + it('is clickable when path and onOpenNote provided', () => { + const onOpenNote = vi.fn() + render() + fireEvent.click(screen.getByTestId('ai-action-card')) + expect(onOpenNote).toHaveBeenCalledWith('/vault/test.md') + }) + + it('has button role when clickable', () => { + render() + expect(screen.getByRole('button')).toBeTruthy() + }) + + it('is not clickable without path', () => { + const onOpenNote = vi.fn() + render() + fireEvent.click(screen.getByTestId('ai-action-card')) + expect(onOpenNote).not.toHaveBeenCalled() + }) + + it('is not clickable without onOpenNote', () => { + render() + const card = screen.getByTestId('ai-action-card') + expect(card.getAttribute('role')).toBeNull() + }) + + it('uses lighter background for ui_ tools', () => { + render() + const card = screen.getByTestId('ai-action-card') + expect(card.style.background).toContain('0.06') + }) + + it('uses standard background for vault tools', () => { + render() + const card = screen.getByTestId('ai-action-card') + expect(card.style.background).toContain('0.1') + }) +}) diff --git a/src/components/AiActionCard.tsx b/src/components/AiActionCard.tsx new file mode 100644 index 00000000..8e217fec --- /dev/null +++ b/src/components/AiActionCard.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react' +import { + PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle, + CircleNotch, CheckCircle, XCircle, +} from '@phosphor-icons/react' + +export type AiActionStatus = 'pending' | 'done' | 'error' + +export interface AiActionCardProps { + tool: string + label: string + path?: string + status: AiActionStatus + onOpenNote?: (path: string) => void +} + +type IconRenderer = (size: number) => ReactNode + +const TOOL_ICON_MAP: Record = { + create_note: (s) => , + edit_note_frontmatter: (s) => , + append_to_note: (s) => , + search_notes: (s) => , + list_notes: (s) => , + link_notes: (s) => , + delete_note: (s) => , + vault_context: (s) => , + ui_open_note: (s) => , + ui_open_tab: (s) => , + ui_highlight: (s) => , + ui_set_filter: (s) => , +} + +const DEFAULT_ICON: IconRenderer = (s) => + +function StatusIndicator({ status }: { status: AiActionStatus }) { + if (status === 'pending') { + return + } + if (status === 'done') { + return + } + return +} + +export function AiActionCard({ tool, label, path, status, onOpenNote }: AiActionCardProps) { + const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON + const isClickable = !!path && !!onOpenNote + const isUiTool = tool.startsWith('ui_') + + return ( +
onOpenNote(path) : undefined} + role={isClickable ? 'button' : undefined} + data-testid="ai-action-card" + > + {renderIcon(14)} + {label} + +
+ ) +} diff --git a/src/components/AiMessage.test.tsx b/src/components/AiMessage.test.tsx new file mode 100644 index 00000000..acadfabc --- /dev/null +++ b/src/components/AiMessage.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { AiMessage } from './AiMessage' + +describe('AiMessage', () => { + it('renders user message', () => { + render() + expect(screen.getByText('Hello AI')).toBeTruthy() + }) + + it('renders response text', () => { + render() + expect(screen.getByText('Here is the answer')).toBeTruthy() + }) + + it('shows undo button with response', () => { + render() + expect(screen.getByTestId('undo-button')).toBeTruthy() + }) + + it('renders reasoning toggle collapsed by default', () => { + render() + expect(screen.getByTestId('reasoning-toggle')).toBeTruthy() + expect(screen.queryByTestId('reasoning-content')).toBeNull() + }) + + it('expands reasoning on toggle click', () => { + render() + fireEvent.click(screen.getByTestId('reasoning-toggle')) + expect(screen.getByTestId('reasoning-content')).toBeTruthy() + expect(screen.getByText('Thinking about it...')).toBeTruthy() + }) + + it('collapses reasoning on second click', () => { + render() + fireEvent.click(screen.getByTestId('reasoning-toggle')) + expect(screen.getByTestId('reasoning-content')).toBeTruthy() + fireEvent.click(screen.getByTestId('reasoning-toggle')) + expect(screen.queryByTestId('reasoning-content')).toBeNull() + }) + + it('renders action cards', () => { + render( + , + ) + expect(screen.getAllByTestId('ai-action-card')).toHaveLength(2) + }) + + it('passes onOpenNote to action cards', () => { + const onOpenNote = vi.fn() + render( + , + ) + fireEvent.click(screen.getByTestId('ai-action-card')) + expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md') + }) + + it('shows streaming indicator when streaming without response', () => { + const { container } = render( + , + ) + expect(container.querySelector('.typing-dot')).toBeTruthy() + }) + + it('does not show streaming indicator when response is present', () => { + const { container } = render( + , + ) + expect(container.querySelector('.typing-dot')).toBeNull() + }) + + it('does not render reasoning block when no reasoning', () => { + render() + expect(screen.queryByTestId('reasoning-toggle')).toBeNull() + }) + + it('does not render actions when empty array', () => { + render() + expect(screen.queryByTestId('ai-action-card')).toBeNull() + }) +}) diff --git a/src/components/AiMessage.tsx b/src/components/AiMessage.tsx new file mode 100644 index 00000000..f7bb8501 --- /dev/null +++ b/src/components/AiMessage.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react' +import { AiActionCard, type AiActionStatus } from './AiActionCard' + +export interface AiAction { + tool: string + label: string + path?: string + status: AiActionStatus +} + +export interface AiMessageProps { + userMessage: string + reasoning?: string + actions: AiAction[] + response?: string + isStreaming?: boolean + onOpenNote?: (path: string) => void +} + +function UserBubble({ content }: { content: string }) { + return ( +
+
+ {content} +
+
+ ) +} + +function ReasoningBlock({ text, expanded, onToggle }: { + text: string; expanded: boolean; onToggle: () => void +}) { + return ( +
+ + {expanded && ( +
+ {text} +
+ )} +
+ ) +} + +function ActionCardsList({ actions, onOpenNote }: { + actions: AiAction[]; onOpenNote?: (path: string) => void +}) { + return ( +
+ {actions.map((action, i) => ( + + ))} +
+ ) +} + +function ResponseBlock({ text }: { text: string }) { + return ( +
+
{text}
+ +
+ ) +} + +function StreamingIndicator() { + return ( +
+
+ + + +
+
+ ) +} + +export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) { + const [reasoningExpanded, setReasoningExpanded] = useState(false) + + return ( +
+ + {reasoning && ( + setReasoningExpanded(!reasoningExpanded)} + /> + )} + {actions.length > 0 && } + {response && } + {isStreaming && !response && } +
+ ) +} diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx new file mode 100644 index 00000000..88912205 --- /dev/null +++ b/src/components/AiPanel.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { AiPanel } from './AiPanel' + +describe('AiPanel', () => { + it('renders panel with AI header', () => { + render() + expect(screen.getByText('AI')).toBeTruthy() + }) + + it('renders data-testid ai-panel', () => { + render() + expect(screen.getByTestId('ai-panel')).toBeTruthy() + }) + + it('calls onClose when close button is clicked', () => { + const onClose = vi.fn() + render() + // Find close button inside the panel header (last button with X icon) + const panel = screen.getByTestId('ai-panel') + const buttons = panel.querySelectorAll('button') + const closeBtn = buttons[0] // First button in panel is the close button in header + fireEvent.click(closeBtn) + expect(onClose).toHaveBeenCalled() + }) + + it('renders mock messages', () => { + render() + const messages = screen.getAllByTestId('ai-message') + expect(messages.length).toBeGreaterThanOrEqual(2) + }) + + it('renders user message text from mock data', () => { + render() + expect(screen.getByText('Crea una nota evento per la riunione con Marco domani')).toBeTruthy() + }) + + it('renders response text from mock data', () => { + render() + expect(screen.getByText(/Ho creato la nota evento/)).toBeTruthy() + }) + + it('renders disabled input bar', () => { + render() + const input = screen.getByPlaceholderText('Ask the AI agent...') + expect(input).toBeTruthy() + expect((input as HTMLInputElement).disabled).toBe(true) + }) + + it('passes onOpenNote to messages', () => { + const onOpenNote = vi.fn() + render() + // Action cards with paths should be clickable + const cards = screen.getAllByTestId('ai-action-card') + const clickableCard = cards.find(card => card.getAttribute('role') === 'button') + if (clickableCard) { + fireEvent.click(clickableCard) + expect(onOpenNote).toHaveBeenCalled() + } + }) + + it('renders action cards from mock data', () => { + render() + const cards = screen.getAllByTestId('ai-action-card') + expect(cards.length).toBeGreaterThanOrEqual(4) // First mock has 4 actions + }) +}) diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx new file mode 100644 index 00000000..cf6f5424 --- /dev/null +++ b/src/components/AiPanel.tsx @@ -0,0 +1,120 @@ +import { useRef, useEffect } from 'react' +import { Robot, X, PaperPlaneRight } from '@phosphor-icons/react' +import { AiMessage, type AiAction } from './AiMessage' + +export interface AiAgentMessage { + userMessage: string + reasoning?: string + actions: AiAction[] + response?: string + isStreaming?: boolean +} + +interface AiPanelProps { + onClose: () => void + onOpenNote?: (path: string) => void +} + +const MOCK_MESSAGES: AiAgentMessage[] = [ + { + userMessage: 'Crea una nota evento per la riunione con Marco domani', + reasoning: "L'utente vuole creare un evento per una riunione con Marco. Devo creare un file in event/, impostare la data corretta (domani = 2026-03-01), e linkare Marco come partecipante.", + actions: [ + { tool: 'vault_context', label: 'Loaded vault context', status: 'done' }, + { tool: 'create_note', label: 'Created: 2026-03-01-meeting-marco.md', path: 'event/2026-03-01-meeting-marco.md', status: 'done' }, + { tool: 'link_notes', label: 'Linked: Marco \u2192 meeting', status: 'done' }, + { tool: 'ui_open_tab', label: 'Opened tab', path: 'event/2026-03-01-meeting-marco.md', status: 'done' }, + ], + response: 'Ho creato la nota evento e linkato Marco come partecipante. La trovi già aperta in un nuovo tab.', + }, + { + userMessage: 'Cerca tutte le note su TypeScript', + actions: [ + { tool: 'search_notes', label: 'Searched: TypeScript', status: 'done' }, + { tool: 'ui_set_filter', label: 'Filtered results', status: 'done' }, + ], + response: 'Ho trovato 12 note che menzionano TypeScript. Ho applicato il filtro nella lista note.', + }, +] + +function PanelHeader({ onClose }: { onClose: () => void }) { + return ( +
+ + + AI + + +
+ ) +} + +function MessageHistory({ messages, onOpenNote }: { + messages: AiAgentMessage[]; onOpenNote?: (path: string) => void +}) { + const endRef = useRef(null) + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages.length]) + + return ( +
+ {messages.map((msg, i) => ( + + ))} +
+
+ ) +} + +function InputBar() { + return ( +
+ + +
+ ) +} + +export function AiPanel({ onClose, onOpenNote }: AiPanelProps) { + return ( + + ) +} diff --git a/src/hooks/useAiActivity.test.ts b/src/hooks/useAiActivity.test.ts new file mode 100644 index 00000000..9dbf29f0 --- /dev/null +++ b/src/hooks/useAiActivity.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useAiActivity } from './useAiActivity' + +let lastWsInstance: MockWebSocket | null = null + +class MockWebSocket { + onmessage: ((event: MessageEvent) => void) | null = null + onerror: (() => void) | null = null + close = vi.fn() + url: string + + constructor(url: string) { + this.url = url + lastWsInstance = this // eslint-disable-line @typescript-eslint/no-this-alias + } +} + +beforeEach(() => { + lastWsInstance = null + vi.stubGlobal('WebSocket', MockWebSocket) + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function sendWsMessage(data: Record) { + lastWsInstance?.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) })) +} + +describe('useAiActivity', () => { + it('initializes with null highlight', () => { + const { result } = renderHook(() => useAiActivity()) + expect(result.current.highlightElement).toBeNull() + expect(result.current.highlightPath).toBeNull() + }) + + it('connects to ws://localhost:9711', () => { + renderHook(() => useAiActivity()) + expect(lastWsInstance).not.toBeNull() + expect(lastWsInstance!.url).toBe('ws://localhost:9711') + }) + + it('sets highlight on ui_action highlight message', () => { + const { result } = renderHook(() => useAiActivity()) + act(() => { + sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'editor', path: '/vault/test.md' }) + }) + expect(result.current.highlightElement).toBe('editor') + expect(result.current.highlightPath).toBe('/vault/test.md') + }) + + it('auto-clears highlight after 800ms', () => { + const { result } = renderHook(() => useAiActivity()) + act(() => { + sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'tab', path: '/vault/note.md' }) + }) + expect(result.current.highlightElement).toBe('tab') + act(() => { vi.advanceTimersByTime(800) }) + expect(result.current.highlightElement).toBeNull() + expect(result.current.highlightPath).toBeNull() + }) + + it('resets timer on repeated highlight messages', () => { + const { result } = renderHook(() => useAiActivity()) + act(() => { + sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'editor' }) + }) + act(() => { vi.advanceTimersByTime(500) }) + // Second message resets the timer + act(() => { + sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'notelist' }) + }) + expect(result.current.highlightElement).toBe('notelist') + act(() => { vi.advanceTimersByTime(500) }) + // Still active — only 500ms since the second message + expect(result.current.highlightElement).toBe('notelist') + act(() => { vi.advanceTimersByTime(300) }) + expect(result.current.highlightElement).toBeNull() + }) + + it('ignores non-highlight messages', () => { + const { result } = renderHook(() => useAiActivity()) + act(() => { + sendWsMessage({ type: 'ui_action', action: 'other_action' }) + }) + expect(result.current.highlightElement).toBeNull() + }) + + it('ignores malformed JSON', () => { + const { result } = renderHook(() => useAiActivity()) + act(() => { + lastWsInstance?.onmessage?.(new MessageEvent('message', { data: 'not json' })) + }) + expect(result.current.highlightElement).toBeNull() + }) + + it('closes WebSocket on unmount', () => { + const { unmount } = renderHook(() => useAiActivity()) + unmount() + expect(lastWsInstance!.close).toHaveBeenCalled() + }) + + it('handles highlight with no path', () => { + const { result } = renderHook(() => useAiActivity()) + act(() => { + sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'properties' }) + }) + expect(result.current.highlightElement).toBe('properties') + expect(result.current.highlightPath).toBeNull() + }) +}) diff --git a/src/hooks/useAiActivity.ts b/src/hooks/useAiActivity.ts new file mode 100644 index 00000000..ba5f01cd --- /dev/null +++ b/src/hooks/useAiActivity.ts @@ -0,0 +1,64 @@ +import { useState, useEffect, useRef } from 'react' + +export type HighlightElement = 'editor' | 'tab' | 'properties' | 'notelist' | null + +export interface AiActivity { + highlightElement: HighlightElement + highlightPath: string | null +} + +const WS_UI_URL = 'ws://localhost:9711' +const HIGHLIGHT_DURATION_MS = 800 + +/** + * Listens on the UI WebSocket bridge (port 9711) for highlight events + * from the AI agent. Sets highlightElement for 800ms then auto-clears. + */ +export function useAiActivity(): AiActivity { + const [highlightElement, setHighlightElement] = useState(null) + const [highlightPath, setHighlightPath] = useState(null) + const timerRef = useRef | null>(null) + + useEffect(() => { + let ws: WebSocket | null = null + let mounted = true + + try { + ws = new WebSocket(WS_UI_URL) + + ws.onmessage = (event) => { + if (!mounted) return + try { + const data = JSON.parse(event.data as string) + if (data.type === 'ui_action' && data.action === 'highlight') { + setHighlightElement(data.element ?? null) + setHighlightPath(data.path ?? null) + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + if (mounted) { + setHighlightElement(null) + setHighlightPath(null) + } + }, HIGHLIGHT_DURATION_MS) + } + } catch { + // Ignore parse errors from malformed messages + } + } + + ws.onerror = () => { + // Silent — UI bridge may not be running + } + } catch { + // WebSocket connection failed — bridge not available + } + + return () => { + mounted = false + ws?.close() + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + return { highlightElement, highlightPath } +}