Files
tolaria/src/components/AiPanelChrome.tsx
2026-04-28 14:03:10 +02:00

292 lines
8.2 KiB
TypeScript

import { useEffect, useRef } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { Copy } from 'lucide-react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
import type { AiAgentReadiness } from '../lib/aiAgents'
import type { NoteReference } from '../utils/ai-context'
import type { VaultEntry } from '../types'
interface AiPanelHeaderProps {
agentLabel: string
agentReadiness: AiAgentReadiness
onClose: () => void
onCopyMcpConfig?: () => void
onNewChat: () => void
}
interface AiPanelContextBarProps {
activeEntry: VaultEntry
linkedCount: number
}
interface AiPanelMessageHistoryProps {
agentLabel: string
agentReadiness: AiAgentReadiness
messages: AiAgentMessage[]
isActive: boolean
onOpenNote?: (path: string) => void
onNavigateWikilink?: (target: string) => void
hasContext: boolean
}
interface AiPanelComposerProps {
entries: VaultEntry[]
agentLabel: string
agentReadiness: AiAgentReadiness
input: string
inputRef: React.RefObject<HTMLDivElement | null>
isActive: boolean
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
onUnsupportedAiPaste?: (message: string) => void
}
function getComposerPlaceholder(
agentLabel: string,
agentReadiness: AiAgentReadiness,
): string {
if (agentReadiness === 'checking') {
return 'Checking AI agent availability...'
}
if (agentReadiness === 'missing') {
return `${agentLabel} is not installed. Open AI Agents in Settings.`
}
return `Ask ${agentLabel}`
}
function AiPanelEmptyState({
agentLabel,
agentReadiness,
hasContext,
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReadiness' | 'hasContext'>) {
if (agentReadiness === 'checking') {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
Checking AI agent availability
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
Messages can be sent when the selected agent is ready
</p>
</div>
)
}
if (agentReadiness === 'missing') {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{agentLabel} is not available on this machine
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
Install it or switch the default AI agent in Settings
</p>
</div>
)
}
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{hasContext
? `Ask ${agentLabel} about this note and its linked context`
: `Open a note, then ask ${agentLabel} about it`
}
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
{hasContext
? 'Summarize, find connections, expand ideas'
: 'The AI will use the active note as context'
}
</p>
</div>
)
}
export function AiPanelHeader({
agentLabel,
agentReadiness,
onClose,
onCopyMcpConfig,
onNewChat,
}: AiPanelHeaderProps) {
return (
<div
className="flex shrink-0 items-center border-b border-border"
style={{ height: 52, padding: '0 12px', gap: 8 }}
>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
AI Agent
</span>
<span className="truncate text-[11px] text-muted-foreground">
{agentReadiness === 'checking'
? 'Checking availability'
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
</span>
</div>
{onCopyMcpConfig ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopyMcpConfig}
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
>
<Copy size={15} />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close AI panel"
title="Close AI panel"
>
<X size={16} />
</Button>
</div>
)
}
export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBarProps) {
return (
<div
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
style={{ padding: '6px 12px', gap: 6, fontSize: 11 }}
data-testid="context-bar"
>
<Link size={12} className="shrink-0" />
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
{linkedCount > 0 && (
<span style={{ opacity: 0.6 }}>+ {linkedCount} linked</span>
)}
</div>
)
}
export function AiPanelMessageHistory({
agentLabel,
agentReadiness,
messages,
isActive,
onOpenNote,
onNavigateWikilink,
hasContext,
}: AiPanelMessageHistoryProps) {
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, isActive])
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.length === 0 && !isActive && (
<AiPanelEmptyState
agentLabel={agentLabel}
agentReadiness={agentReadiness}
hasContext={hasContext}
/>
)}
{messages.map((message, index) => (
<AiMessage
key={message.id ?? index}
{...message}
onOpenNote={onOpenNote}
onNavigateWikilink={onNavigateWikilink}
/>
))}
<div ref={endRef} />
</div>
)
}
export function AiPanelComposer({
entries,
agentLabel,
agentReadiness,
input,
inputRef,
isActive,
onChange,
onSend,
onUnsupportedAiPaste,
}: AiPanelComposerProps) {
const composerDisabled = isActive || agentReadiness !== 'ready'
const canSend = !composerDisabled && input.trim().length > 0
const placeholder = getComposerPlaceholder(agentLabel, agentReadiness)
const sendButtonStyle = {
background: canSend ? 'var(--primary)' : 'var(--muted)',
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
borderRadius: 8,
width: 32,
height: 34,
cursor: canSend ? 'pointer' : 'not-allowed',
} as const
return (
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<WikilinkChatInput
entries={entries}
value={input}
onChange={onChange}
onSend={onSend}
onUnsupportedPaste={onUnsupportedAiPaste}
disabled={composerDisabled}
placeholder={placeholder}
inputRef={inputRef}
/>
</div>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={sendButtonStyle}
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
disabled={!canSend}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
)
}