@rendiv/studio 0.1.3 → 0.1.5

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 (38) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/scaffold-project.d.ts +10 -0
  6. package/dist/scaffold-project.d.ts.map +1 -0
  7. package/dist/scaffold-project.js +181 -0
  8. package/dist/scaffold-project.js.map +1 -0
  9. package/dist/start-studio-workspace.d.ts +16 -0
  10. package/dist/start-studio-workspace.d.ts.map +1 -0
  11. package/dist/start-studio-workspace.js +110 -0
  12. package/dist/start-studio-workspace.js.map +1 -0
  13. package/dist/start-studio.d.ts +5 -0
  14. package/dist/start-studio.d.ts.map +1 -1
  15. package/dist/start-studio.js +8 -5
  16. package/dist/start-studio.js.map +1 -1
  17. package/dist/studio-entry-code.d.ts +7 -2
  18. package/dist/studio-entry-code.d.ts.map +1 -1
  19. package/dist/studio-entry-code.js +19 -4
  20. package/dist/studio-entry-code.js.map +1 -1
  21. package/dist/vite-plugin-studio.d.ts +4 -0
  22. package/dist/vite-plugin-studio.d.ts.map +1 -1
  23. package/dist/vite-plugin-studio.js +135 -28
  24. package/dist/vite-plugin-studio.js.map +1 -1
  25. package/dist/workspace-entry-code.d.ts +12 -0
  26. package/dist/workspace-entry-code.d.ts.map +1 -0
  27. package/dist/workspace-entry-code.js +38 -0
  28. package/dist/workspace-entry-code.js.map +1 -0
  29. package/dist/workspace-picker-server.d.ts +27 -0
  30. package/dist/workspace-picker-server.d.ts.map +1 -0
  31. package/dist/workspace-picker-server.js +199 -0
  32. package/dist/workspace-picker-server.js.map +1 -0
  33. package/package.json +8 -3
  34. package/ui/RenderQueue.tsx +15 -55
  35. package/ui/StudioApp.tsx +163 -18
  36. package/ui/Terminal.tsx +266 -0
  37. package/ui/TopBar.tsx +40 -10
  38. package/ui/WorkspacePicker.tsx +423 -0
