diff --git a/package-lock.json b/package-lock.json index 8fe1f89..18060d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6757,6 +6757,13 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "dev": true, + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -11091,6 +11098,7 @@ "@wxt-dev/module-react": "^1.1.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "idb": "^8.0.3", "lucide-react": "^0.563.0", "motion": "^12.34.0", "next-themes": "^0.4.6", diff --git a/packages/extension/package.json b/packages/extension/package.json index d02c9a5..367eda2 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -23,6 +23,7 @@ "@wxt-dev/module-react": "^1.1.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "idb": "^8.0.3", "lucide-react": "^0.563.0", "motion": "^12.34.0", "next-themes": "^0.4.6", diff --git a/packages/extension/src/entrypoints/sidepanel/App.tsx b/packages/extension/src/entrypoints/sidepanel/App.tsx index dbfcfa0..9ce13ad 100644 --- a/packages/extension/src/entrypoints/sidepanel/App.tsx +++ b/packages/extension/src/entrypoints/sidepanel/App.tsx @@ -1,4 +1,4 @@ -import { Send, Settings, Square } from 'lucide-react' +import { History, Send, Settings, Square } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' @@ -8,20 +8,47 @@ import { InputGroupButton, InputGroupTextarea, } from '@/components/ui/input-group' +import { saveSession } from '@/lib/db' import { useAgent } from '../../agent/useAgent' import { ConfigPanel } from './components/ConfigPanel' +import { HistoryDetail } from './components/HistoryDetail' +import { HistoryList } from './components/HistoryList' import { ActivityCard, EventCard } from './components/cards' import { EmptyState, Logo, StatusDot } from './components/misc' +type View = + | { name: 'chat' } + | { name: 'config' } + | { name: 'history' } + | { name: 'history-detail'; sessionId: string } + export default function App() { - const [showConfig, setShowConfig] = useState(false) + const [view, setView] = useState({ name: 'chat' }) const [inputValue, setInputValue] = useState('') const historyRef = useRef(null) const textareaRef = useRef(null) const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent() + // Persist session when task finishes + const prevStatusRef = useRef(status) + useEffect(() => { + const prev = prevStatusRef.current + prevStatusRef.current = status + + if ( + prev === 'running' && + (status === 'completed' || status === 'error') && + history.length > 0 && + currentTask + ) { + saveSession({ task: currentTask, history, status }).catch((err) => + console.error('[SidePanel] Failed to save session:', err) + ) + } + }, [status, history, currentTask]) + // Auto-scroll to bottom on new events useEffect(() => { if (historyRef.current) { @@ -56,19 +83,36 @@ export default function App() { } } - if (showConfig) { + // --- View routing --- + + if (view.name === 'config') { return ( { await configure(newConfig) - setShowConfig(false) + setView({ name: 'chat' }) }} - onClose={() => setShowConfig(false)} + onClose={() => setView({ name: 'chat' })} /> ) } + if (view.name === 'history') { + return ( + setView({ name: 'history-detail', sessionId: id })} + onBack={() => setView({ name: 'chat' })} + /> + ) + } + + if (view.name === 'history-detail') { + return setView({ name: 'history' })} /> + } + + // --- Chat view --- + const isRunning = status === 'running' const showEmptyState = !currentTask && history.length === 0 && !isRunning @@ -85,7 +129,15 @@ export default function App() { + + History + + + {/* Task */} +
+
Task
+
+ {session.task} +
+
+ + {/* Events (read-only) */} +
+ {session.history.map((event, index) => ( + // eslint-disable-next-line react-x/no-array-index-key + + ))} +
+ + ) +} diff --git a/packages/extension/src/entrypoints/sidepanel/components/HistoryList.tsx b/packages/extension/src/entrypoints/sidepanel/components/HistoryList.tsx new file mode 100644 index 0000000..c13bae2 --- /dev/null +++ b/packages/extension/src/entrypoints/sidepanel/components/HistoryList.tsx @@ -0,0 +1,119 @@ +import { ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db' + +function timeAgo(ts: number): string { + const seconds = Math.floor((Date.now() - ts) / 1000) + if (seconds < 60) return 'just now' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +export function HistoryList({ + onSelect, + onBack, +}: { + onSelect: (id: string) => void + onBack: () => void +}) { + const [sessions, setSessions] = useState([]) + const [loading, setLoading] = useState(true) + + const load = useCallback(async () => { + setSessions(await listSessions()) + setLoading(false) + }, []) + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + load() + }, [load]) + + const handleDelete = async (e: React.MouseEvent, id: string) => { + e.stopPropagation() + await deleteSession(id) + setSessions((prev) => prev.filter((s) => s.id !== id)) + } + + return ( +
+ {/* Header */} +
+ + History + {sessions.length > 0 && ( + + )} +
+ + {/* List */} +
+ {loading && ( +
+ Loading... +
+ )} + + {!loading && sessions.length === 0 && ( +
+ No history yet +
+ )} + + {sessions.map((session) => ( +
onSelect(session.id)} + onKeyDown={(e) => e.key === 'Enter' && onSelect(session.id)} + className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group" + > + {/* Status icon */} + {session.status === 'completed' ? ( + + ) : ( + + )} + + {/* Content */} +
+

{session.task}

+

+ {timeAgo(session.createdAt)} ยท {session.history.length} steps +

+
+ + {/* Delete */} + +
+ ))} +
+
+ ) +} diff --git a/packages/extension/src/lib/db.ts b/packages/extension/src/lib/db.ts new file mode 100644 index 0000000..9e520a8 --- /dev/null +++ b/packages/extension/src/lib/db.ts @@ -0,0 +1,70 @@ +import type { HistoricalEvent } from '@page-agent/core' +import { type DBSchema, type IDBPDatabase, openDB } from 'idb' + +const DB_NAME = 'page-agent-ext' +const DB_VERSION = 1 + +export interface SessionRecord { + id: string + task: string + history: HistoricalEvent[] + status: 'completed' | 'error' + createdAt: number +} + +interface PageAgentDB extends DBSchema { + sessions: { + key: string + value: SessionRecord + indexes: { 'by-created': number } + } +} + +let dbPromise: Promise> | null = null + +function getDB() { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + const store = db.createObjectStore('sessions', { keyPath: 'id' }) + store.createIndex('by-created', 'createdAt') + }, + }) + } + return dbPromise +} + +export async function saveSession( + session: Omit +): Promise { + const db = await getDB() + const record: SessionRecord = { + ...session, + id: crypto.randomUUID(), + createdAt: Date.now(), + } + await db.put('sessions', record) + return record +} + +/** List sessions, newest first */ +export async function listSessions(): Promise { + const db = await getDB() + const all = await db.getAllFromIndex('sessions', 'by-created') + return all.reverse() +} + +export async function getSession(id: string): Promise { + const db = await getDB() + return db.get('sessions', id) +} + +export async function deleteSession(id: string): Promise { + const db = await getDB() + await db.delete('sessions', id) +} + +export async function clearSessions(): Promise { + const db = await getDB() + await db.clear('sessions') +}