repoburg 1.3.185 → 1.3.186

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 (46) 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 +260 -0
  4. package/agent-pane/backend/src/codex/item-mapper.ts +59 -0
  5. package/agent-pane/backend/src/db/index.ts +67 -0
  6. package/agent-pane/backend/src/db/schema.ts +65 -0
  7. package/agent-pane/backend/src/index.ts +55 -0
  8. package/agent-pane/backend/src/routes/sessions.ts +97 -0
  9. package/agent-pane/backend/src/routes/turns.ts +60 -0
  10. package/agent-pane/backend/src/ws/index.ts +43 -0
  11. package/agent-pane/backend/tsconfig.json +23 -0
  12. package/agent-pane/frontend/index.html +18 -0
  13. package/agent-pane/frontend/package-lock.json +1948 -0
  14. package/agent-pane/frontend/package.json +28 -0
  15. package/agent-pane/frontend/src/App.tsx +44 -0
  16. package/agent-pane/frontend/src/AppInitializer.tsx +46 -0
  17. package/agent-pane/frontend/src/api/client.ts +29 -0
  18. package/agent-pane/frontend/src/api/sessions.ts +31 -0
  19. package/agent-pane/frontend/src/api/websocket.ts +139 -0
  20. package/agent-pane/frontend/src/components/CommandInputBar.tsx +42 -0
  21. package/agent-pane/frontend/src/components/CommandPalette.tsx +196 -0
  22. package/agent-pane/frontend/src/components/DiffViewer.tsx +27 -0
  23. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +25 -0
  24. package/agent-pane/frontend/src/components/NewSessionDialog.tsx +84 -0
  25. package/agent-pane/frontend/src/components/RenderArea.tsx +157 -0
  26. package/agent-pane/frontend/src/components/RightPanel.tsx +40 -0
  27. package/agent-pane/frontend/src/components/RuntimeIcon.tsx +16 -0
  28. package/agent-pane/frontend/src/components/SessionTabs.tsx +41 -0
  29. package/agent-pane/frontend/src/components/StatusBar.tsx +60 -0
  30. package/agent-pane/frontend/src/components/StatusDot.tsx +13 -0
  31. package/agent-pane/frontend/src/components/Toast.tsx +34 -0
  32. package/agent-pane/frontend/src/components/TurnContent.tsx +202 -0
  33. package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +60 -0
  34. package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +77 -0
  35. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +25 -0
  36. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +69 -0
  37. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +84 -0
  38. package/agent-pane/frontend/src/main.tsx +10 -0
  39. package/agent-pane/frontend/src/store/store.ts +492 -0
  40. package/agent-pane/frontend/src/styles/app.css +288 -0
  41. package/agent-pane/frontend/src/styles/tokens.css +62 -0
  42. package/agent-pane/frontend/src/types/index.ts +115 -0
  43. package/agent-pane/frontend/tsconfig.json +22 -0
  44. package/agent-pane/frontend/vite.config.ts +16 -0
  45. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  46. package/package.json +2 -1