@@ -19,8 +19,6 @@ export interface RenderJob {
19
19
 
20
20
  interface RenderQueueProps {
21
21
  jobs: RenderJob[];
22
- open: boolean;
23
- onToggle: () => void;
24
22
  onCancel: (jobId: string) => void;
25
23
  onRemove: (jobId: string) => void;
26
24
  onClear: () => void;
@@ -77,13 +75,10 @@ function overallProgress(job: RenderJob): number {
77
75
 
78
76
  export const RenderQueue: React.FC<RenderQueueProps> = ({
79
77
  jobs,
80
- open,
81
- onToggle,
82
78
  onCancel,
83
79
  onRemove,
84
80
  onClear,
85
81
  }) => {
86
- const activeCount = jobs.filter((j) => j.status === 'queued' || j.status === 'bundling' || j.status === 'rendering' || j.status === 'encoding').length;
87
82
  const hasFinished = jobs.some((j) => j.status === 'done' || j.status === 'error' || j.status === 'cancelled');
88
83
 
89
84
  const handleCancel = useCallback((e: React.MouseEvent, jobId: string) => {
@@ -96,41 +91,21 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
96
91
  onRemove(jobId);
97
92
  }, [onRemove]);
98
93
 
99
- if (!open) return null;
100
-
101
94
  return (
102
- <div style={panelStyle}>
103
- {/* Header */}
104
- <div style={headerStyle}>
105
- <span style={{ fontWeight: 600, fontSize: 11, textTransform: 'uppercase' as const, letterSpacing: '0.05em' }}>
106
- Render Queue
107
- {activeCount > 0 && (
108
- <span style={{ marginLeft: 6, color: colors.accent, fontWeight: 600 }}>
109
- ({activeCount})
110
- </span>
111
- )}
112
- </span>
113
- <div style={{ display: 'flex', gap: 8 }}>
114
- {hasFinished && (
115
- <button
116
- type="button"
117
- style={clearBtnStyle}
118
- onClick={onClear}
119
- title="Clear finished jobs"
120
- >
121
- Clear
122
- </button>
123
- )}
95
+ <div style={contentStyle}>
96
+ {/* Actions bar */}
97
+ {hasFinished && (
98
+ <div style={actionsBarStyle}>
124
99
  <button
125
100
  type="button"
126
- style={closeBtnStyle}
127
- onClick={onToggle}
128
- title="Close panel"
101
+ style={clearBtnStyle}
102
+ onClick={onClear}
103
+ title="Clear finished jobs"
129
104
  >
130
- {'\u2715'}
105
+ Clear finished
131
106
  </button>
132
107
  </div>
133
- </div>
108
+ )}
134
109
 
135
110
  {/* Job list */}
136
111
  <div style={listStyle}>
@@ -221,23 +196,17 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
221
196
 
222
197
  // Styles
223
198
 
224
- const panelStyle: React.CSSProperties = {
225
- width: 300,
226
- minWidth: 300,
227
- height: '100%',
228
- backgroundColor: colors.surface,
229
- borderLeft: `1px solid ${colors.border}`,
199
+ const contentStyle: React.CSSProperties = {
230
200
  display: 'flex',
231
201
  flexDirection: 'column',
232
- flexShrink: 0,
202
+ flex: 1,
203
+ overflow: 'hidden',
233
204
  };
234
205
 
235
- const headerStyle: React.CSSProperties = {
206
+ const actionsBarStyle: React.CSSProperties = {
236
207
  display: 'flex',
237
- justifyContent: 'space-between',
238
- alignItems: 'center',
239
- padding: '12px 16px',
240
- color: colors.textSecondary,
208
+ justifyContent: 'flex-end',
209
+ padding: '6px 8px',
241
210
  borderBottom: `1px solid ${colors.border}`,
242
211
  flexShrink: 0,
243
212
  };
@@ -277,15 +246,6 @@ const progressBarStyle: React.CSSProperties = {
277
246
  transition: 'width 0.3s ease',
278
247
  };
279
248
 
280
- const closeBtnStyle: React.CSSProperties = {
281
- background: 'none',
282
- border: 'none',
283
- color: colors.textSecondary,
284
- cursor: 'pointer',
285
- fontSize: 12,
286
- padding: '2px 4px',
287
- };
288
-
289
249
  const clearBtnStyle: React.CSSProperties = {
290
250
  background: 'none',
291
251
  border: `1px solid ${colors.border}`,
package/ui/StudioApp.tsx CHANGED
@@ -13,10 +13,13 @@ import { TopBar } from './TopBar';
13
13
  import { Timeline } from './Timeline';
14
14
  import { TimelineEditor } from './TimelineEditor';
15
15
  import { RenderQueue, type RenderJob } from './RenderQueue';
16
- import { layoutStyles, scrollbarCSS } from './styles';
16
+ import { Terminal } from './Terminal';
17
+ import { layoutStyles, scrollbarCSS, colors, fonts } from './styles';
17
18
 
18
19
  // Read the entry point from the generated code's data attribute (set by studio-entry-code)
19
20
  const ENTRY_POINT = (window as Record<string, unknown>).__RENDIV_STUDIO_ENTRY__ as string ?? 'src/index.tsx';
21
+ // When set, Studio was launched from a workspace and can navigate back
22
+ const WORKSPACE_DIR = (window as unknown as Record<string, unknown>).__RENDIV_WORKSPACE_DIR__ as string | undefined;
20
23
 
21
24
  const ViewToggle: React.FC<{ view: 'editor' | 'tree'; onChange: (v: 'editor' | 'tree') => void }> = ({ view, onChange }) => (
22
25
  <div style={{ display: 'flex', gap: 2, padding: '2px', backgroundColor: '#0d1117', borderRadius: 6 }}>
@@ -73,9 +76,22 @@ const StudioApp: React.FC = () => {
73
76
 
74
77
  // Render queue state (server-driven)
75
78
  const [renderJobs, setRenderJobs] = useState<RenderJob[]>([]);
76
- const [queueOpen, setQueueOpen] = useState(false);
77
79
  const hasActiveRef = useRef(false);
78
80
 
81
+ // Right panel state — tabbed panel for Queue and Agent
82
+ const [rightPanel, setRightPanel] = useState<'queue' | 'agent' | null>(() => {
83
+ const stored = localStorage.getItem('rendiv-studio:right-panel');
84
+ if (stored === 'queue' || stored === 'agent') return stored;
85
+ return null;
86
+ });
87
+ const [rightPanelWidth, setRightPanelWidth] = useState(() => {
88
+ const stored = localStorage.getItem('rendiv-studio:right-panel-width');
89
+ return stored ? Number(stored) : 360;
90
+ });
91
+ const isDraggingPanel = useRef(false);
92
+ const panelDragStartX = useRef(0);
93
+ const panelDragStartWidth = useRef(0);
94
+
79
95
  const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
80
96
  e.preventDefault();
81
97
  isDraggingTimeline.current = true;
@@ -83,11 +99,23 @@ const StudioApp: React.FC = () => {
83
99
  dragStartHeight.current = timelineHeight;
84
100
  }, [timelineHeight]);
85
101
 
102
+ const handlePanelResizeMouseDown = useCallback((e: React.MouseEvent) => {
103
+ e.preventDefault();
104
+ isDraggingPanel.current = true;
105
+ panelDragStartX.current = e.clientX;
106
+ panelDragStartWidth.current = rightPanelWidth;
107
+ }, [rightPanelWidth]);
108
+
86
109
  useEffect(() => {
87
110
  const handleMouseMove = (e: MouseEvent) => {
88
- if (!isDraggingTimeline.current) return;
89
- const delta = dragStartY.current - e.clientY;
90
- setTimelineHeight(Math.max(120, dragStartHeight.current + delta));
111
+ if (isDraggingTimeline.current) {
112
+ const delta = dragStartY.current - e.clientY;
113
+ setTimelineHeight(Math.max(120, dragStartHeight.current + delta));
114
+ }
115
+ if (isDraggingPanel.current) {
116
+ const delta = panelDragStartX.current - e.clientX;
117
+ setRightPanelWidth(Math.max(280, Math.min(800, panelDragStartWidth.current + delta)));
118
+ }
91
119
  };
92
120
  const handleMouseUp = () => {
93
121
  if (isDraggingTimeline.current) {
@@ -97,6 +125,13 @@ const StudioApp: React.FC = () => {
97
125
  return h;
98
126
  });
99
127
  }
128
+ if (isDraggingPanel.current) {
129
+ isDraggingPanel.current = false;
130
+ setRightPanelWidth((w) => {
131
+ localStorage.setItem('rendiv-studio:right-panel-width', String(w));
132
+ return w;
133
+ });
134
+ }
100
135
  };
101
136
  window.addEventListener('mousemove', handleMouseMove);
102
137
  window.addEventListener('mouseup', handleMouseUp);
@@ -351,7 +386,8 @@ const StudioApp: React.FC = () => {
351
386
  totalFrames: selectedComposition.durationInFrames,
352
387
  }),
353
388
  });
354
- setQueueOpen(true);
389
+ setRightPanel('queue');
390
+ localStorage.setItem('rendiv-studio:right-panel-tab', 'queue');
355
391
  }, [selectedComposition, inputProps]);
356
392
 
357
393
  const handleCancelJob = useCallback((jobId: string) => {
@@ -366,8 +402,24 @@ const StudioApp: React.FC = () => {
366
402
  fetch('/__rendiv_api__/render/queue/clear', { method: 'POST' });
367
403
  }, []);
368
404
 
369
- const handleToggleQueue = useCallback(() => {
370
- setQueueOpen((prev) => !prev);
405
+ const handleBackToWorkspace = useCallback(() => {
406
+ if (!WORKSPACE_DIR) return;
407
+ fetch('/__rendiv_api__/workspace/back', { method: 'POST' }).catch(() => {});
408
+ // Server will restart in workspace picker mode; Vite HMR will reconnect + reload
409
+ }, []);
410
+
411
+ const handleTogglePanel = useCallback(() => {
412
+ setRightPanel((prev) => {
413
+ if (prev !== null) {
414
+ // Close panel
415
+ localStorage.setItem('rendiv-studio:right-panel', '');
416
+ return null;
417
+ }
418
+ // Open to last active tab (default 'queue')
419
+ const tab = (localStorage.getItem('rendiv-studio:right-panel-tab') as 'queue' | 'agent') || 'queue';
420
+ localStorage.setItem('rendiv-studio:right-panel', tab);
421
+ return tab;
422
+ });
371
423
  }, []);
372
424
 
373
425
  const queueCount = renderJobs.filter((j) =>
@@ -384,8 +436,10 @@ const StudioApp: React.FC = () => {
384
436
  entryPoint={ENTRY_POINT}
385
437
  onRender={handleAddRender}
386
438
  queueCount={queueCount}
387
- queueOpen={queueOpen}
388
- onToggleQueue={handleToggleQueue}
439
+ panelOpen={rightPanel !== null}
440
+ onTogglePanel={handleTogglePanel}
441
+ workspaceDir={WORKSPACE_DIR}
442
+ onBackToWorkspace={handleBackToWorkspace}
389
443
  />
390
444
 
391
445
  <div style={layoutStyles.body}>
@@ -411,14 +465,67 @@ const StudioApp: React.FC = () => {
411
465
  </div>
412
466
  )}
413
467
 
414
- <RenderQueue
415
- jobs={renderJobs}
416
- open={queueOpen}
417
- onToggle={handleToggleQueue}
418
- onCancel={handleCancelJob}
419
- onRemove={handleRemoveJob}
420
- onClear={handleClearFinished}
421
- />
468
+ {/* Right panel — tabbed: Queue / Agent */}
469
+ {rightPanel !== null && (
470
+ <div style={{ ...rightPanelStyle, width: rightPanelWidth, minWidth: 280 }}>
471
+ <div
472
+ style={panelResizeHandleStyle}
473
+ onMouseDown={handlePanelResizeMouseDown}
474
+ />
475
+ <div style={tabBarStyle}>
476
+ <div style={{ display: 'flex', gap: 2, padding: 2, backgroundColor: colors.bg, borderRadius: 6 }}>
477
+ {(['queue', 'agent'] as const).map((tab) => (
478
+ <button
479
+ key={tab}
480
+ onClick={() => {
481
+ setRightPanel(tab);
482
+ localStorage.setItem('rendiv-studio:right-panel', tab);
483
+ localStorage.setItem('rendiv-studio:right-panel-tab', tab);
484
+ }}
485
+ style={{
486
+ padding: '3px 10px',
487
+ fontSize: 11,
488
+ fontWeight: 500,
489
+ border: 'none',
490
+ borderRadius: 4,
491
+ cursor: 'pointer',
492
+ backgroundColor: rightPanel === tab ? colors.border : 'transparent',
493
+ color: rightPanel === tab ? colors.textPrimary : colors.textSecondary,
494
+ fontFamily: fonts.sans,
495
+ }}
496
+ >
497
+ {tab === 'queue' ? 'Queue' : 'Agent'}
498
+ {tab === 'queue' && queueCount > 0 && (
499
+ <span style={{ marginLeft: 4, color: colors.accent, fontWeight: 600 }}>({queueCount})</span>
500
+ )}
501
+ </button>
502
+ ))}
503
+ </div>
504
+ <button
505
+ type="button"
506
+ onClick={() => {
507
+ setRightPanel(null);
508
+ localStorage.setItem('rendiv-studio:right-panel', '');
509
+ }}
510
+ style={tabCloseStyle}
511
+ title="Close panel"
512
+ >
513
+ {'\u2715'}
514
+ </button>
515
+ </div>
516
+ <div style={{ display: rightPanel === 'queue' ? 'flex' : 'none', flex: 1, flexDirection: 'column' as const, overflow: 'hidden' }}>
517
+ <RenderQueue
518
+ jobs={renderJobs}
519
+ onCancel={handleCancelJob}
520
+ onRemove={handleRemoveJob}
521
+ onClear={handleClearFinished}
522
+ />
523
+ </div>
524
+ <div style={{ display: rightPanel === 'agent' ? 'flex' : 'none', flex: 1, flexDirection: 'column' as const, overflow: 'hidden' }}>
525
+ <Terminal open={rightPanel === 'agent'} />
526
+ </div>
527
+ </div>
528
+ )}
422
529
  </div>
423
530
 
424
531
  {/* Timeline — full-width resizable row */}
@@ -475,6 +582,44 @@ const StudioApp: React.FC = () => {
475
582
  );
476
583
  };
477
584
 
585
+ const rightPanelStyle: React.CSSProperties = {
586
+ position: 'relative',
587
+ height: '100%',
588
+ backgroundColor: colors.surface,
589
+ borderLeft: `1px solid ${colors.border}`,
590
+ display: 'flex',
591
+ flexDirection: 'column',
592
+ flexShrink: 0,
593
+ };
594
+
595
+ const panelResizeHandleStyle: React.CSSProperties = {
596
+ position: 'absolute',
597
+ top: 0,
598
+ left: 0,
599
+ width: 4,
600
+ height: '100%',
601
+ cursor: 'col-resize',
602
+ zIndex: 10,
603
+ };
604
+
605
+ const tabBarStyle: React.CSSProperties = {
606
+ display: 'flex',
607
+ justifyContent: 'space-between',
608
+ alignItems: 'center',
609
+ padding: '6px 8px',
610
+ borderBottom: `1px solid ${colors.border}`,
611
+ flexShrink: 0,
612
+ };
613
+
614
+ const tabCloseStyle: React.CSSProperties = {
615
+ background: 'none',
616
+ border: 'none',
617
+ color: colors.textSecondary,
618
+ cursor: 'pointer',
619
+ fontSize: 12,
620
+ padding: '2px 4px',
621
+ };
622
+
478
623
  export function createStudioApp(container: HTMLElement | null): void {
479
624
  if (!container) {
480
625
  throw new Error('Rendiv Studio: Could not find #root element');
@@ -0,0 +1,266 @@
1
+ import React, { useRef, useEffect, useCallback, useState } from 'react';
2
+ import { Terminal as XTerm } from '@xterm/xterm';
3
+ import { FitAddon } from '@xterm/addon-fit';
4
+ import '@xterm/xterm/css/xterm.css';
5
+ import { colors, fonts } from './styles';
6
+
7
+ export interface TerminalProps {
8
+ open: boolean;
9
+ }
10
+
11
+ type TerminalStatus = 'idle' | 'starting' | 'running' | 'exited' | 'error';
12
+
13
+ export const Terminal: React.FC<TerminalProps> = ({ open }) => {
14
+ const containerRef = useRef<HTMLDivElement>(null);
15
+ const xtermRef = useRef<XTerm | null>(null);
16
+ const fitAddonRef = useRef<FitAddon | null>(null);
17
+ const [status, setStatus] = useState<TerminalStatus>('idle');
18
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
19
+ const attachedRef = useRef(false);
20
+
21
+ // Create xterm instance once on mount
22
+ useEffect(() => {
23
+ const term = new XTerm({
24
+ cursorBlink: true,
25
+ fontSize: 11,
26
+ fontFamily: fonts.mono,
27
+ theme: {
28
+ background: colors.bg,
29
+ foreground: colors.textPrimary,
30
+ cursor: colors.accent,
31
+ cursorAccent: colors.bg,
32
+ selectionBackground: 'rgba(88, 166, 255, 0.3)',
33
+ black: '#484f58',
34
+ red: '#ff7b72',
35
+ green: '#3fb950',
36
+ yellow: '#d29922',
37
+ blue: '#58a6ff',
38
+ magenta: '#bc8cff',
39
+ cyan: '#39c5cf',
40
+ white: '#b1bac4',
41
+ brightBlack: '#6e7681',
42
+ brightRed: '#ffa198',
43
+ brightGreen: '#56d364',
44
+ brightYellow: '#e3b341',
45
+ brightBlue: '#79c0ff',
46
+ brightMagenta: '#d2a8ff',
47
+ brightCyan: '#56d4dd',
48
+ brightWhite: '#f0f6fc',
49
+ },
50
+ });
51
+
52
+ const fitAddon = new FitAddon();
53
+ term.loadAddon(fitAddon);
54
+
55
+ // Forward user keystrokes to server
56
+ term.onData((data) => {
57
+ const hot = (import.meta as any).hot;
58
+ hot?.send('rendiv:terminal-input', { data });
59
+ });
60
+
61
+ xtermRef.current = term;
62
+ fitAddonRef.current = fitAddon;
63
+
64
+ return () => {
65
+ term.dispose();
66
+ xtermRef.current = null;
67
+ fitAddonRef.current = null;
68
+ attachedRef.current = false;
69
+ };
70
+ }, []);
71
+
72
+ // Listen for server events (once, independent of open state)
73
+ useEffect(() => {
74
+ const hot = (import.meta as any).hot;
75
+ if (!hot) return;
76
+
77
+ const onOutput = (data: { data: string }) => {
78
+ xtermRef.current?.write(data.data);
79
+ };
80
+ const onStarted = () => {
81
+ setStatus('running');
82
+ setErrorMessage(null);
83
+ };
84
+ const onExited = () => {
85
+ setStatus('exited');
86
+ };
87
+ const onError = (data: { message: string }) => {
88
+ setStatus('error');
89
+ setErrorMessage(data.message);
90
+ };
91
+ const onStatusResponse = (data: { running: boolean }) => {
92
+ if (data.running) setStatus('running');
93
+ };
94
+
95
+ hot.on('rendiv:terminal-output', onOutput);
96
+ hot.on('rendiv:terminal-started', onStarted);
97
+ hot.on('rendiv:terminal-exited', onExited);
98
+ hot.on('rendiv:terminal-error', onError);
99
+ hot.on('rendiv:terminal-status-response', onStatusResponse);
100
+
101
+ // Check if terminal is already running (re-attach after page refresh)
102
+ hot.send('rendiv:terminal-status', {});
103
+
104
+ return () => {
105
+ hot.off?.('rendiv:terminal-output', onOutput);
106
+ hot.off?.('rendiv:terminal-started', onStarted);
107
+ hot.off?.('rendiv:terminal-exited', onExited);
108
+ hot.off?.('rendiv:terminal-error', onError);
109
+ hot.off?.('rendiv:terminal-status-response', onStatusResponse);
110
+ };
111
+ }, []);
112
+
113
+ // Attach/detach xterm to DOM when panel opens/closes
114
+ useEffect(() => {
115
+ if (!open || !containerRef.current || !xtermRef.current) return;
116
+
117
+ if (!attachedRef.current) {
118
+ xtermRef.current.open(containerRef.current);
119
+ attachedRef.current = true;
120
+ }
121
+
122
+ // Fit after a frame to ensure container has layout dimensions
123
+ requestAnimationFrame(() => {
124
+ fitAddonRef.current?.fit();
125
+ });
126
+ }, [open]);
127
+
128
+ // Resize terminal when container size changes
129
+ useEffect(() => {
130
+ if (!open || !containerRef.current) return;
131
+
132
+ const observer = new ResizeObserver(() => {
133
+ const term = xtermRef.current;
134
+ const fitAddon = fitAddonRef.current;
135
+ if (!term || !fitAddon) return;
136
+
137
+ fitAddon.fit();
138
+ const hot = (import.meta as any).hot;
139
+ hot?.send('rendiv:terminal-resize', { cols: term.cols, rows: term.rows });
140
+ });
141
+
142
+ observer.observe(containerRef.current);
143
+ return () => observer.disconnect();
144
+ }, [open]);
145
+
146
+ const handleStart = useCallback(() => {
147
+ setStatus('starting');
148
+ const term = xtermRef.current;
149
+ const hot = (import.meta as any).hot;
150
+ hot?.send('rendiv:terminal-start', {
151
+ cols: term?.cols ?? 80,
152
+ rows: term?.rows ?? 24,
153
+ });
154
+ }, []);
155
+
156
+ const handleRestart = useCallback(() => {
157
+ const hot = (import.meta as any).hot;
158
+ hot?.send('rendiv:terminal-stop', {});
159
+ xtermRef.current?.clear();
160
+ setTimeout(() => {
161
+ setStatus('starting');
162
+ const term = xtermRef.current;
163
+ hot?.send('rendiv:terminal-start', {
164
+ cols: term?.cols ?? 80,
165
+ rows: term?.rows ?? 24,
166
+ });
167
+ }, 300);
168
+ }, []);
169
+
170
+ const showOverlay = status === 'idle' || status === 'error' || status === 'exited';
171
+
172
+ return (
173
+ <div style={panelStyle}>
174
+ {/* Actions bar — only when there's something to act on */}
175
+ {(status === 'running' || status === 'exited') && (
176
+ <div style={actionsBarStyle}>
177
+ <button style={actionBtnStyle} onClick={handleRestart}>Restart</button>
178
+ </div>
179
+ )}
180
+
181
+ <div style={terminalBodyStyle}>
182
+ {showOverlay && (
183
+ <div style={overlayStyle}>
184
+ {status === 'error' && errorMessage && (
185
+ <div style={{ color: colors.error, fontSize: 13, marginBottom: 12, textAlign: 'center' as const }}>
186
+ {errorMessage}
187
+ </div>
188
+ )}
189
+ {status === 'exited' && (
190
+ <div style={{ color: colors.textSecondary, fontSize: 13, marginBottom: 12 }}>
191
+ Claude Code has exited
192
+ </div>
193
+ )}
194
+ <button style={startBtnStyle} onClick={status === 'exited' ? handleRestart : handleStart}>
195
+ {status === 'exited' ? 'Restart Claude Code' : status === 'error' ? 'Retry' : 'Launch Claude Code'}
196
+ </button>
197
+ </div>
198
+ )}
199
+ <div ref={containerRef} style={xtermContainerStyle} />
200
+ </div>
201
+ </div>
202
+ );
203
+ };
204
+
205
+ const panelStyle: React.CSSProperties = {
206
+ display: 'flex',
207
+ flexDirection: 'column',
208
+ flex: 1,
209
+ overflow: 'hidden',
210
+ };
211
+
212
+ const actionsBarStyle: React.CSSProperties = {
213
+ display: 'flex',
214
+ justifyContent: 'flex-end',
215
+ padding: '4px 8px',
216
+ borderBottom: `1px solid ${colors.border}`,
217
+ flexShrink: 0,
218
+ };
219
+
220
+ const terminalBodyStyle: React.CSSProperties = {
221
+ flex: 1,
222
+ position: 'relative',
223
+ backgroundColor: colors.bg,
224
+ overflow: 'hidden',
225
+ };
226
+
227
+ const xtermContainerStyle: React.CSSProperties = {
228
+ width: '100%',
229
+ height: '100%',
230
+ padding: '4px 0 4px 4px',
231
+ };
232
+
233
+ const overlayStyle: React.CSSProperties = {
234
+ position: 'absolute',
235
+ inset: 0,
236
+ display: 'flex',
237
+ flexDirection: 'column',
238
+ alignItems: 'center',
239
+ justifyContent: 'center',
240
+ zIndex: 10,
241
+ backgroundColor: colors.bg,
242
+ };
243
+
244
+ const startBtnStyle: React.CSSProperties = {
245
+ padding: '8px 20px',
246
+ fontSize: 13,
247
+ fontWeight: 600,
248
+ color: '#fff',
249
+ backgroundColor: colors.accentMuted,
250
+ border: 'none',
251
+ borderRadius: 6,
252
+ cursor: 'pointer',
253
+ fontFamily: fonts.sans,
254
+ };
255
+
256
+ const actionBtnStyle: React.CSSProperties = {
257
+ background: 'none',
258
+ border: `1px solid ${colors.border}`,
259
+ color: colors.textSecondary,
260
+ cursor: 'pointer',
261
+ fontSize: 10,
262
+ padding: '2px 8px',
263
+ borderRadius: 4,
264
+ fontFamily: fonts.sans,
265
+ };
266
+