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,28 @@
1
+ {
2
+ "name": "agent-pane-frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc --noEmit && vite build",
9
+ "preview": "vite preview",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "dompurify": "^3.2.0",
14
+ "lucide-react": "^0.469.0",
15
+ "marked": "^15.0.0",
16
+ "react": "^18.3.1",
17
+ "react-dom": "^18.3.1",
18
+ "zustand": "^5.0.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/dompurify": "^3.0.5",
22
+ "@types/react": "^18.3.0",
23
+ "@types/react-dom": "^18.3.0",
24
+ "@vitejs/plugin-react": "^4.3.0",
25
+ "typescript": "^5.7.0",
26
+ "vite": "^6.0.0"
27
+ }
28
+ }
@@ -0,0 +1,44 @@
1
+ import { useEffect } from 'react';
2
+ import { useStore } from './store/store.ts';
3
+ import IdeWorkspace from './components/IdeWorkspace.tsx';
4
+ import NewSessionDialog from './components/NewSessionDialog.tsx';
5
+ import CommandPalette from './components/CommandPalette.tsx';
6
+ import Toast from './components/Toast.tsx';
7
+ import AppInitializer from './AppInitializer.tsx';
8
+
9
+ export default function App() {
10
+ const loading = useStore((s) => s.loading);
11
+ const error = useStore((s) => s.error);
12
+ const sessions = useStore((s) => s.sessions);
13
+ const setShowCommandPalette = useStore((s) => s.setShowCommandPalette);
14
+
15
+ // Cmd+K / Ctrl+K listener
16
+ useEffect(() => {
17
+ function handleKeyDown(e: KeyboardEvent) {
18
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
19
+ e.preventDefault();
20
+ setShowCommandPalette(true);
21
+ }
22
+ }
23
+ window.addEventListener('keydown', handleKeyDown);
24
+ return () => window.removeEventListener('keydown', handleKeyDown);
25
+ }, [setShowCommandPalette]);
26
+
27
+ return (
28
+ <>
29
+ <AppInitializer />
30
+ {loading && sessions.length === 0 ? (
31
+ <div className="empty-state">Loading sessions…</div>
32
+ ) : error && sessions.length === 0 ? (
33
+ <div className="empty-state">Error: {error}</div>
34
+ ) : (
35
+ <>
36
+ <IdeWorkspace />
37
+ <NewSessionDialog />
38
+ <CommandPalette />
39
+ <Toast />
40
+ </>
41
+ )}
42
+ </>
43
+ );
44
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect } from 'react';
2
+ import { useStore } from './store/store.ts';
3
+ import { connectWebSocket, disconnectWebSocket } from './api/websocket.ts';
4
+
5
+ export default function AppInitializer() {
6
+ const init = useStore((s) => s.init);
7
+
8
+ useEffect(() => {
9
+ // Fetch initial sessions from REST API
10
+ init();
11
+
12
+ // Connect WebSocket for live streaming
13
+ connectWebSocket();
14
+
15
+ // visibilitychange — reconcile when tab returns to foreground
16
+ function handleVisibilityChange() {
17
+ if (document.visibilityState !== 'visible') return;
18
+
19
+ const state = useStore.getState();
20
+ const activeId = state.activeSessionId;
21
+ if (!activeId) return;
22
+
23
+ const detail = state.sessionDetails[activeId];
24
+
25
+ // If WS is not connected, reconnect
26
+ if (!state.wsConnected) {
27
+ connectWebSocket();
28
+ }
29
+
30
+ // If session was running, re-fetch to catch missed events
31
+ if (detail && detail.is_running === 1) {
32
+ state.refreshActiveSession();
33
+ state.addLogEntry('INFO', 'Tab returned — reconciling session state');
34
+ }
35
+ }
36
+
37
+ document.addEventListener('visibilitychange', handleVisibilityChange);
38
+
39
+ return () => {
40
+ disconnectWebSocket();
41
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
42
+ };
43
+ }, [init]);
44
+
45
+ return null;
46
+ }
@@ -0,0 +1,29 @@
1
+ const API_BASE = '/api';
2
+
3
+ export async function apiGet<T>(path: string): Promise<T> {
4
+ const res = await fetch(`${API_BASE}${path}`);
5
+ if (!res.ok) {
6
+ throw new Error(`GET ${path} failed: ${res.status} ${res.statusText}`);
7
+ }
8
+ return res.json() as Promise<T>;
9
+ }
10
+
11
+ export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
12
+ const res = await fetch(`${API_BASE}${path}`, {
13
+ method: 'POST',
14
+ headers: { 'Content-Type': 'application/json' },
15
+ body: body ? JSON.stringify(body) : undefined,
16
+ });
17
+ if (!res.ok) {
18
+ throw new Error(`POST ${path} failed: ${res.status} ${res.statusText}`);
19
+ }
20
+ return res.json() as Promise<T>;
21
+ }
22
+
23
+ export async function apiDelete<T>(path: string): Promise<T> {
24
+ const res = await fetch(`${API_BASE}${path}`, { method: 'DELETE' });
25
+ if (!res.ok) {
26
+ throw new Error(`DELETE ${path} failed: ${res.status} ${res.statusText}`);
27
+ }
28
+ return res.json() as Promise<T>;
29
+ }
@@ -0,0 +1,31 @@
1
+ import { apiGet, apiPost, apiDelete } from './client.ts';
2
+ import type { Session, SessionDetail, Turn } from '../types/index.ts';
3
+
4
+ export function fetchSessions(): Promise<Session[]> {
5
+ return apiGet<Session[]>('/sessions');
6
+ }
7
+
8
+ export function fetchSessionDetail(id: string): Promise<SessionDetail> {
9
+ return apiGet<SessionDetail>(`/sessions/${id}`);
10
+ }
11
+
12
+ export function createSession(body: {
13
+ title?: string;
14
+ runtime_type?: string;
15
+ model?: string;
16
+ cwd?: string;
17
+ }): Promise<Session> {
18
+ return apiPost<Session>('/sessions', body);
19
+ }
20
+
21
+ export function deleteSession(id: string): Promise<{ success: boolean }> {
22
+ return apiDelete<{ success: boolean }>(`/sessions/${id}`);
23
+ }
24
+
25
+ export function submitPrompt(sessionId: string, userPrompt: string): Promise<Turn> {
26
+ return apiPost<Turn>(`/sessions/${sessionId}/turns`, { user_prompt: userPrompt });
27
+ }
28
+
29
+ export function cancelTurn(sessionId: string): Promise<{ success: boolean }> {
30
+ return apiPost<{ success: boolean }>(`/sessions/${sessionId}/cancel`);
31
+ }
@@ -0,0 +1,139 @@
1
+ import { useStore } from '../store/store.ts';
2
+ import type { Item, TokenUsage } from '../types/index.ts';
3
+
4
+ type WsEventHandler = (data: unknown) => void;
5
+
6
+ let ws: WebSocket | null = null;
7
+ let reconnectTimer: number | null = null;
8
+ let reconnectDelay = 1000;
9
+ const MAX_RECONNECT_DELAY = 10000;
10
+
11
+ /** Build the WebSocket URL from the current page location. */
12
+ function buildWsUrl(): string {
13
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
14
+ return `${proto}//${window.location.host}/ws`;
15
+ }
16
+
17
+ /** Connect to the backend WebSocket server. Dispatches events to store actions. */
18
+ export function connectWebSocket(): void {
19
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
20
+ return;
21
+ }
22
+
23
+ ws = new WebSocket(buildWsUrl());
24
+
25
+ ws.onopen = () => {
26
+ const wasDisconnected = reconnectDelay > 1000;
27
+ reconnectDelay = 1000; // reset backoff
28
+ useStore.getState().setWsConnected(true);
29
+ useStore.getState().addLogEntry('INFO', 'WebSocket connected');
30
+
31
+ // On reconnect (not initial connect): reconcile active session
32
+ if (wasDisconnected) {
33
+ const state = useStore.getState();
34
+ if (state.activeSessionId) {
35
+ state.refreshActiveSession();
36
+ state.addToast('info', 'WebSocket reconnected — session state reconciled');
37
+ }
38
+ }
39
+ };
40
+
41
+ ws.onclose = () => {
42
+ useStore.getState().setWsConnected(false);
43
+ useStore.getState().addLogEntry('WARN', 'WebSocket disconnected');
44
+ useStore.getState().addToast('warn', 'WebSocket disconnected — reconnecting…');
45
+ scheduleReconnect();
46
+ };
47
+
48
+ ws.onerror = () => {
49
+ // onclose will fire after onerror
50
+ };
51
+
52
+ ws.onmessage = (event: MessageEvent) => {
53
+ try {
54
+ const msg = JSON.parse(event.data as string);
55
+ const handler = HANDLERS[msg.event as string];
56
+ if (handler) {
57
+ handler(msg.data);
58
+ }
59
+ } catch {
60
+ // Ignore malformed messages
61
+ }
62
+ };
63
+ }
64
+
65
+ /** Disconnect the WebSocket (used on unmount). */
66
+ export function disconnectWebSocket(): void {
67
+ if (reconnectTimer) {
68
+ clearTimeout(reconnectTimer);
69
+ reconnectTimer = null;
70
+ }
71
+ if (ws) {
72
+ ws.onclose = null; // prevent reconnect on intentional close
73
+ ws.close();
74
+ ws = null;
75
+ }
76
+ useStore.getState().setWsConnected(false);
77
+ }
78
+
79
+ /** Schedule a reconnect with exponential backoff. */
80
+ function scheduleReconnect(): void {
81
+ if (reconnectTimer) return;
82
+ reconnectTimer = window.setTimeout(() => {
83
+ reconnectTimer = null;
84
+ connectWebSocket();
85
+ }, reconnectDelay);
86
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
87
+ }
88
+
89
+ // --- Event handlers — dispatch to store actions ---
90
+
91
+ interface TurnEventPayload {
92
+ sessionId: string;
93
+ turnId: string;
94
+ }
95
+
96
+ interface ItemEventPayload {
97
+ sessionId: string;
98
+ turnId: string;
99
+ item: Item;
100
+ }
101
+
102
+ interface TurnCompletedPayload {
103
+ sessionId: string;
104
+ turnId: string;
105
+ usage: TokenUsage;
106
+ }
107
+
108
+ interface TurnFailedPayload {
109
+ sessionId: string;
110
+ turnId: string;
111
+ error: string;
112
+ }
113
+
114
+ const HANDLERS: Record<string, WsEventHandler> = {
115
+ 'turn:started': (data) => {
116
+ const p = data as TurnEventPayload;
117
+ useStore.getState().onTurnStarted(p.sessionId, p.turnId);
118
+ },
119
+ 'item:started': (data) => {
120
+ const p = data as ItemEventPayload;
121
+ useStore.getState().onItemStarted(p.sessionId, p.turnId, p.item);
122
+ },
123
+ 'item:updated': (data) => {
124
+ const p = data as ItemEventPayload;
125
+ useStore.getState().onItemUpdated(p.sessionId, p.turnId, p.item);
126
+ },
127
+ 'item:completed': (data) => {
128
+ const p = data as ItemEventPayload;
129
+ useStore.getState().onItemCompleted(p.sessionId, p.turnId, p.item);
130
+ },
131
+ 'turn:completed': (data) => {
132
+ const p = data as TurnCompletedPayload;
133
+ useStore.getState().onTurnCompleted(p.sessionId, p.turnId, p.usage);
134
+ },
135
+ 'turn:failed': (data) => {
136
+ const p = data as TurnFailedPayload;
137
+ useStore.getState().onTurnFailed(p.sessionId, p.turnId, p.error);
138
+ },
139
+ };
@@ -0,0 +1,42 @@
1
+ import { useState } from 'react';
2
+ import { CornerDownLeft, Square } from 'lucide-react';
3
+ import { useStore } from '../store/store.ts';
4
+
5
+ export default function CommandInputBar() {
6
+ const [value, setValue] = useState('');
7
+ const { submitPrompt, cancelTurn } = useStore();
8
+ const detail = useStore((s) => (s.activeSessionId ? s.sessionDetails[s.activeSessionId] : null));
9
+ const isRunning = detail?.is_running === 1;
10
+
11
+ function handleSend() {
12
+ const v = value.trim();
13
+ if (!v || isRunning) return;
14
+ setValue(''); // Clear input immediately for visual feedback
15
+ submitPrompt(v);
16
+ }
17
+
18
+ return (
19
+ <div className="cmd-bar">
20
+ <input
21
+ className="cmd-input"
22
+ placeholder={isRunning ? 'Agent is running…' : 'Type a message to the agent…'}
23
+ autoComplete="off"
24
+ value={value}
25
+ disabled={isRunning}
26
+ onChange={(e) => setValue(e.target.value)}
27
+ onKeyDown={(e) => {
28
+ if (e.key === 'Enter' && !isRunning) handleSend();
29
+ }}
30
+ />
31
+ {isRunning ? (
32
+ <button className="cmd-send" style={{ background: 'var(--red)' }} onClick={() => cancelTurn()}>
33
+ <Square size={14} /> Cancel
34
+ </button>
35
+ ) : (
36
+ <button className="cmd-send" onClick={handleSend} disabled={!value.trim()}>
37
+ <CornerDownLeft size={14} /> Send
38
+ </button>
39
+ )}
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,196 @@
1
+ import { useState, useEffect, useRef, useMemo } from 'react';
2
+ import { useStore } from '../store/store.ts';
3
+ import {
4
+ Plus,
5
+ Trash2,
6
+ Send,
7
+ Square,
8
+ List,
9
+ BarChart3,
10
+ FileCode,
11
+ ScrollText,
12
+ X,
13
+ CornerDownLeft,
14
+ } from 'lucide-react';
15
+ import type { RightTab } from '../types/index.ts';
16
+
17
+ interface Command {
18
+ id: string;
19
+ label: string;
20
+ hint: string;
21
+ icon: typeof Plus;
22
+ action: () => void;
23
+ available: boolean;
24
+ }
25
+
26
+ export default function CommandPalette() {
27
+ const show = useStore((s) => s.showCommandPalette);
28
+ const setShow = useStore((s) => s.setShowCommandPalette);
29
+ const store = useStore();
30
+
31
+ const [query, setQuery] = useState('');
32
+ const [selectedIdx, setSelectedIdx] = useState(0);
33
+ const inputRef = useRef<HTMLInputElement>(null);
34
+
35
+ // Build commands
36
+ const commands: Command[] = useMemo(() => {
37
+ const detail = store.activeSessionId ? store.sessionDetails[store.activeSessionId] : null;
38
+ const isRunning = detail?.is_running === 1;
39
+
40
+ return [
41
+ {
42
+ id: 'new-session',
43
+ label: 'New Session',
44
+ hint: 'Create a new Codex session',
45
+ icon: Plus,
46
+ action: () => store.setShowNewSessionDialog(true),
47
+ available: true,
48
+ },
49
+ {
50
+ id: 'delete-session',
51
+ label: 'Delete Session',
52
+ hint: 'Delete the active session',
53
+ icon: Trash2,
54
+ action: () => { if (store.activeSessionId) store.deleteSession(store.activeSessionId); },
55
+ available: !!store.activeSessionId,
56
+ },
57
+ {
58
+ id: 'send-message',
59
+ label: 'Send Message',
60
+ hint: 'Focus the message input',
61
+ icon: Send,
62
+ action: () => { const el = document.querySelector<HTMLInputElement>('.cmd-input'); el?.focus(); },
63
+ available: !!store.activeSessionId && !isRunning,
64
+ },
65
+ {
66
+ id: 'cancel-turn',
67
+ label: 'Cancel Turn',
68
+ hint: 'Cancel the running turn',
69
+ icon: Square,
70
+ action: () => store.cancelTurn(),
71
+ available: isRunning,
72
+ },
73
+ {
74
+ id: 'tab-turns',
75
+ label: 'Switch to Turns Tab',
76
+ hint: 'View conversation timeline',
77
+ icon: List,
78
+ action: () => store.switchRightTab('turns' as RightTab),
79
+ available: !!store.activeSessionId,
80
+ },
81
+ {
82
+ id: 'tab-metrics',
83
+ label: 'Switch to Metrics Tab',
84
+ hint: 'View token usage and session info',
85
+ icon: BarChart3,
86
+ action: () => store.switchRightTab('metrics' as RightTab),
87
+ available: !!store.activeSessionId,
88
+ },
89
+ {
90
+ id: 'tab-changes',
91
+ label: 'Switch to Changes Tab',
92
+ hint: 'View file changes',
93
+ icon: FileCode,
94
+ action: () => store.switchRightTab('changes' as RightTab),
95
+ available: !!store.activeSessionId,
96
+ },
97
+ {
98
+ id: 'tab-logs',
99
+ label: 'Switch to Logs Tab',
100
+ hint: 'View event log',
101
+ icon: ScrollText,
102
+ action: () => store.switchRightTab('logs' as RightTab),
103
+ available: !!store.activeSessionId,
104
+ },
105
+ {
106
+ id: 'close-tab',
107
+ label: 'Close Tab',
108
+ hint: 'Close the active session tab',
109
+ icon: X,
110
+ action: () => { if (store.activeSessionId) store.closeTab(store.activeSessionId); },
111
+ available: !!store.activeSessionId,
112
+ },
113
+ ];
114
+ }, [store]);
115
+
116
+ // Filter commands
117
+ const filtered = useMemo(() => {
118
+ const q = query.toLowerCase().trim();
119
+ const available = commands.filter((c) => c.available);
120
+ if (!q) return available;
121
+ return available.filter((c) =>
122
+ c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q),
123
+ );
124
+ }, [commands, query]);
125
+
126
+ // Reset on open
127
+ useEffect(() => {
128
+ if (show) {
129
+ setQuery('');
130
+ setSelectedIdx(0);
131
+ setTimeout(() => inputRef.current?.focus(), 50);
132
+ }
133
+ }, [show]);
134
+
135
+ // Keyboard navigation
136
+ function handleKeyDown(e: React.KeyboardEvent) {
137
+ if (e.key === 'ArrowDown') {
138
+ e.preventDefault();
139
+ setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1));
140
+ } else if (e.key === 'ArrowUp') {
141
+ e.preventDefault();
142
+ setSelectedIdx((i) => Math.max(i - 1, 0));
143
+ } else if (e.key === 'Enter') {
144
+ e.preventDefault();
145
+ const cmd = filtered[selectedIdx];
146
+ if (cmd) {
147
+ cmd.action();
148
+ setShow(false);
149
+ }
150
+ } else if (e.key === 'Escape') {
151
+ setShow(false);
152
+ }
153
+ }
154
+
155
+ if (!show) return null;
156
+
157
+ return (
158
+ <div className="palette-overlay" onClick={() => setShow(false)}>
159
+ <div className="palette" onClick={(e) => e.stopPropagation()}>
160
+ <div className="palette-search">
161
+ <input
162
+ ref={inputRef}
163
+ type="text"
164
+ placeholder="Type a command…"
165
+ value={query}
166
+ onChange={(e) => { setQuery(e.target.value); setSelectedIdx(0); }}
167
+ onKeyDown={handleKeyDown}
168
+ />
169
+ <span className="palette-esc">ESC</span>
170
+ </div>
171
+ {filtered.length === 0 ? (
172
+ <div className="palette-empty">No matching commands</div>
173
+ ) : (
174
+ <div className="palette-list">
175
+ {filtered.map((cmd, i) => {
176
+ const Icon = cmd.icon;
177
+ return (
178
+ <div
179
+ key={cmd.id}
180
+ className={`palette-item ${i === selectedIdx ? 'selected' : ''}`}
181
+ onClick={() => { cmd.action(); setShow(false); }}
182
+ onMouseEnter={() => setSelectedIdx(i)}
183
+ >
184
+ <span className="palette-item-icon"><Icon size={16} /></span>
185
+ <span className="palette-item-label">{cmd.label}</span>
186
+ <span className="palette-item-hint">{cmd.hint}</span>
187
+ {i === selectedIdx && <CornerDownLeft size={12} className="palette-item-enter" />}
188
+ </div>
189
+ );
190
+ })}
191
+ </div>
192
+ )}
193
+ </div>
194
+ </div>
195
+ );
196
+ }
@@ -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
+ }