@@ -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,157 @@
1
+ import { useStore } from '../store/store.ts';
2
+ import TurnContent from './TurnContent.tsx';
3
+ import type { Item } from '../types/index.ts';
4
+ import { memo } from 'react';
5
+
6
+ function RenderAreaBase() {
7
+ const { activeSessionId, sessionDetails, activeTurnIndex, activeRightTab, changesScope, submittingPrompt } = useStore();
8
+
9
+ const detail = activeSessionId ? sessionDetails[activeSessionId] : null;
10
+ const turns = detail?.turns ?? [];
11
+ const turn = turns[activeTurnIndex];
12
+
13
+ // When Changes tab is active, render file changes from items
14
+ if (activeRightTab === 'changes') {
15
+ if (!detail) {
16
+ return (
17
+ <div className="render-scroll">
18
+ <div className="empty-state">No session selected</div>
19
+ </div>
20
+ );
21
+ }
22
+
23
+ // Collect all file_change items across turns
24
+ const allChanges: Array<{ path: string; kind: string; turnIdx: number }> = [];
25
+ turns.forEach((t, tIdx) => {
26
+ t.items.forEach((item: Item) => {
27
+ if (item.type === 'file_change' && item.data?.changes) {
28
+ const changes = item.data.changes as Array<{ path: string; kind: string }>;
29
+ changes.forEach((c) => allChanges.push({ path: c.path, kind: c.kind, turnIdx: tIdx }));
30
+ }
31
+ });
32
+ });
33
+
34
+ let entries = allChanges;
35
+ if (changesScope === 'turngroup') {
36
+ let start = activeTurnIndex;
37
+ while (start > 0 && turns[start] && false) start--; // no user/agent distinction in real turns
38
+ entries = allChanges.filter((c) => c.turnIdx === activeTurnIndex);
39
+ }
40
+
41
+ // Deduplicate by path (keep last)
42
+ const seen = new Map<string, { path: string; kind: string }>();
43
+ for (const c of entries) {
44
+ seen.set(c.path, { path: c.path, kind: c.kind });
45
+ }
46
+ const deduped = Array.from(seen.values());
47
+
48
+ if (deduped.length === 0) {
49
+ return (
50
+ <div className="render-scroll">
51
+ <div className="render-content">
52
+ <p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No file changes in this scope.</p>
53
+ </div>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ return (
59
+ <div className="render-scroll">
60
+ <div className="render-content">
61
+ <div style={{ marginBottom: '20px' }}>
62
+ <span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--text)' }}>
63
+ {changesScope === 'session' ? 'All Session Changes' : 'Current Turn Changes'}
64
+ </span>
65
+ <span style={{ fontSize: '12px', color: 'var(--text-muted)', marginLeft: '8px' }}>
66
+ {deduped.length} file{deduped.length > 1 ? 's' : ''}
67
+ </span>
68
+ </div>
69
+ {deduped.map((c) => {
70
+ const fileName = c.path.split('/').pop() || c.path;
71
+ const dir = c.path.replace('/' + fileName, '');
72
+ const statusLabel = c.kind === 'add' ? 'Added' : c.kind === 'delete' ? 'Deleted' : 'Modified';
73
+ const statusClass = c.kind === 'add' ? 'fc-add' : c.kind === 'delete' ? 'fc-del' : 'fc-mod';
74
+ const statusChar = c.kind === 'add' ? 'A' : c.kind === 'delete' ? 'D' : 'M';
75
+
76
+ return (
77
+ <div key={c.path}>
78
+ <div className="diff-file-header">
79
+ <span className={`diff-file-status ${statusClass}`}>{statusChar}</span>
80
+ <span className="diff-file-name">
81
+ {dir && <span style={{ color: 'var(--text-muted)' }}>{dir}/</span>}
82
+ {fileName}
83
+ </span>
84
+ <span className="diff-file-stats">
85
+ <span className={`diff-file-badge ${statusClass}`}>{statusLabel}</span>
86
+ </span>
87
+ </div>
88
+ <p style={{ color: 'var(--text-muted)', fontSize: '12px', fontStyle: 'italic', padding: '8px 16px' }}>
89
+ Diff content not available (Codex SDK limitation).
90
+ </p>
91
+ </div>
92
+ );
93
+ })}
94
+ </div>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ // Default: render turn content
100
+ if (!turn) {
101
+ // Show "Sending…" if a prompt was just submitted but turn hasn't been created yet
102
+ if (submittingPrompt) {
103
+ return (
104
+ <div className="render-scroll">
105
+ <div className="turn-group">
106
+ <div className="render-content">
107
+ <div className="turn-header">
108
+ <span className="turn-header-icon" style={{ background: 'var(--amber)', color: '#fff' }}>U</span>
109
+ <span className="turn-header-title">You</span>
110
+ </div>
111
+ <p className="user-message">{submittingPrompt}</p>
112
+ </div>
113
+ <div className="render-content">
114
+ <p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Sending…</p>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div className="render-scroll">
123
+ <div className="empty-state">
124
+ {turns.length === 0 ? 'No turns yet. Send a message below to start.' : 'Select a turn to view content'}
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ // Error / aborted banner
131
+ let banner: React.ReactNode = null;
132
+ if (turn.status === 'failed' && turn.error) {
133
+ banner = (
134
+ <div className="error-banner">
135
+ <span className="banner-icon">!</span>
136
+ <span className="banner-text">{turn.error}</span>
137
+ </div>
138
+ );
139
+ } else if (turn.status === 'aborted') {
140
+ banner = (
141
+ <div className="aborted-banner">
142
+ <span className="banner-icon">■</span>
143
+ <span className="banner-text">Turn was cancelled by user.</span>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ return (
149
+ <div className="render-scroll">
150
+ {banner}
151
+ <TurnContent turn={turn} />
152
+ </div>
153
+ );
154
+ }
155
+
156
+ const RenderArea = memo(RenderAreaBase);
157
+ 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,41 @@
1
+ import { useStore } from '../store/store.ts';
2
+ import { statusToDot } from '../types/index.ts';
3
+ import StatusDot from './StatusDot.tsx';
4
+ import { X, Plus } from 'lucide-react';
5
+
6
+ export default function SessionTabs() {
7
+ const { openTabs, activeSessionId, sessions, selectSession, closeTab, setShowNewSessionDialog } = useStore();
8
+
9
+ return (
10
+ <div className="ide-tabs">
11
+ {openTabs.map((sid) => {
12
+ const s = sessions.find((x) => x.id === sid);
13
+ if (!s) return null;
14
+ const dot = statusToDot(s.status, s.is_running === 1);
15
+ const isActive = activeSessionId === sid;
16
+ return (
17
+ <div
18
+ key={sid}
19
+ className={`ide-tab ${isActive ? 'active' : ''}`}
20
+ onClick={() => selectSession(sid)}
21
+ >
22
+ {s.title || sid.slice(0, 8)}
23
+ {s.is_running === 1 && <StatusDot status={dot} />}
24
+ <span
25
+ className="ide-tab-close"
26
+ onClick={(e) => {
27
+ e.stopPropagation();
28
+ closeTab(sid);
29
+ }}
30
+ >
31
+ <X size={12} />
32
+ </span>
33
+ </div>
34
+ );
35
+ })}
36
+ <div className="ide-tab-add" title="New Session" onClick={() => setShowNewSessionDialog(true)}>
37
+ <Plus size={14} />
38
+ </div>
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,60 @@
1
+ import { useStore } from '../store/store.ts';
2
+ import { statusToDot, statusToLabel } 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);
28
+ const label = statusToLabel(session.status, isRunning);
29
+ const turnCount = detail?.turns.length ?? 0;
30
+
31
+ return (
32
+ <div className="ide-statusbar">
33
+ <div className="sb-item"><Zap size={14} /> AgentPane</div>
34
+ <div className="sb-divider">|</div>
35
+ <div className="sb-item">
36
+ <span className={`runtime-icon ic-codex`} style={{ width: 16, height: 16, fontSize: 8 }}>CX</span>
37
+ {session.runtime_type}
38
+ </div>
39
+ {session.model && (
40
+ <>
41
+ <div className="sb-divider">|</div>
42
+ <div className="sb-item">{session.model}</div>
43
+ </>
44
+ )}
45
+ <div className="sb-spacer" />
46
+ <div className="sb-item" title={wsConnected ? 'WebSocket connected' : 'WebSocket disconnected'}>
47
+ <span className={`status-dot ${wsConnected ? 'done' : 'error'}`} />
48
+ {wsConnected ? 'Live' : 'Offline'}
49
+ </div>
50
+ <div className="sb-divider">|</div>
51
+ <div className="sb-item" style={session.status === 'failed' ? { color: 'var(--red)' } : undefined}>
52
+ <StatusDot status={dot} /> {label}
53
+ </div>
54
+ <div className="sb-divider">|</div>
55
+ <div className="sb-item">{turnCount} turn{turnCount !== 1 ? 's' : ''}</div>
56
+ <div className="sb-divider">|</div>
57
+ <div className="sb-item"><Command size={14} /> K</div>
58
+ </div>
59
+ );
60
+ }
@@ -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,34 @@
1
+ import { useStore } from '../store/store.ts';
2
+ import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
3
+ import type { Toast as ToastType } from '../store/store.ts';
4
+
5
+ const ICONS: Record<ToastType['type'], typeof CheckCircle> = {
6
+ success: CheckCircle,
7
+ error: AlertCircle,
8
+ info: Info,
9
+ warn: AlertTriangle,
10
+ };
11
+
12
+ export default function Toast() {
13
+ const toasts = useStore((s) => s.toasts);
14
+ const removeToast = useStore((s) => s.removeToast);
15
+
16
+ if (toasts.length === 0) return null;
17
+
18
+ return (
19
+ <div className="toast-container">
20
+ {toasts.map((toast) => {
21
+ const Icon = ICONS[toast.type];
22
+ return (
23
+ <div key={toast.id} className={`toast toast-${toast.type}`}>
24
+ <Icon size={16} />
25
+ <span className="toast-message">{toast.message}</span>
26
+ <button className="toast-close" onClick={() => removeToast(toast.id)}>
27
+ <X size={14} />
28
+ </button>
29
+ </div>
30
+ );
31
+ })}
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,202 @@
1
+ import { marked } from 'marked';
2
+ import DOMPurify from 'dompurify';
3
+ import { memo } from 'react';
4
+ import type { Turn, Item } from '../types/index.ts';
5
+
6
+ marked.setOptions({ breaks: true, gfm: true });
7
+
8
+ function renderMarkdown(content: string): string {
9
+ const raw = marked.parse(content) as string;
10
+ return DOMPurify.sanitize(raw);
11
+ }
12
+
13
+ /** Extract text from item data (agent_message / reasoning have {text}). */
14
+ function itemText(item: Item): string {
15
+ if (item.data?.text) return item.data.text as string;
16
+ return '';
17
+ }
18
+
19
+ /** Render a single item as a turn card. */
20
+ function ItemCard({ item }: { item: Item }) {
21
+ const isRunning = item.status === 'in_progress';
22
+
23
+ switch (item.type) {
24
+ case 'agent_message': {
25
+ return (
26
+ <div className="render-content">
27
+ <div className="turn-header">
28
+ <span className="turn-header-icon ic-codex">CX</span>
29
+ <span className="turn-header-title">Agent</span>
30
+ {isRunning && <span className="turn-header-meta">writing…</span>}
31
+ </div>
32
+ <div dangerouslySetInnerHTML={{ __html: renderMarkdown(itemText(item) || '…') }} />
33
+ </div>
34
+ );
35
+ }
36
+
37
+ case 'reasoning': {
38
+ return (
39
+ <div className="render-content">
40
+ <div className="turn-header">
41
+ <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>💭</span>
42
+ <span className="turn-header-title">Reasoning</span>
43
+ </div>
44
+ <div dangerouslySetInnerHTML={{ __html: renderMarkdown(itemText(item)) }} />
45
+ </div>
46
+ );
47
+ }
48
+
49
+ case 'command_execution': {
50
+ const data = item.data || {};
51
+ const command = (data.command as string) || '';
52
+ const output = (data.aggregated_output as string) || '';
53
+ const exitCode = data.exit_code as number | null;
54
+ const summary = isRunning ? 'running…' : (exitCode !== null ? `exit ${exitCode}` : '');
55
+ return (
56
+ <div className="render-content">
57
+ <div className="turn-header">
58
+ <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>🔧</span>
59
+ <span className="turn-header-title">Command</span>
60
+ {summary && <span className="turn-header-meta">{summary}</span>}
61
+ </div>
62
+ <pre className="cmd-block"><code>{command}</code></pre>
63
+ {output && <pre className="cmd-output"><code>{output}</code></pre>}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ case 'file_change': {
69
+ const changes = (item.data?.changes as Array<{ path: string; kind: string }>) || [];
70
+ const summary = `${changes.length} file${changes.length !== 1 ? 's' : ''}`;
71
+ return (
72
+ <div className="render-content">
73
+ <div className="turn-header">
74
+ <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>📁</span>
75
+ <span className="turn-header-title">File Changes</span>
76
+ <span className="turn-header-meta">{summary}</span>
77
+ </div>
78
+ <div className="file-change-list">
79
+ {changes.map((c, i) => {
80
+ const statusChar = c.kind === 'add' ? 'A' : c.kind === 'delete' ? 'D' : 'M';
81
+ const statusClass = c.kind === 'add' ? 'fc-add' : c.kind === 'delete' ? 'fc-del' : 'fc-mod';
82
+ return (
83
+ <div key={i} className="file-change">
84
+ <span className={`fc-status ${statusClass}`}>{statusChar}</span>
85
+ <span className="fc-name">{c.path}</span>
86
+ </div>
87
+ );
88
+ })}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ case 'mcp_tool_call': {
95
+ const data = item.data || {};
96
+ const tool = (data.tool as string) || 'mcp';
97
+ const server = (data.server as string) || '';
98
+ return (
99
+ <div className="render-content">
100
+ <div className="turn-header">
101
+ <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>🔌</span>
102
+ <span className="turn-header-title">MCP: {tool}</span>
103
+ {server && <span className="turn-header-meta">{server}</span>}
104
+ </div>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ case 'web_search': {
110
+ const query = (item.data?.query as string) || '';
111
+ return (
112
+ <div className="render-content">
113
+ <div className="turn-header">
114
+ <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>🔍</span>
115
+ <span className="turn-header-title">Web Search</span>
116
+ </div>
117
+ <p className="user-message">{query}</p>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ case 'todo_list': {
123
+ const items = (item.data?.items as Array<{ text: string; completed: boolean }>) || [];
124
+ return (
125
+ <div className="render-content">
126
+ <div className="turn-header">
127
+ <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>📋</span>
128
+ <span className="turn-header-title">Todo List</span>
129
+ </div>
130
+ <ul className="todo-list">
131
+ {items.map((t, i) => (
132
+ <li key={i} className={t.completed ? 'todo-done' : 'todo-pending'}>
133
+ {t.completed ? '✅' : '⬜'} {t.text}
134
+ </li>
135
+ ))}
136
+ </ul>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ case 'error': {
142
+ const msg = (item.data?.message as string) || 'Unknown error';
143
+ return (
144
+ <div className="render-content">
145
+ <div className="turn-header">
146
+ <span className="turn-header-icon" style={{ background: 'var(--red)', color: '#fff' }}>!</span>
147
+ <span className="turn-header-title">Error</span>
148
+ </div>
149
+ <pre className="cmd-output" style={{ color: 'var(--red)' }}><code>{msg}</code></pre>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ default:
155
+ return null;
156
+ }
157
+ }
158
+
159
+ const MemoItemCard = memo(ItemCard);
160
+
161
+ interface Props {
162
+ turn: Turn;
163
+ }
164
+
165
+ function TurnContentBase({ turn }: Props) {
166
+ return (
167
+ <div className="turn-group">
168
+ {/* User prompt */}
169
+ <div className="render-content">
170
+ <div className="turn-header">
171
+ <span className="turn-header-icon" style={{ background: 'var(--amber)', color: '#fff' }}>U</span>
172
+ <span className="turn-header-title">You</span>
173
+ </div>
174
+ <p className="user-message">{turn.user_prompt}</p>
175
+ </div>
176
+
177
+ {/* Items in order */}
178
+ {turn.items.map((item) => (
179
+ <MemoItemCard key={item.id} item={item} />
180
+ ))}
181
+
182
+ {/* Turn error (if any) */}
183
+ {turn.error && (
184
+ <div className="render-content">
185
+ <pre className="cmd-output" style={{ color: 'var(--red)' }}><code>{turn.error}</code></pre>
186
+ </div>
187
+ )}
188
+
189
+ {/* Pending state (no items yet) */}
190
+ {turn.items.length === 0 && !turn.error && (turn.status === 'pending' || turn.status === 'running') && (
191
+ <div className="render-content">
192
+ <p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>
193
+ {turn.status === 'running' ? 'Agent is thinking…' : 'Waiting to start…'}
194
+ </p>
195
+ </div>
196
+ )}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ const TurnContent = memo(TurnContentBase);
202
+ export default TurnContent;