diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 88e80ad..70911ec 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -10,6 +10,7 @@ import { Bot, Brain, ClipboardList, + Eye, Zap, ChevronLeft, ChevronRight, @@ -35,6 +36,7 @@ const NAV_ITEMS: NavItem[] = [ { to: '/paperclip', label: 'Paperclip', icon: Bot }, { to: '/ai', label: 'IA / Claude', icon: Brain }, { to: '/operations', label: 'Operações', icon: ClipboardList }, + { to: '/sessions', label: 'Espelho', icon: Eye }, ] function useIsMobile() { diff --git a/src/components/sessions/FilterBar.tsx b/src/components/sessions/FilterBar.tsx new file mode 100644 index 0000000..c83dec7 --- /dev/null +++ b/src/components/sessions/FilterBar.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react' + +export interface Filters { + days: number + project: string + tool: string + skill: string + q: string +} + +interface Props { + initial: Filters + projects: string[] + tools: string[] + skills: string[] + onChange: (f: Filters) => void +} + +export function FilterBar({ initial, projects, tools, skills, onChange }: Props) { + const [f, setF] = useState(initial) + + function update(partial: Partial) { + const next = { ...f, ...partial } + setF(next) + onChange(next) + } + + return ( +
+ + + + + + + + + update({ q: e.target.value })} + className="flex-1 min-w-[200px] bg-slate-900 border border-white/10 rounded px-3 py-2 text-sm" + /> +
+ ) +} diff --git a/src/components/sessions/SessionRow.tsx b/src/components/sessions/SessionRow.tsx new file mode 100644 index 0000000..f720cf9 --- /dev/null +++ b/src/components/sessions/SessionRow.tsx @@ -0,0 +1,55 @@ +import { Link } from 'react-router-dom' +import type { SessionMeta } from '../../../api/types/session' + +function formatDuration(sec: number | null): string { + if (!sec) return '—' + if (sec < 60) return `${sec}s` + if (sec < 3600) return `${Math.round(sec / 60)}min` + return `${Math.floor(sec / 3600)}h${Math.round((sec % 3600) / 60)}m` +} + +function outcomeIcon(o: SessionMeta['outcome']): string { + switch (o) { + case 'completed': + return '✓' + case 'error': + return '✗' + case 'interrupted': + return '⚠' + default: + return '?' + } +} + +interface Props { + session: SessionMeta +} + +export function SessionRow({ session }: Props) { + const when = new Date(session.started_at).toLocaleString('pt-PT', { dateStyle: 'short', timeStyle: 'short' }) + return ( + + {when} + {session.project_slug} + + + {session.first_prompt?.slice(0, 80) ?? '—'} + {(session.first_prompt?.length ?? 0) > 80 ? '…' : ''} + + + {formatDuration(session.duration_sec)} + {session.event_count} + {session.tool_calls} + +
+ {session.skills_invoked.slice(0, 2).map((s) => ( + + {s} + + ))} +
+ + {outcomeIcon(session.outcome)} + + ) +} diff --git a/src/lib/api/sessions.ts b/src/lib/api/sessions.ts new file mode 100644 index 0000000..85fa11d --- /dev/null +++ b/src/lib/api/sessions.ts @@ -0,0 +1,36 @@ +import type { SessionMeta, SessionEvent } from '../../../api/types/session' + +export interface ListParams { + days?: number + project?: string + tool?: string + skill?: string + q?: string + limit?: number + offset?: number +} + +export interface ListResponse { + total: number + items: SessionMeta[] +} + +const API_BASE = import.meta.env.VITE_API_BASE ?? '' + +function buildQuery(params: Record): string { + const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '' && v !== null) + if (entries.length === 0) return '' + return '?' + new URLSearchParams(entries as [string, string][]).toString() +} + +export async function listSessions(params: ListParams): Promise { + const res = await fetch(`${API_BASE}/api/sessions${buildQuery(params as Record)}`) + if (!res.ok) throw new Error(`listSessions failed: ${res.status}`) + return res.json() +} + +export async function getSession(id: string): Promise<{ meta: SessionMeta; events: SessionEvent[] }> { + const res = await fetch(`${API_BASE}/api/sessions/${encodeURIComponent(id)}`) + if (!res.ok) throw new Error(`getSession failed: ${res.status}`) + return res.json() +} diff --git a/src/main.tsx b/src/main.tsx index 4483a20..463efa0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,6 +11,8 @@ import N8nMonitor from './pages/N8nMonitor.tsx' import Paperclip from './pages/Paperclip.tsx' import AiOverview from './pages/AiOverview.tsx' import Operations from './pages/Operations.tsx' +import Sessions from './pages/Sessions.tsx' +import SessionDetail from './pages/SessionDetail.tsx' import Layout from './components/Layout.tsx' import { oidcConfig } from './auth/config.ts' import { AuthWrapper } from './auth/AuthWrapper.tsx' @@ -30,6 +32,8 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> + } /> } /> diff --git a/src/pages/SessionDetail.tsx b/src/pages/SessionDetail.tsx new file mode 100644 index 0000000..f92514b --- /dev/null +++ b/src/pages/SessionDetail.tsx @@ -0,0 +1,3 @@ +export default function SessionDetail() { + return
Em construção — Task 7.
+} diff --git a/src/pages/Sessions.tsx b/src/pages/Sessions.tsx new file mode 100644 index 0000000..c2cac3f --- /dev/null +++ b/src/pages/Sessions.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from 'react' +import { listSessions, type ListResponse } from '../lib/api/sessions' +import { SessionRow } from '../components/sessions/SessionRow' +import { FilterBar, type Filters } from '../components/sessions/FilterBar' + +const PAGE_SIZE = 50 + +export default function Sessions() { + const [filters, setFilters] = useState({ days: 7, project: '', tool: '', skill: '', q: '' }) + const [data, setData] = useState(null) + const [offset, setOffset] = useState(0) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + listSessions({ + days: filters.days, + project: filters.project || undefined, + tool: filters.tool || undefined, + skill: filters.skill || undefined, + q: filters.q || undefined, + limit: PAGE_SIZE, + offset, + }) + .then((r) => { + if (!cancelled) setData(r) + }) + .catch((e: Error) => { + if (!cancelled) setError(e.message) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [filters, offset]) + + const { projects, tools, skills } = useMemo(() => { + const p = new Set() + const t = new Set() + const s = new Set() + data?.items.forEach((it) => { + p.add(it.project_slug) + it.tools_used.forEach((x) => t.add(x)) + it.skills_invoked.forEach((x) => s.add(x)) + }) + return { projects: [...p].sort(), tools: [...t].sort(), skills: [...s].sort() } + }, [data]) + + return ( +
+
+

Espelho — Sessões Claude

+

Replay de sessões para observar comportamento real.

+
+ + { + setFilters(f) + setOffset(0) + }} + /> + + {error &&
{error}
} + +
+ + + + + + + + + + + + + + + {loading && ( + + + + )} + {!loading && data?.items.length === 0 && ( + + + + )} + {data?.items.map((s) => )} + +
InícioProjectoPromptDuraçãoEventosToolsSkillsOK
+ A carregar… +
+ Sem sessões para estes filtros. +
+
+ +
+ + {data ? `${offset + 1}–${Math.min(offset + PAGE_SIZE, data.total)} de ${data.total}` : ''} + +
+ + +
+
+
+ ) +}