repoburg 1.3.185 → 1.3.188

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/agent-pane/backend/package-lock.json +2359 -0
  2. package/agent-pane/backend/package.json +29 -0
  3. package/agent-pane/backend/src/codex/codex-provider.ts +363 -0
  4. package/agent-pane/backend/src/codex/item-mapper.ts +59 -0
  5. package/agent-pane/backend/src/db/index.ts +91 -0
  6. package/agent-pane/backend/src/db/schema.ts +76 -0
  7. package/agent-pane/backend/src/index.ts +57 -0
  8. package/agent-pane/backend/src/routes/sessions.ts +134 -0
  9. package/agent-pane/backend/src/routes/turns.ts +60 -0
  10. package/agent-pane/backend/src/routes/workspaces.ts +110 -0
  11. package/agent-pane/backend/src/ws/index.ts +43 -0
  12. package/agent-pane/backend/tsconfig.json +23 -0
  13. package/agent-pane/frontend/index.html +18 -0
  14. package/agent-pane/frontend/package-lock.json +1948 -0
  15. package/agent-pane/frontend/package.json +28 -0
  16. package/agent-pane/frontend/src/App.tsx +44 -0
  17. package/agent-pane/frontend/src/AppInitializer.tsx +46 -0
  18. package/agent-pane/frontend/src/api/client.ts +41 -0
  19. package/agent-pane/frontend/src/api/sessions.ts +47 -0
  20. package/agent-pane/frontend/src/api/websocket.ts +149 -0
  21. package/agent-pane/frontend/src/api/workspaces.ts +24 -0
  22. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +50 -0
  23. package/agent-pane/frontend/src/components/CommandInputBar.tsx +42 -0
  24. package/agent-pane/frontend/src/components/CommandPalette.tsx +207 -0
  25. package/agent-pane/frontend/src/components/DiffViewer.tsx +27 -0
  26. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +25 -0
  27. package/agent-pane/frontend/src/components/NewSessionDialog.tsx +84 -0
  28. package/agent-pane/frontend/src/components/RenderArea.tsx +170 -0
  29. package/agent-pane/frontend/src/components/RightPanel.tsx +40 -0
  30. package/agent-pane/frontend/src/components/RuntimeIcon.tsx +16 -0
  31. package/agent-pane/frontend/src/components/SessionTabs.tsx +44 -0
  32. package/agent-pane/frontend/src/components/StatusBar.tsx +68 -0
  33. package/agent-pane/frontend/src/components/StatusDot.tsx +13 -0
  34. package/agent-pane/frontend/src/components/Toast.tsx +80 -0
  35. package/agent-pane/frontend/src/components/TurnContent.tsx +294 -0
  36. package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +350 -0
  37. package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +81 -0
  38. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +25 -0
  39. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +82 -0
  40. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +45 -0
  41. package/agent-pane/frontend/src/main.tsx +10 -0
  42. package/agent-pane/frontend/src/store/store.ts +635 -0
  43. package/agent-pane/frontend/src/styles/app.css +365 -0
  44. package/agent-pane/frontend/src/styles/tokens.css +62 -0
  45. package/agent-pane/frontend/src/types/index.ts +153 -0
  46. package/agent-pane/frontend/tsconfig.json +22 -0
  47. package/agent-pane/frontend/vite.config.ts +16 -0
  48. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  49. package/package.json +2 -1
