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:
340
design/ai-agent-panel-ui.pen
Normal file
340
design/ai-agent-panel-ui.pen
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
62
src/components/AiActionCard.test.tsx
Normal file
62
src/components/AiActionCard.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
69
src/components/AiActionCard.tsx
Normal file
69
src/components/AiActionCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/components/AiMessage.test.tsx
Normal file
91
src/components/AiMessage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
134
src/components/AiMessage.tsx
Normal file
134
src/components/AiMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/components/AiPanel.test.tsx
Normal file
67
src/components/AiPanel.test.tsx
Normal 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
120
src/components/AiPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
src/hooks/useAiActivity.test.ts
Normal file
115
src/hooks/useAiActivity.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
64
src/hooks/useAiActivity.ts
Normal file
64
src/hooks/useAiActivity.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user