feat: AI agent panel UI — 3-layer panel with action cards and WebSocket activity hook (#149)

* feat: AI agent panel UI — 3-layer panel with action cards and WebSocket activity hook

* style: rustfmt mcp.rs

---------

Co-authored-by: Test <test@test.com>
This commit is contained in:
Luca Rossi
2026-02-28 21:30:47 +01:00
committed by GitHub
parent e1e4b23a55
commit aeb4f640c5
10 changed files with 1066 additions and 17 deletions

View File

@@ -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"
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -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<String, String> {
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<String, String> {
}
/// Insert or update the "laputa" entry in an MCP config file.
fn upsert_mcp_config(
config_path: &Path,
entry: &serde_json::Value,
) -> Result<bool, String> {
fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bool, String> {
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();

View File

@@ -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(<AiActionCard tool="create_note" label="Created test.md" status="done" />)
expect(screen.getByText('Created test.md')).toBeTruthy()
})
it('shows pending spinner', () => {
render(<AiActionCard tool="search_notes" label="Searching..." status="pending" />)
expect(screen.getByTestId('status-pending')).toBeTruthy()
})
it('shows done check', () => {
render(<AiActionCard tool="create_note" label="Created" status="done" />)
expect(screen.getByTestId('status-done')).toBeTruthy()
})
it('shows error icon', () => {
render(<AiActionCard tool="delete_note" label="Failed" status="error" />)
expect(screen.getByTestId('status-error')).toBeTruthy()
})
it('is clickable when path and onOpenNote provided', () => {
const onOpenNote = vi.fn()
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={onOpenNote} />)
fireEvent.click(screen.getByTestId('ai-action-card'))
expect(onOpenNote).toHaveBeenCalledWith('/vault/test.md')
})
it('has button role when clickable', () => {
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={vi.fn()} />)
expect(screen.getByRole('button')).toBeTruthy()
})
it('is not clickable without path', () => {
const onOpenNote = vi.fn()
render(<AiActionCard tool="create_note" label="Created" status="done" onOpenNote={onOpenNote} />)
fireEvent.click(screen.getByTestId('ai-action-card'))
expect(onOpenNote).not.toHaveBeenCalled()
})
it('is not clickable without onOpenNote', () => {
render(<AiActionCard tool="create_note" label="Created" path="/vault/test.md" status="done" />)
const card = screen.getByTestId('ai-action-card')
expect(card.getAttribute('role')).toBeNull()
})
it('uses lighter background for ui_ tools', () => {
render(<AiActionCard tool="ui_open_tab" label="Opened tab" status="done" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.06')
})
it('uses standard background for vault tools', () => {
render(<AiActionCard tool="create_note" label="Created" status="done" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.1')
})
})

View File

@@ -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<string, IconRenderer> = {
create_note: (s) => <PencilSimple size={s} />,
edit_note_frontmatter: (s) => <PencilSimple size={s} />,
append_to_note: (s) => <PencilSimple size={s} />,
search_notes: (s) => <MagnifyingGlass size={s} />,
list_notes: (s) => <MagnifyingGlass size={s} />,
link_notes: (s) => <Link size={s} />,
delete_note: (s) => <Trash size={s} />,
vault_context: (s) => <ChartBar size={s} />,
ui_open_note: (s) => <Eye size={s} />,
ui_open_tab: (s) => <Eye size={s} />,
ui_highlight: (s) => <Sparkle size={s} />,
ui_set_filter: (s) => <Sparkle size={s} />,
}
const DEFAULT_ICON: IconRenderer = (s) => <PencilSimple size={s} />
function StatusIndicator({ status }: { status: AiActionStatus }) {
if (status === 'pending') {
return <CircleNotch size={14} className="ai-spin text-muted-foreground" data-testid="status-pending" />
}
if (status === 'done') {
return <CheckCircle size={14} weight="fill" style={{ color: 'var(--accent-green)' }} data-testid="status-done" />
}
return <XCircle size={14} weight="fill" style={{ color: 'var(--destructive)' }} data-testid="status-error" />
}
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 (
<div
className="flex items-center gap-2 rounded"
style={{
padding: '6px 10px',
fontSize: 12,
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
cursor: isClickable ? 'pointer' : 'default',
}}
onClick={isClickable ? () => onOpenNote(path) : undefined}
role={isClickable ? 'button' : undefined}
data-testid="ai-action-card"
>
<span className="shrink-0 text-muted-foreground">{renderIcon(14)}</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
</div>
)
}