@@ -0,0 +1,27 @@
1
+ import type { DiffLine } from '../types/index.ts';
2
+
3
+ interface Props {
4
+ lines: DiffLine[];
5
+ }
6
+
7
+ export default function DiffViewer({ lines }: Props) {
8
+ return (
9
+ <div className="diff-body">
10
+ {lines.map((line, i) => {
11
+ const cls =
12
+ line.type === 'add' ? 'diff-line-add' :
13
+ line.type === 'del' ? 'diff-line-del' : 'diff-line-ctx';
14
+ const sign = line.type === 'add' ? '+' : line.type === 'del' ? '−' : ' ';
15
+
16
+ return (
17
+ <div key={i} className={`diff-line ${cls}`}>
18
+ <span className="diff-line-num">{line.oldLine ?? ''}</span>
19
+ <span className="diff-line-num">{line.newLine ?? ''}</span>
20
+ <span className="diff-sign">{sign}</span>
21
+ <span className="diff-line-content">{line.content}</span>
22
+ </div>
23
+ );
24
+ })}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,25 @@
1
+ import WorkspaceSidebar from './WorkspaceSidebar.tsx';
2
+ import SessionTabs from './SessionTabs.tsx';
3
+ import RenderArea from './RenderArea.tsx';
4
+ import CommandInputBar from './CommandInputBar.tsx';
5
+ import RightPanel from './RightPanel.tsx';
6
+ import StatusBar from './StatusBar.tsx';
7
+
8
+ export default function IdeWorkspace() {
9
+ return (
10
+ <div className="ide-root">
11
+ <div className="ide-layout">
12
+ <WorkspaceSidebar />
13
+ <div className="ide-center">
14
+ <SessionTabs />
15
+ <div className="ide-render">
16
+ <RenderArea />
17
+ </div>
18
+ <CommandInputBar />
19
+ </div>
20
+ <RightPanel />
21
+ </div>
22
+ <StatusBar />
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,84 @@
1
+ import { useState } from 'react';
2
+ import { useStore } from '../store/store.ts';
3
+ import { X } from 'lucide-react';
4
+
5
+ export default function NewSessionDialog() {
6
+ const { showNewSessionDialog, setShowNewSessionDialog, createSession } = useStore();
7
+ const [title, setTitle] = useState('');
8
+ const [model, setModel] = useState('');
9
+ const [cwd, setCwd] = useState('');
10
+
11
+ if (!showNewSessionDialog) return null;
12
+
13
+ function handleSubmit() {
14
+ createSession({
15
+ title: title.trim() || undefined,
16
+ model: model.trim() || undefined,
17
+ cwd: cwd.trim() || undefined,
18
+ runtime_type: 'codex',
19
+ });
20
+ setTitle('');
21
+ setModel('');
22
+ setCwd('');
23
+ }
24
+
25
+ function handleKeyDown(e: React.KeyboardEvent) {
26
+ if (e.key === 'Escape') {
27
+ setShowNewSessionDialog(false);
28
+ }
29
+ }
30
+
31
+ return (
32
+ <div className="dialog-overlay" onClick={() => setShowNewSessionDialog(false)}>
33
+ <div className="dialog" onClick={(e) => e.stopPropagation()} onKeyDown={handleKeyDown}>
34
+ <div className="dialog-header">
35
+ <span className="dialog-title">New Session</span>
36
+ <button className="dialog-close" onClick={() => setShowNewSessionDialog(false)}>
37
+ <X size={16} />
38
+ </button>
39
+ </div>
40
+ <div className="dialog-body">
41
+ <label className="dialog-label">
42
+ Title (optional)
43
+ <input
44
+ className="dialog-input"
45
+ type="text"
46
+ value={title}
47
+ onChange={(e) => setTitle(e.target.value)}
48
+ placeholder="e.g. refactor auth module"
49
+ autoFocus
50
+ />
51
+ </label>
52
+ <label className="dialog-label">
53
+ Model (optional)
54
+ <input
55
+ className="dialog-input"
56
+ type="text"
57
+ value={model}
58
+ onChange={(e) => setModel(e.target.value)}
59
+ placeholder="e.g. gpt-5 (default: Codex picks)"
60
+ />
61
+ </label>
62
+ <label className="dialog-label">
63
+ Working Directory (optional)
64
+ <input
65
+ className="dialog-input"
66
+ type="text"
67
+ value={cwd}
68
+ onChange={(e) => setCwd(e.target.value)}
69
+ placeholder="e.g. /Users/me/projects/my-app"
70
+ />
71
+ </label>
72
+ </div>
73
+ <div className="dialog-footer">
74
+ <button className="dialog-btn-cancel" onClick={() => setShowNewSessionDialog(false)}>
75
+ Cancel
76
+ </button>
77
+ <button className="dialog-btn-create" onClick={handleSubmit}>
78
+ Create
79
+ </button>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,170 @@
1
+ import { useStore } from '../store/store.ts';
2
+ import TurnContent from './TurnContent.tsx';
3
+ import type { Item } from '../types/index.ts';
4
+ import { memo, useEffect, useRef } from 'react';
5
+
6
+ function RenderAreaBase() {
7
+ const { activeSessionId, sessionDetails, activeTurnIndex, activeRightTab, changesScope, submittingPrompt, loading, selectedChangePath, setSelectedChangePath } = useStore();
8
+
9
+ const detail = activeSessionId ? sessionDetails[activeSessionId] : null;
10
+ const turns = detail?.turns ?? [];
11
+ const renderScrollRef = useRef<HTMLDivElement>(null);
12
+
13
+ // F4: Scroll to selected change path when it changes
14
+ useEffect(() => {
15
+ if (!selectedChangePath || activeRightTab !== 'changes') return;
16
+ const timer = setTimeout(() => {
17
+ const sanitized = selectedChangePath.replace(/[^a-zA-Z0-9]/g, '-');
18
+ const el = document.getElementById(`diff-${sanitized}`);
19
+ if (el) {
20
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
21
+ el.classList.add('diff-highlight');
22
+ setTimeout(() => el.classList.remove('diff-highlight'), 1500);
23
+ }
24
+ setSelectedChangePath(null);
25
+ }, 50);
26
+ return () => clearTimeout(timer);
27
+ }, [selectedChangePath, activeRightTab, setSelectedChangePath]);
28
+
29
+ // When Changes tab is active, render file changes from items
30
+ if (activeRightTab === 'changes') {
31
+ if (!detail) {
32
+ return (
33
+ <div className="render-scroll">
34
+ <div className="empty-state">No session selected</div>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ const allChanges: Array<{ path: string; kind: string; turnIdx: number }> = [];
40
+ turns.forEach((t, tIdx) => {
41
+ t.items.forEach((item: Item) => {
42
+ if (item.type === 'file_change' && item.data?.changes) {
43
+ const changes = item.data.changes as Array<{ path: string; kind: string }>;
44
+ changes.forEach((c) => allChanges.push({ path: c.path, kind: c.kind, turnIdx: tIdx }));
45
+ }
46
+ });
47
+ });
48
+
49
+ let entries = allChanges;
50
+ if (changesScope === 'turngroup') {
51
+ entries = allChanges.filter((c) => c.turnIdx === activeTurnIndex);
52
+ }
53
+
54
+ const seen = new Map<string, { path: string; kind: string }>();
55
+ for (const c of entries) seen.set(c.path, { path: c.path, kind: c.kind });
56
+ const deduped = Array.from(seen.values());
57
+
58
+ if (deduped.length === 0) {
59
+ return (
60
+ <div className="render-scroll">
61
+ <div className="render-content">
62
+ <p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No file changes in this scope.</p>
63
+ </div>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <div className="render-scroll" ref={renderScrollRef}>
70
+ <div className="render-content">
71
+ <div style={{ marginBottom: '20px' }}>
72
+ <span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--text)' }}>
73
+ {changesScope === 'session' ? 'All Session Changes' : 'Current Turn Changes'}
74
+ </span>
75
+ <span style={{ fontSize: '12px', color: 'var(--text-muted)', marginLeft: '8px' }}>
76
+ {deduped.length} file{deduped.length > 1 ? 's' : ''}
77
+ </span>
78
+ </div>
79
+ {deduped.map((c) => {
80
+ const fileName = c.path.split('/').pop() || c.path;
81
+ const dir = c.path.replace('/' + fileName, '');
82
+ const statusLabel = c.kind === 'add' ? 'Added' : c.kind === 'delete' ? 'Deleted' : 'Modified';
83
+ const statusClass = c.kind === 'add' ? 'fc-add' : c.kind === 'delete' ? 'fc-del' : 'fc-mod';
84
+ const statusChar = c.kind === 'add' ? 'A' : c.kind === 'delete' ? 'D' : 'M';
85
+ const sanitizedId = c.path.replace(/[^a-zA-Z0-9]/g, '-');
86
+
87
+ return (
88
+ <div key={c.path}>
89
+ <div className="diff-file-header" id={`diff-${sanitizedId}`}>
90
+ <span className={`diff-file-status ${statusClass}`}>{statusChar}</span>
91
+ <span className="diff-file-name">
92
+ {dir && <span style={{ color: 'var(--text-muted)' }}>{dir}/</span>}
93
+ {fileName}
94
+ </span>
95
+ <span className="diff-file-stats">
96
+ <span className={`diff-file-badge ${statusClass}`}>{statusLabel}</span>
97
+ </span>
98
+ </div>
99
+ <p style={{ color: 'var(--text-muted)', fontSize: '12px', fontStyle: 'italic', padding: '8px 16px' }}>
100
+ Diff content not available (Codex SDK limitation).
101
+ </p>
102
+ </div>
103
+ );
104
+ })}
105
+ </div>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ // F2: Skeleton loading when fetching session detail for the first time
111
+ if (loading && !detail) {
112
+ return (
113
+ <div className="render-scroll">
114
+ <div className="skeleton-bars">
115
+ <div className="skeleton-bar" />
116
+ <div className="skeleton-bar" />
117
+ <div className="skeleton-bar" />
118
+ <div className="skeleton-bar" />
119
+ <div className="skeleton-bar" />
120
+ </div>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ if (!detail) {
126
+ return (
127
+ <div className="render-scroll">
128
+ <div className="empty-state">No session selected</div>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ // Render ALL turns, scrollable
134
+ return (
135
+ <div className="render-scroll" ref={renderScrollRef}>
136
+ {/* Submitting prompt — shown at top while waiting for API response */}
137
+ {submittingPrompt && (
138
+ <div className="turn-block">
139
+ <div className="turn-group">
140
+ <div className="render-content">
141
+ <div className="turn-header">
142
+ <span className="turn-header-icon" style={{ background: 'var(--amber)', color: '#fff' }}>U</span>
143
+ <span className="turn-header-title">You</span>
144
+ </div>
145
+ <p className="user-message">{submittingPrompt}</p>
146
+ </div>
147
+ <div className="render-content">
148
+ <p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Sending…</p>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ )}
153
+
154
+ {turns.length === 0 && !submittingPrompt && (
155
+ <div className="empty-state">
156
+ No turns yet. Send a message below to start.
157
+ </div>
158
+ )}
159
+
160
+ {turns.map((turn, idx) => (
161
+ <div key={turn.id} id={`turn-${idx}`} className="turn-block">
162
+ <TurnContent turn={turn} />
163
+ </div>
164
+ ))}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ const RenderArea = memo(RenderAreaBase);
170
+ export default RenderArea;
@@ -0,0 +1,40 @@
1
+ import { useStore } from '../store/store.ts';
2
+ import TurnsPanel from './panels/TurnsPanel.tsx';
3
+ import MetricsPanel from './panels/MetricsPanel.tsx';
4
+ import ChangesPanel from './panels/ChangesPanel.tsx';
5
+ import LogsPanel from './panels/LogsPanel.tsx';
6
+ import type { RightTab } from '../types/index.ts';
7
+
8
+ const TABS: { id: RightTab; label: string }[] = [
9
+ { id: 'turns', label: 'Turns' },
10
+ { id: 'metrics', label: 'Metrics' },
11
+ { id: 'changes', label: 'Changes' },
12
+ { id: 'logs', label: 'Logs' },
13
+ ];
14
+
15
+ export default function RightPanel() {
16
+ const { activeRightTab, switchRightTab } = useStore();
17
+
18
+ return (
19
+ <div className="ide-right">
20
+ <div className="right-tabs">
21
+ {TABS.map((tab) => (
22
+ <div
23
+ key={tab.id}
24
+ className={`right-tab ${activeRightTab === tab.id ? 'active' : ''}`}
25
+ onClick={() => switchRightTab(tab.id)}
26
+ >
27
+ {tab.label}
28
+ </div>
29
+ ))}
30
+ </div>
31
+
32
+ <div className="right-content">
33
+ {activeRightTab === 'turns' && <TurnsPanel />}
34
+ {activeRightTab === 'metrics' && <MetricsPanel />}
35
+ {activeRightTab === 'changes' && <ChangesPanel />}
36
+ {activeRightTab === 'logs' && <LogsPanel />}
37
+ </div>
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,16 @@
1
+ interface Props {
2
+ iconClass: string;
3
+ iconText: string;
4
+ size?: number;
5
+ }
6
+
7
+ export default function RuntimeIcon({ iconClass, iconText, size = 20 }: Props) {
8
+ return (
9
+ <span
10
+ className={`runtime-icon ${iconClass}`}
11
+ style={size !== 20 ? { width: size, height: size, fontSize: size * 0.45 } : undefined}
12
+ >
13
+ {iconText}
14
+ </span>
15
+ );
16
+ }
@@ -0,0 +1,44 @@
1
+ import { useStore } from '../store/store.ts';
2
+ import { statusToDot, runtimeIcon } from '../types/index.ts';
3
+ import StatusDot from './StatusDot.tsx';
4
+ import RuntimeIcon from './RuntimeIcon.tsx';
5
+ import { X, Plus } from 'lucide-react';
6
+
7
+ export default function SessionTabs() {
8
+ const { openTabs, activeSessionId, sessions, selectSession, closeTab, setShowNewSessionDialog } = useStore();
9
+
10
+ return (
11
+ <div className="ide-tabs">
12
+ {openTabs.map((sid) => {
13
+ const s = sessions.find((x) => x.id === sid);
14
+ if (!s) return null;
15
+ const dot = statusToDot(s.status, s.is_running === 1, s.has_unreviewed === 1, s.lifecycle);
16
+ const isActive = activeSessionId === sid;
17
+ const ri = runtimeIcon(s.runtime_type);
18
+ return (
19
+ <div
20
+ key={sid}
21
+ className={`ide-tab ${isActive ? 'active' : ''}`}
22
+ onClick={() => selectSession(sid)}
23
+ >
24
+ <RuntimeIcon iconClass={ri.iconClass} iconText={ri.iconText} size={14} />
25
+ {s.title || sid.slice(0, 8)}
26
+ {dot && <StatusDot status={dot} />}
27
+ <span
28
+ className="ide-tab-close"
29
+ onClick={(e) => {
30
+ e.stopPropagation();
31
+ closeTab(sid);
32
+ }}
33
+ >
34
+ <X size={12} />
35
+ </span>
36
+ </div>
37
+ );
38
+ })}
39
+ <div className="ide-tab-add" title="New Session" onClick={() => setShowNewSessionDialog(true)}>
40
+ <Plus size={14} />
41
+ </div>
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,68 @@
1
+ import { useStore } from '../store/store.ts';
2
+ import { statusToDot, statusToLabel, formatDuration } from '../types/index.ts';
3
+ import StatusDot from './StatusDot.tsx';
4
+ import { Zap, Command } from 'lucide-react';
5
+
6
+ export default function StatusBar() {
7
+ const { activeSessionId, sessions, sessionDetails, wsConnected } = useStore();
8
+ const session = sessions.find((s) => s.id === activeSessionId);
9
+ const detail = activeSessionId ? sessionDetails[activeSessionId] : null;
10
+
11
+ if (!session) {
12
+ return (
13
+ <div className="ide-statusbar">
14
+ <div className="sb-item"><Zap size={14} /> AgentPane</div>
15
+ <div className="sb-divider">|</div>
16
+ <div className="sb-item" title={wsConnected ? 'WebSocket connected' : 'WebSocket disconnected'}>
17
+ <span className={`status-dot ${wsConnected ? 'done' : 'error'}`} />
18
+ {wsConnected ? 'Live' : 'Offline'}
19
+ </div>
20
+ <div className="sb-spacer" />
21
+ <div className="sb-item"><Command size={14} /> K</div>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ const isRunning = session.is_running === 1;
27
+ const dot = statusToDot(session.status, isRunning, session.has_unreviewed === 1, session.lifecycle);
28
+ const label = statusToLabel(session.status, isRunning, session.has_unreviewed === 1, session.lifecycle);
29
+
30
+ // Calculate duration
31
+ let duration = '—';
32
+ if (detail && detail.turns.length > 0) {
33
+ const firstTurn = detail.turns[0];
34
+ const lastTurn = detail.turns[detail.turns.length - 1];
35
+ const endISO = isRunning ? null : lastTurn.updated_at;
36
+ duration = formatDuration(firstTurn.created_at, endISO);
37
+ }
38
+
39
+ return (
40
+ <div className="ide-statusbar">
41
+ <div className="sb-item"><Zap size={14} /> AgentPane</div>
42
+ <div className="sb-divider">|</div>
43
+ <div className="sb-item">
44
+ <span className={`runtime-icon ic-codex`} style={{ width: 16, height: 16, fontSize: 8 }}>CX</span>
45
+ {session.runtime_type}
46
+ </div>
47
+ {session.model && (
48
+ <>
49
+ <div className="sb-divider">|</div>
50
+ <div className="sb-item">{session.model}</div>
51
+ </>
52
+ )}
53
+ <div className="sb-spacer" />
54
+ <div className="sb-item" title={wsConnected ? 'WebSocket connected' : 'WebSocket disconnected'}>
55
+ <span className={`status-dot ${wsConnected ? 'done' : 'error'}`} />
56
+ {wsConnected ? 'Live' : 'Offline'}
57
+ </div>
58
+ <div className="sb-divider">|</div>
59
+ <div className="sb-item" style={session.status === 'failed' ? { color: 'var(--red)' } : undefined}>
60
+ <StatusDot status={dot} /> {label}
61
+ </div>
62
+ <div className="sb-divider">|</div>
63
+ <div className="sb-item">{duration}</div>
64
+ <div className="sb-divider">|</div>
65
+ <div className="sb-item"><Command size={14} /> K</div>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,13 @@
1
+ interface Props {
2
+ status: string;
3
+ size?: number;
4
+ }
5
+
6
+ export default function StatusDot({ status, size = 6 }: Props) {
7
+ return (
8
+ <span
9
+ className={`status-dot ${status}`}
10
+ style={size !== 6 ? { width: size, height: size } : undefined}
11
+ />
12
+ );
13
+ }
@@ -0,0 +1,80 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useStore } from '../store/store.ts';
3
+ import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Undo2 } from 'lucide-react';
4
+ import type { Toast as ToastType } from '../store/store.ts';
5
+
6
+ const ICONS: Record<ToastType['type'], typeof CheckCircle> = {
7
+ success: CheckCircle,
8
+ error: AlertCircle,
9
+ info: Info,
10
+ warn: AlertTriangle,
11
+ };
12
+
13
+ const AUTO_DISMISS_MS = 4000;
14
+
15
+ function ToastItem({ toast }: { toast: ToastType }) {
16
+ const removeToast = useStore((s) => s.removeToast);
17
+ const Icon = ICONS[toast.type];
18
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
19
+
20
+ // Manage auto-dismiss with hover-to-pause
21
+ useEffect(() => {
22
+ function startTimer() {
23
+ timerRef.current = setTimeout(() => removeToast(toast.id), AUTO_DISMISS_MS);
24
+ }
25
+ startTimer();
26
+ return () => {
27
+ if (timerRef.current) clearTimeout(timerRef.current);
28
+ };
29
+ }, [toast.id, removeToast]);
30
+
31
+ function handleMouseEnter() {
32
+ if (timerRef.current) {
33
+ clearTimeout(timerRef.current);
34
+ timerRef.current = null;
35
+ }
36
+ }
37
+
38
+ function handleMouseLeave() {
39
+ timerRef.current = setTimeout(() => removeToast(toast.id), 2000);
40
+ }
41
+
42
+ return (
43
+ <div
44
+ className={`toast toast-${toast.type}`}
45
+ onMouseEnter={handleMouseEnter}
46
+ onMouseLeave={handleMouseLeave}
47
+ >
48
+ <Icon size={16} />
49
+ <span className="toast-message">{toast.message}</span>
50
+ {toast.undoAction && (
51
+ <button
52
+ className="toast-undo"
53
+ onClick={() => {
54
+ toast.undoAction?.();
55
+ removeToast(toast.id);
56
+ }}
57
+ >
58
+ <Undo2 size={12} /> {toast.undoLabel || 'Undo'}
59
+ </button>
60
+ )}
61
+ <button className="toast-close" onClick={() => removeToast(toast.id)}>
62
+ <X size={14} />
63
+ </button>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ export default function Toast() {
69
+ const toasts = useStore((s) => s.toasts);
70
+
71
+ if (toasts.length === 0) return null;
72
+
73
+ return (
74
+ <div className="toast-container">
75
+ {toasts.map((toast) => (
76
+ <ToastItem key={toast.id} toast={toast} />
77
+ ))}
78
+ </div>
79
+ );
80
+ }