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.
- package/agent-pane/backend/package-lock.json +2359 -0
- package/agent-pane/backend/package.json +29 -0
- package/agent-pane/backend/src/codex/codex-provider.ts +363 -0
- package/agent-pane/backend/src/codex/item-mapper.ts +59 -0
- package/agent-pane/backend/src/db/index.ts +91 -0
- package/agent-pane/backend/src/db/schema.ts +76 -0
- package/agent-pane/backend/src/index.ts +57 -0
- package/agent-pane/backend/src/routes/sessions.ts +134 -0
- package/agent-pane/backend/src/routes/turns.ts +60 -0
- package/agent-pane/backend/src/routes/workspaces.ts +110 -0
- package/agent-pane/backend/src/ws/index.ts +43 -0
- package/agent-pane/backend/tsconfig.json +23 -0
- package/agent-pane/frontend/index.html +18 -0
- package/agent-pane/frontend/package-lock.json +1948 -0
- package/agent-pane/frontend/package.json +28 -0
- package/agent-pane/frontend/src/App.tsx +44 -0
- package/agent-pane/frontend/src/AppInitializer.tsx +46 -0
- package/agent-pane/frontend/src/api/client.ts +41 -0
- package/agent-pane/frontend/src/api/sessions.ts +47 -0
- package/agent-pane/frontend/src/api/websocket.ts +149 -0
- package/agent-pane/frontend/src/api/workspaces.ts +24 -0
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +50 -0
- package/agent-pane/frontend/src/components/CommandInputBar.tsx +42 -0
- package/agent-pane/frontend/src/components/CommandPalette.tsx +207 -0
- package/agent-pane/frontend/src/components/DiffViewer.tsx +27 -0
- package/agent-pane/frontend/src/components/IdeWorkspace.tsx +25 -0
- package/agent-pane/frontend/src/components/NewSessionDialog.tsx +84 -0
- package/agent-pane/frontend/src/components/RenderArea.tsx +170 -0
- package/agent-pane/frontend/src/components/RightPanel.tsx +40 -0
- package/agent-pane/frontend/src/components/RuntimeIcon.tsx +16 -0
- package/agent-pane/frontend/src/components/SessionTabs.tsx +44 -0
- package/agent-pane/frontend/src/components/StatusBar.tsx +68 -0
- package/agent-pane/frontend/src/components/StatusDot.tsx +13 -0
- package/agent-pane/frontend/src/components/Toast.tsx +80 -0
- package/agent-pane/frontend/src/components/TurnContent.tsx +294 -0
- package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +350 -0
- package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +81 -0
- package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +25 -0
- package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +82 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +45 -0
- package/agent-pane/frontend/src/main.tsx +10 -0
- package/agent-pane/frontend/src/store/store.ts +635 -0
- package/agent-pane/frontend/src/styles/app.css +365 -0
- package/agent-pane/frontend/src/styles/tokens.css +62 -0
- package/agent-pane/frontend/src/types/index.ts +153 -0
- package/agent-pane/frontend/tsconfig.json +22 -0
- package/agent-pane/frontend/vite.config.ts +16 -0
- package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
- 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
|
+
}
|