View File

@@ -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(<AiMessage userMessage="Hello AI" actions={[]} />)
expect(screen.getByText('Hello AI')).toBeTruthy()
})
it('renders response text', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the answer" />)
expect(screen.getByText('Here is the answer')).toBeTruthy()
})
it('shows undo button with response', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Done" />)
expect(screen.getByTestId('undo-button')).toBeTruthy()
})
it('renders reasoning toggle collapsed by default', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('expands reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
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(<AiMessage userMessage="Ask" reasoning="Thinking..." actions={[]} />)
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(
<AiMessage
userMessage="Do something"
actions={[
{ tool: 'create_note', label: 'Created test.md', status: 'done' },
{ tool: 'search_notes', label: 'Searched', status: 'pending' },
]}
/>,
)
expect(screen.getAllByTestId('ai-action-card')).toHaveLength(2)
})
it('passes onOpenNote to action cards', () => {
const onOpenNote = vi.fn()
render(
<AiMessage
userMessage="Do"
actions={[{ tool: 'create_note', label: 'Open', path: '/vault/note.md', status: 'done' }]}
onOpenNote={onOpenNote}
/>,
)
fireEvent.click(screen.getByTestId('ai-action-card'))
expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md')
})
it('shows streaming indicator when streaming without response', () => {
const { container } = render(
<AiMessage userMessage="Ask" actions={[]} isStreaming />,
)
expect(container.querySelector('.typing-dot')).toBeTruthy()
})
it('does not show streaming indicator when response is present', () => {
const { container } = render(
<AiMessage userMessage="Ask" actions={[]} response="Done" isStreaming />,
)
expect(container.querySelector('.typing-dot')).toBeNull()
})
it('does not render reasoning block when no reasoning', () => {
render(<AiMessage userMessage="Ask" actions={[]} />)
expect(screen.queryByTestId('reasoning-toggle')).toBeNull()
})
it('does not render actions when empty array', () => {
render(<AiMessage userMessage="Ask" actions={[]} />)
expect(screen.queryByTestId('ai-action-card')).toBeNull()
})
})

View File

@@ -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 (
<div className="flex justify-end" style={{ marginBottom: 8 }}>
<div
style={{
background: 'var(--muted)',
color: 'var(--foreground)',
borderRadius: '12px 12px 2px 12px',
maxWidth: '85%',
padding: '8px 12px',
fontSize: 13,
lineHeight: 1.5,
}}
>
{content}
</div>
</div>
)
}
function ReasoningBlock({ text, expanded, onToggle }: {
text: string; expanded: boolean; onToggle: () => void
}) {
return (
<div style={{ marginBottom: 8 }}>
<button
className="flex items-center gap-1.5 w-full border-none bg-transparent cursor-pointer p-0 text-muted-foreground hover:text-foreground transition-colors"
style={{ fontSize: 12, padding: '4px 0' }}
onClick={onToggle}
data-testid="reasoning-toggle"
>
<Brain size={14} />
<span>Reasoning</span>
{expanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
</button>
{expanded && (
<div
className="text-muted-foreground"
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px' }}
data-testid="reasoning-content"
>
{text}
</div>
)}
</div>
)
}
function ActionCardsList({ actions, onOpenNote }: {
actions: AiAction[]; onOpenNote?: (path: string) => void
}) {
return (
<div className="flex flex-col gap-1" style={{ marginBottom: 8 }}>
{actions.map((action, i) => (
<AiActionCard
key={`${action.tool}-${i}`}
tool={action.tool}
label={action.label}
path={action.path}
status={action.status}
onOpenNote={onOpenNote}
/>
))}
</div>
)
}
function ResponseBlock({ text }: { text: string }) {
return (
<div style={{ marginBottom: 4 }}>
<div style={{ fontSize: 13, lineHeight: 1.6 }}>{text}</div>
<button
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
data-testid="undo-button"
>
<ArrowCounterClockwise size={12} />
<span>Undo</span>
</button>
</div>
)
}
function StreamingIndicator() {
return (
<div className="flex items-center gap-2 text-muted-foreground" style={{ fontSize: 12, padding: '4px 0' }}>
<div className="flex gap-1">
<span className="typing-dot" />
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
</div>
</div>
)
}
export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
const [reasoningExpanded, setReasoningExpanded] = useState(false)
return (
<div data-testid="ai-message" style={{ marginBottom: 16 }}>
<UserBubble content={userMessage} />
{reasoning && (
<ReasoningBlock
text={reasoning}
expanded={reasoningExpanded}
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
/>
)}
{actions.length > 0 && <ActionCardsList actions={actions} onOpenNote={onOpenNote} />}
{response && <ResponseBlock text={response} />}
{isStreaming && !response && <StreamingIndicator />}
</div>
)
}

View File

@@ -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(<AiPanel onClose={vi.fn()} />)
expect(screen.getByText('AI')).toBeTruthy()
})
it('renders data-testid ai-panel', () => {
render(<AiPanel onClose={vi.fn()} />)
expect(screen.getByTestId('ai-panel')).toBeTruthy()
})
it('calls onClose when close button is clicked', () => {
const onClose = vi.fn()
render(<AiPanel onClose={onClose} />)
// 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(<AiPanel onClose={vi.fn()} />)
const messages = screen.getAllByTestId('ai-message')
expect(messages.length).toBeGreaterThanOrEqual(2)
})
it('renders user message text from mock data', () => {
render(<AiPanel onClose={vi.fn()} />)
expect(screen.getByText('Crea una nota evento per la riunione con Marco domani')).toBeTruthy()
})
it('renders response text from mock data', () => {
render(<AiPanel onClose={vi.fn()} />)
expect(screen.getByText(/Ho creato la nota evento/)).toBeTruthy()
})
it('renders disabled input bar', () => {
render(<AiPanel onClose={vi.fn()} />)
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(<AiPanel onClose={vi.fn()} onOpenNote={onOpenNote} />)
// 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(<AiPanel onClose={vi.fn()} />)
const cards = screen.getAllByTestId('ai-action-card')
expect(cards.length).toBeGreaterThanOrEqual(4) // First mock has 4 actions
})
})

120
src/components/AiPanel.tsx Normal file
View File

@@ -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 (
<div
className="flex shrink-0 items-center border-b border-border"
style={{ height: 45, padding: '0 12px', gap: 8 }}
>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
AI
</span>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClose}
title="Close AI panel (\u2318I)"
>
<X size={16} />
</button>
</div>
)
}
function MessageHistory({ messages, onOpenNote }: {
messages: AiAgentMessage[]; onOpenNote?: (path: string) => void
}) {
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages.length])
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.map((msg, i) => (
<AiMessage key={i} {...msg} onOpenNote={onOpenNote} />
))}
<div ref={endRef} />
</div>
)
}
function InputBar() {
return (
<div
className="flex shrink-0 items-center gap-2 border-t border-border"
style={{ padding: '8px 12px' }}
>
<input
className="flex-1 border border-border bg-transparent text-muted-foreground"
style={{ fontSize: 13, borderRadius: 8, padding: '8px 10px', outline: 'none', fontFamily: 'inherit' }}
placeholder="Ask the AI agent..."
disabled
/>
<button
className="shrink-0 flex items-center justify-center border-none"
style={{
background: 'var(--muted)',
color: 'var(--muted-foreground)',
borderRadius: 8, width: 32, height: 34,
cursor: 'not-allowed',
}}
disabled
title="Coming in Task 3"
>
<PaperPlaneRight size={16} />
</button>
</div>
)
}
export function AiPanel({ onClose, onOpenNote }: AiPanelProps) {
return (
<aside
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
data-testid="ai-panel"
>
<PanelHeader onClose={onClose} />
<MessageHistory messages={MOCK_MESSAGES} onOpenNote={onOpenNote} />
<InputBar />
</aside>
)
}

View File

@@ -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<string, unknown>) {
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()
})
})

View File

@@ -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<HighlightElement>(null)
const [highlightPath, setHighlightPath] = useState<string | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 }
}