@aion0/forge 0.1.0

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 (80) hide show
  1. package/CLAUDE.md +4 -0
  2. package/README.md +264 -0
  3. package/app/api/auth/[...nextauth]/route.ts +3 -0
  4. package/app/api/claude/[id]/route.ts +31 -0
  5. package/app/api/claude/[id]/stream/route.ts +63 -0
  6. package/app/api/claude/route.ts +28 -0
  7. package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
  8. package/app/api/claude-sessions/[projectName]/route.ts +37 -0
  9. package/app/api/claude-sessions/sync/route.ts +17 -0
  10. package/app/api/flows/route.ts +6 -0
  11. package/app/api/flows/run/route.ts +19 -0
  12. package/app/api/notify/test/route.ts +33 -0
  13. package/app/api/projects/route.ts +7 -0
  14. package/app/api/sessions/[id]/chat/route.ts +64 -0
  15. package/app/api/sessions/[id]/messages/route.ts +9 -0
  16. package/app/api/sessions/[id]/route.ts +17 -0
  17. package/app/api/sessions/route.ts +20 -0
  18. package/app/api/settings/route.ts +15 -0
  19. package/app/api/status/route.ts +12 -0
  20. package/app/api/tasks/[id]/route.ts +36 -0
  21. package/app/api/tasks/[id]/stream/route.ts +77 -0
  22. package/app/api/tasks/link/route.ts +37 -0
  23. package/app/api/tasks/route.ts +43 -0
  24. package/app/api/tasks/session/route.ts +14 -0
  25. package/app/api/templates/route.ts +6 -0
  26. package/app/api/tunnel/route.ts +20 -0
  27. package/app/api/watchers/route.ts +33 -0
  28. package/app/globals.css +26 -0
  29. package/app/icon.svg +26 -0
  30. package/app/layout.tsx +17 -0
  31. package/app/login/page.tsx +61 -0
  32. package/app/page.tsx +9 -0
  33. package/cli/mw.ts +377 -0
  34. package/components/ChatPanel.tsx +191 -0
  35. package/components/ClaudeTerminal.tsx +267 -0
  36. package/components/Dashboard.tsx +270 -0
  37. package/components/MarkdownContent.tsx +57 -0
  38. package/components/NewSessionModal.tsx +93 -0
  39. package/components/NewTaskModal.tsx +456 -0
  40. package/components/ProjectList.tsx +108 -0
  41. package/components/SessionList.tsx +74 -0
  42. package/components/SessionView.tsx +655 -0
  43. package/components/SettingsModal.tsx +366 -0
  44. package/components/StatusBar.tsx +99 -0
  45. package/components/TaskBoard.tsx +110 -0
  46. package/components/TaskDetail.tsx +351 -0
  47. package/components/TunnelToggle.tsx +163 -0
  48. package/components/WebTerminal.tsx +1069 -0
  49. package/docs/LOCAL-DEPLOY.md +144 -0
  50. package/docs/roadmap-multi-agent-workflow.md +330 -0
  51. package/instrumentation.ts +14 -0
  52. package/lib/auth.ts +47 -0
  53. package/lib/claude-process.ts +352 -0
  54. package/lib/claude-sessions.ts +267 -0
  55. package/lib/cloudflared.ts +218 -0
  56. package/lib/flows.ts +86 -0
  57. package/lib/init.ts +82 -0
  58. package/lib/notify.ts +75 -0
  59. package/lib/password.ts +77 -0
  60. package/lib/projects.ts +86 -0
  61. package/lib/session-manager.ts +156 -0
  62. package/lib/session-watcher.ts +345 -0
  63. package/lib/settings.ts +44 -0
  64. package/lib/task-manager.ts +668 -0
  65. package/lib/telegram-bot.ts +912 -0
  66. package/lib/terminal-server.ts +70 -0
  67. package/lib/terminal-standalone.ts +363 -0
  68. package/middleware.ts +33 -0
  69. package/next-env.d.ts +6 -0
  70. package/next.config.ts +16 -0
  71. package/package.json +66 -0
  72. package/postcss.config.mjs +7 -0
  73. package/src/config/index.ts +119 -0
  74. package/src/core/db/database.ts +133 -0
  75. package/src/core/memory/strategy.ts +32 -0
  76. package/src/core/providers/chat.ts +65 -0
  77. package/src/core/providers/registry.ts +60 -0
  78. package/src/core/session/manager.ts +190 -0
  79. package/src/types/index.ts +128 -0
  80. package/tsconfig.json +41 -0
@@ -0,0 +1,1069 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback, memo, useImperativeHandle, forwardRef } from 'react';
4
+ import { Terminal } from '@xterm/xterm';
5
+ import { FitAddon } from '@xterm/addon-fit';
6
+ import '@xterm/xterm/css/xterm.css';
7
+
8
+ // ─── Imperative API for parent components ────────────────────
9
+
10
+ export interface WebTerminalHandle {
11
+ openSessionInTerminal: (sessionId: string, projectPath: string) => void;
12
+ }
13
+
14
+ // ─── Types ───────────────────────────────────────────────────
15
+
16
+ interface TmuxSession {
17
+ name: string;
18
+ created: string;
19
+ attached: boolean;
20
+ windows: number;
21
+ }
22
+
23
+ type SplitNode =
24
+ | { type: 'terminal'; id: number; sessionName?: string }
25
+ | { type: 'split'; id: number; direction: 'horizontal' | 'vertical'; ratio: number; first: SplitNode; second: SplitNode };
26
+
27
+ interface TabState {
28
+ id: number;
29
+ label: string;
30
+ tree: SplitNode;
31
+ ratios: Record<number, number>;
32
+ activeId: number;
33
+ }
34
+
35
+ // ─── Layout persistence ──────────────────────────────────────
36
+
37
+ function getWsUrl() {
38
+ if (typeof window === 'undefined') return 'ws://localhost:3001';
39
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
40
+ const wsHost = window.location.hostname;
41
+ // When accessed via tunnel or non-localhost, use the Next.js proxy path
42
+ // so the WS goes through the same origin (no need to expose port 3001)
43
+ if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
44
+ return `${wsProtocol}//${window.location.host}/terminal-ws`;
45
+ }
46
+ return `${wsProtocol}//${wsHost}:3001`;
47
+ }
48
+
49
+ /** Load shared terminal state from server */
50
+ function loadSharedState(): Promise<{ tabs: TabState[]; activeTabId: number; sessionLabels: Record<string, string> } | null> {
51
+ return new Promise((resolve) => {
52
+ const timeout = setTimeout(() => resolve(null), 3000);
53
+ try {
54
+ const ws = new WebSocket(getWsUrl());
55
+ ws.onopen = () => ws.send(JSON.stringify({ type: 'load-state' }));
56
+ ws.onmessage = (e) => {
57
+ clearTimeout(timeout);
58
+ try {
59
+ const msg = JSON.parse(e.data);
60
+ if (msg.type === 'terminal-state' && msg.data) {
61
+ const d = msg.data;
62
+ if (Array.isArray(d.tabs) && d.tabs.length > 0 && typeof d.activeTabId === 'number') {
63
+ resolve({ tabs: d.tabs, activeTabId: d.activeTabId, sessionLabels: d.sessionLabels || {} });
64
+ ws.close();
65
+ return;
66
+ }
67
+ }
68
+ } catch {}
69
+ resolve(null);
70
+ ws.close();
71
+ };
72
+ ws.onerror = () => { clearTimeout(timeout); resolve(null); };
73
+ } catch {
74
+ clearTimeout(timeout);
75
+ resolve(null);
76
+ }
77
+ });
78
+ }
79
+
80
+ /** Save shared terminal state to server (fire-and-forget) */
81
+ function saveSharedState(tabs: TabState[], activeTabId: number, sessionLabels: Record<string, string>) {
82
+ try {
83
+ const ws = new WebSocket(getWsUrl());
84
+ ws.onopen = () => {
85
+ ws.send(JSON.stringify({ type: 'save-state', data: { tabs, activeTabId, sessionLabels } }));
86
+ setTimeout(() => ws.close(), 200);
87
+ };
88
+ ws.onerror = () => ws.close();
89
+ } catch {}
90
+ }
91
+
92
+ // ─── Split tree helpers ──────────────────────────────────────
93
+
94
+ let nextId = 1;
95
+
96
+ function initNextId(tree: SplitNode) {
97
+ if (tree.type === 'terminal') {
98
+ nextId = Math.max(nextId, tree.id + 1);
99
+ } else {
100
+ nextId = Math.max(nextId, tree.id + 1);
101
+ initNextId(tree.first);
102
+ initNextId(tree.second);
103
+ }
104
+ }
105
+
106
+ function initNextIdFromTabs(tabs: TabState[]) {
107
+ for (const tab of tabs) {
108
+ nextId = Math.max(nextId, tab.id + 1);
109
+ initNextId(tab.tree);
110
+ }
111
+ }
112
+
113
+ function makeTerminal(sessionName?: string): SplitNode {
114
+ return { type: 'terminal', id: nextId++, sessionName };
115
+ }
116
+
117
+ function makeSplit(direction: 'horizontal' | 'vertical', first: SplitNode, second: SplitNode): SplitNode {
118
+ return { type: 'split', id: nextId++, direction, ratio: 0.5, first, second };
119
+ }
120
+
121
+ function splitNodeById(tree: SplitNode, targetId: number, direction: 'horizontal' | 'vertical'): SplitNode {
122
+ if (tree.type === 'terminal') {
123
+ if (tree.id === targetId) return makeSplit(direction, tree, makeTerminal());
124
+ return tree;
125
+ }
126
+ return { ...tree, first: splitNodeById(tree.first, targetId, direction), second: splitNodeById(tree.second, targetId, direction) };
127
+ }
128
+
129
+ function removeNodeById(tree: SplitNode, targetId: number): SplitNode | null {
130
+ if (tree.type === 'terminal') return tree.id === targetId ? null : tree;
131
+ if (tree.first.type === 'terminal' && tree.first.id === targetId) return tree.second;
132
+ if (tree.second.type === 'terminal' && tree.second.id === targetId) return tree.first;
133
+ const f = removeNodeById(tree.first, targetId);
134
+ if (f !== tree.first) return f ? { ...tree, first: f } : tree.second;
135
+ const s = removeNodeById(tree.second, targetId);
136
+ if (s !== tree.second) return s ? { ...tree, second: s } : tree.first;
137
+ return tree;
138
+ }
139
+
140
+ function updateSessionName(tree: SplitNode, targetId: number, sessionName: string): SplitNode {
141
+ if (tree.type === 'terminal') {
142
+ return tree.id === targetId ? { ...tree, sessionName } : tree;
143
+ }
144
+ return { ...tree, first: updateSessionName(tree.first, targetId, sessionName), second: updateSessionName(tree.second, targetId, sessionName) };
145
+ }
146
+
147
+ function countTerminals(tree: SplitNode): number {
148
+ if (tree.type === 'terminal') return 1;
149
+ return countTerminals(tree.first) + countTerminals(tree.second);
150
+ }
151
+
152
+ function firstTerminalId(n: SplitNode): number {
153
+ return n.type === 'terminal' ? n.id : firstTerminalId(n.first);
154
+ }
155
+
156
+ function collectSessionNames(tree: SplitNode): string[] {
157
+ if (tree.type === 'terminal') return tree.sessionName ? [tree.sessionName] : [];
158
+ return [...collectSessionNames(tree.first), ...collectSessionNames(tree.second)];
159
+ }
160
+
161
+ function collectAllSessionNames(tabs: TabState[]): string[] {
162
+ return tabs.flatMap(t => collectSessionNames(t.tree));
163
+ }
164
+
165
+ // ─── Pending commands for new terminal panes ────────────────
166
+
167
+ const pendingCommands = new Map<number, string>();
168
+
169
+ // ─── Global drag lock — suppress terminal fit() during split drag ──
170
+
171
+ let globalDragging = false;
172
+
173
+ // ─── Main component ─────────────────────────────────────────
174
+
175
+ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, ref) {
176
+ const [tabs, setTabs] = useState<TabState[]>(() => {
177
+ const tree = makeTerminal();
178
+ return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
179
+ });
180
+ const [activeTabId, setActiveTabId] = useState(() => tabs[0]?.id || 1);
181
+ const [hydrated, setHydrated] = useState(false);
182
+ const [tmuxSessions, setTmuxSessions] = useState<TmuxSession[]>([]);
183
+ const [showSessionPicker, setShowSessionPicker] = useState(false);
184
+ const [editingTabId, setEditingTabId] = useState<number | null>(null);
185
+ const [editingLabel, setEditingLabel] = useState('');
186
+ const [closeConfirm, setCloseConfirm] = useState<{ tabId: number; sessions: string[] } | null>(null);
187
+ const sessionLabelsRef = useRef<Record<string, string>>({});
188
+ const dragTabRef = useRef<number | null>(null);
189
+ const [refreshKeys, setRefreshKeys] = useState<Record<number, number>>({});
190
+
191
+ // Restore shared state from server after mount
192
+ useEffect(() => {
193
+ loadSharedState().then(saved => {
194
+ if (saved && saved.tabs.length > 0) {
195
+ initNextIdFromTabs(saved.tabs);
196
+ setTabs(saved.tabs);
197
+ setActiveTabId(saved.activeTabId);
198
+ sessionLabelsRef.current = saved.sessionLabels || {};
199
+ }
200
+ setHydrated(true);
201
+ });
202
+ }, []);
203
+
204
+ // Persist to server on changes (debounced, only after hydration)
205
+ const saveTimerRef = useRef(0);
206
+ useEffect(() => {
207
+ if (!hydrated) return;
208
+ // Sync session labels ref
209
+ const labels = { ...sessionLabelsRef.current };
210
+ for (const tab of tabs) {
211
+ for (const sn of collectSessionNames(tab.tree)) {
212
+ labels[sn] = tab.label;
213
+ }
214
+ }
215
+ sessionLabelsRef.current = labels;
216
+ // Debounced save to server
217
+ clearTimeout(saveTimerRef.current);
218
+ saveTimerRef.current = window.setTimeout(() => {
219
+ saveSharedState(tabs, activeTabId, labels);
220
+ }, 500);
221
+ }, [tabs, activeTabId, hydrated]);
222
+
223
+ const activeTab = tabs.find(t => t.id === activeTabId) || tabs[0];
224
+
225
+ // ─── Imperative handle for parent ─────────────────────
226
+
227
+ useImperativeHandle(ref, () => ({
228
+ openSessionInTerminal(sessionId: string, projectPath: string) {
229
+ const tree = makeTerminal();
230
+ const paneId = firstTerminalId(tree);
231
+ const cmd = `cd ${projectPath} && claude --resume ${sessionId}\n`;
232
+ pendingCommands.set(paneId, cmd);
233
+ const newTab: TabState = {
234
+ id: nextId++,
235
+ label: `claude ${sessionId.slice(0, 8)}`,
236
+ tree,
237
+ ratios: {},
238
+ activeId: paneId,
239
+ };
240
+ setTabs(prev => [...prev, newTab]);
241
+ setActiveTabId(newTab.id);
242
+ },
243
+ }));
244
+
245
+ // ─── Tab operations ───────────────────────────────────
246
+
247
+ const addTab = useCallback(() => {
248
+ const tree = makeTerminal();
249
+ const tabNum = tabs.length + 1;
250
+ const newTab: TabState = { id: nextId++, label: `Terminal ${tabNum}`, tree, ratios: {}, activeId: firstTerminalId(tree) };
251
+ setTabs(prev => [...prev, newTab]);
252
+ setActiveTabId(newTab.id);
253
+ }, [tabs.length]);
254
+
255
+ const removeTab = useCallback((tabId: number) => {
256
+ setTabs(prev => {
257
+ if (prev.length <= 1) return prev;
258
+ const filtered = prev.filter(t => t.id !== tabId);
259
+ // Also fix activeTabId if needed
260
+ setActiveTabId(curActive => {
261
+ if (curActive === tabId) {
262
+ const idx = prev.findIndex(t => t.id === tabId);
263
+ const next = prev[idx - 1] || prev[idx + 1];
264
+ return next?.id || prev[0]?.id || 0;
265
+ }
266
+ return curActive;
267
+ });
268
+ return filtered;
269
+ });
270
+ }, []);
271
+
272
+ const closeTab = useCallback((tabId: number) => {
273
+ setTabs(prev => {
274
+ const tab = prev.find(t => t.id === tabId);
275
+ if (!tab) return prev;
276
+ const sessions = collectSessionNames(tab.tree);
277
+ if (sessions.length > 0) {
278
+ setCloseConfirm({ tabId, sessions });
279
+ return prev; // don't remove yet, show dialog
280
+ }
281
+ // No sessions, just close directly
282
+ if (prev.length <= 1) return prev;
283
+ const filtered = prev.filter(t => t.id !== tabId);
284
+ setActiveTabId(curActive => {
285
+ if (curActive === tabId) {
286
+ const idx = prev.findIndex(t => t.id === tabId);
287
+ const next = prev[idx - 1] || prev[idx + 1];
288
+ return next?.id || prev[0]?.id || 0;
289
+ }
290
+ return curActive;
291
+ });
292
+ return filtered;
293
+ });
294
+ }, []);
295
+
296
+ const closeTabWithAction = useCallback((action: 'detach' | 'kill') => {
297
+ if (!closeConfirm) return;
298
+ const { tabId, sessions } = closeConfirm;
299
+ if (action === 'kill') {
300
+ for (const sn of sessions) {
301
+ const ws = new WebSocket(getWsUrl());
302
+ ws.onopen = () => {
303
+ ws.send(JSON.stringify({ type: 'kill', sessionName: sn }));
304
+ setTimeout(() => ws.close(), 500);
305
+ };
306
+ }
307
+ }
308
+ removeTab(tabId);
309
+ setCloseConfirm(null);
310
+ }, [closeConfirm, removeTab]);
311
+
312
+ const moveTab = useCallback((fromId: number, toId: number) => {
313
+ if (fromId === toId) return;
314
+ setTabs(prev => {
315
+ const fromIdx = prev.findIndex(t => t.id === fromId);
316
+ const toIdx = prev.findIndex(t => t.id === toId);
317
+ if (fromIdx < 0 || toIdx < 0) return prev;
318
+ const next = [...prev];
319
+ const [moved] = next.splice(fromIdx, 1);
320
+ next.splice(toIdx, 0, moved);
321
+ return next;
322
+ });
323
+ }, []);
324
+
325
+ const renameTab = useCallback((tabId: number, newLabel: string) => {
326
+ const label = newLabel.trim();
327
+ if (!label) return;
328
+ setTabs(prev => {
329
+ const tab = prev.find(t => t.id === tabId);
330
+ if (tab) {
331
+ const sessions = collectSessionNames(tab.tree);
332
+ for (const sn of sessions) {
333
+ sessionLabelsRef.current[sn] = label;
334
+ }
335
+ }
336
+ return prev.map(t => t.id === tabId ? { ...t, label } : t);
337
+ });
338
+ setEditingTabId(null);
339
+ }, []);
340
+
341
+ // ─── Update active tab's state ─────────────────────────
342
+
343
+ const updateActiveTab = useCallback((updater: (tab: TabState) => TabState) => {
344
+ setTabs(prev => prev.map(t => t.id === activeTabId ? updater(t) : t));
345
+ }, [activeTabId]);
346
+
347
+ const onSessionConnected = useCallback((paneId: number, sessionName: string) => {
348
+ setTabs(prev => prev.map(t => ({
349
+ ...t,
350
+ tree: updateSessionName(t.tree, paneId, sessionName),
351
+ })));
352
+ }, []);
353
+
354
+ const refreshSessions = useCallback(() => {
355
+ // Use a short-lived WS to list sessions, with abort guard
356
+ let closed = false;
357
+ const ws = new WebSocket(getWsUrl());
358
+ const timeout = setTimeout(() => { closed = true; ws.close(); }, 3000);
359
+ ws.onopen = () => {
360
+ if (closed) return;
361
+ ws.send(JSON.stringify({ type: 'list' }));
362
+ };
363
+ ws.onmessage = (e) => {
364
+ clearTimeout(timeout);
365
+ try {
366
+ const msg = JSON.parse(e.data);
367
+ if (msg.type === 'sessions') setTmuxSessions(msg.sessions);
368
+ } catch {}
369
+ ws.close();
370
+ };
371
+ ws.onerror = () => { clearTimeout(timeout); ws.close(); };
372
+ }, []);
373
+
374
+ const onSplit = useCallback((dir: 'horizontal' | 'vertical') => {
375
+ if (!activeTab) return;
376
+ updateActiveTab(t => ({ ...t, tree: splitNodeById(t.tree, t.activeId, dir) }));
377
+ }, [activeTab, updateActiveTab]);
378
+
379
+ const onClosePane = useCallback(() => {
380
+ if (!activeTab) return;
381
+ updateActiveTab(t => {
382
+ if (countTerminals(t.tree) <= 1) return t;
383
+ const newTree = removeNodeById(t.tree, t.activeId) || t.tree;
384
+ return { ...t, tree: newTree, activeId: firstTerminalId(newTree) };
385
+ });
386
+ }, [activeTab, updateActiveTab]);
387
+
388
+ const setActiveId = useCallback((id: number) => {
389
+ updateActiveTab(t => ({ ...t, activeId: id }));
390
+ }, [updateActiveTab]);
391
+
392
+ const setRatios = useCallback((updater: React.SetStateAction<Record<number, number>>) => {
393
+ updateActiveTab(t => ({
394
+ ...t,
395
+ ratios: typeof updater === 'function' ? updater(t.ratios) : updater,
396
+ }));
397
+ }, [updateActiveTab]);
398
+
399
+ const usedSessions = collectAllSessionNames(tabs);
400
+
401
+ // Auto-refresh tmux sessions periodically to show detached count
402
+ useEffect(() => {
403
+ if (!hydrated) return;
404
+ refreshSessions();
405
+ const timer = setInterval(refreshSessions, 10000);
406
+ return () => clearInterval(timer);
407
+ }, [hydrated, refreshSessions]);
408
+
409
+ const detachedCount = tmuxSessions.filter(s => !usedSessions.includes(s.name)).length;
410
+
411
+ return (
412
+ <div className="h-full w-full flex-1 flex flex-col bg-[#1a1a2e]">
413
+ {/* Tab bar + toolbar */}
414
+ <div className="flex items-center bg-[#12122a] border-b border-[#2a2a4a] shrink-0">
415
+ {/* Tabs */}
416
+ <div className="flex items-center overflow-x-auto">
417
+ {tabs.map(tab => (
418
+ <div
419
+ key={tab.id}
420
+ draggable={editingTabId !== tab.id}
421
+ onDragStart={(e) => {
422
+ dragTabRef.current = tab.id;
423
+ e.dataTransfer.effectAllowed = 'move';
424
+ // Make drag image semi-transparent
425
+ if (e.currentTarget instanceof HTMLElement) {
426
+ e.dataTransfer.setDragImage(e.currentTarget, 0, 0);
427
+ }
428
+ }}
429
+ onDragOver={(e) => {
430
+ e.preventDefault();
431
+ e.dataTransfer.dropEffect = 'move';
432
+ }}
433
+ onDrop={(e) => {
434
+ e.preventDefault();
435
+ if (dragTabRef.current !== null) {
436
+ moveTab(dragTabRef.current, tab.id);
437
+ dragTabRef.current = null;
438
+ }
439
+ }}
440
+ onDragEnd={() => { dragTabRef.current = null; }}
441
+ className={`flex items-center gap-1 px-3 py-1 text-[11px] cursor-pointer border-r border-[#2a2a4a] select-none ${
442
+ tab.id === activeTabId
443
+ ? 'bg-[#1a1a2e] text-white'
444
+ : 'text-gray-500 hover:text-gray-300 hover:bg-[#1a1a2e]/50'
445
+ }`}
446
+ onClick={() => setActiveTabId(tab.id)}
447
+ >
448
+ {editingTabId === tab.id ? (
449
+ <input
450
+ autoFocus
451
+ value={editingLabel}
452
+ onChange={(e) => setEditingLabel(e.target.value)}
453
+ onBlur={() => renameTab(tab.id, editingLabel)}
454
+ onKeyDown={(e) => {
455
+ if (e.key === 'Enter') renameTab(tab.id, editingLabel);
456
+ if (e.key === 'Escape') setEditingTabId(null);
457
+ }}
458
+ onClick={(e) => e.stopPropagation()}
459
+ className="bg-transparent border border-[#4a4a6a] rounded px-1 text-[11px] text-white outline-none w-20"
460
+ />
461
+ ) : (
462
+ <span
463
+ className="truncate max-w-[100px]"
464
+ onDoubleClick={(e) => {
465
+ e.stopPropagation();
466
+ setEditingTabId(tab.id);
467
+ setEditingLabel(tab.label);
468
+ }}
469
+ >
470
+ {tab.label}
471
+ </span>
472
+ )}
473
+ {tabs.length > 1 && (
474
+ <button
475
+ onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
476
+ className="text-[9px] text-gray-600 hover:text-red-400 ml-1"
477
+ >
478
+ x
479
+ </button>
480
+ )}
481
+ </div>
482
+ ))}
483
+ <button
484
+ onClick={addTab}
485
+ className="px-2 py-1 text-[11px] text-gray-500 hover:text-white hover:bg-[#2a2a4a]"
486
+ title="New terminal tab"
487
+ >
488
+ +
489
+ </button>
490
+ </div>
491
+
492
+ {/* Toolbar */}
493
+ <div className="flex items-center gap-1 px-2 ml-auto">
494
+ <button onClick={() => onSplit('vertical')} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded">
495
+ Split Right
496
+ </button>
497
+ <button onClick={() => onSplit('horizontal')} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded">
498
+ Split Down
499
+ </button>
500
+ <button
501
+ onClick={() => { refreshSessions(); setShowSessionPicker(v => !v); }}
502
+ className={`text-[10px] px-2 py-0.5 rounded relative ${showSessionPicker ? 'text-white bg-[#7c5bf0]/30' : 'text-gray-400 hover:text-white hover:bg-[#2a2a4a]'}`}
503
+ >
504
+ Sessions
505
+ {detachedCount > 0 && (
506
+ <span className="ml-1 inline-flex items-center justify-center min-w-[14px] h-[14px] rounded-full bg-yellow-500/80 text-[8px] text-black font-bold px-1">
507
+ {detachedCount}
508
+ </span>
509
+ )}
510
+ </button>
511
+ <button
512
+ onClick={() => {
513
+ if (!activeTab) return;
514
+ setRefreshKeys(prev => ({ ...prev, [activeTab.activeId]: (prev[activeTab.activeId] || 0) + 1 }));
515
+ }}
516
+ className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded"
517
+ title="Refresh terminal (fix garbled display)"
518
+ >
519
+ Refresh
520
+ </button>
521
+ {activeTab && countTerminals(activeTab.tree) > 1 && (
522
+ <button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-red-400 hover:bg-[#2a2a4a] rounded">
523
+ Close Pane
524
+ </button>
525
+ )}
526
+ </div>
527
+ </div>
528
+
529
+ {/* Session management panel */}
530
+ {showSessionPicker && (
531
+ <div className="bg-[#0e0e20] border-b border-[#2a2a4a] px-3 py-2 shrink-0 max-h-48 overflow-y-auto">
532
+ <div className="flex items-center justify-between mb-2">
533
+ <span className="text-[10px] text-gray-400 font-semibold uppercase">Tmux Sessions</span>
534
+ <button
535
+ onClick={refreshSessions}
536
+ className="text-[9px] text-gray-500 hover:text-white"
537
+ >
538
+ Refresh
539
+ </button>
540
+ </div>
541
+ {tmuxSessions.length === 0 ? (
542
+ <p className="text-[10px] text-gray-500">No persistent sessions. New terminals auto-create tmux sessions.</p>
543
+ ) : (
544
+ <table className="w-full text-[10px]">
545
+ <thead>
546
+ <tr className="text-gray-500 text-left border-b border-[#2a2a4a]">
547
+ <th className="py-1 pr-3 font-medium">Session</th>
548
+ <th className="py-1 pr-3 font-medium">Created</th>
549
+ <th className="py-1 pr-3 font-medium">Status</th>
550
+ <th className="py-1 font-medium text-right">Actions</th>
551
+ </tr>
552
+ </thead>
553
+ <tbody>
554
+ {tmuxSessions.map(s => {
555
+ const inUse = usedSessions.includes(s.name);
556
+ const savedLabel = sessionLabelsRef.current[s.name];
557
+ return (
558
+ <tr key={s.name} className="border-b border-[#2a2a4a]/50 hover:bg-[#1a1a2e]">
559
+ <td className="py-1.5 pr-3 text-gray-300">
560
+ {savedLabel ? (
561
+ <><span>{savedLabel}</span> <span className="font-mono text-gray-600 text-[9px]">{s.name.replace('mw-', '')}</span></>
562
+ ) : (
563
+ <span className="font-mono">{s.name.replace('mw-', '')}</span>
564
+ )}
565
+ </td>
566
+ <td className="py-1.5 pr-3 text-gray-500">{new Date(s.created).toLocaleString()}</td>
567
+ <td className="py-1.5 pr-3">
568
+ {inUse ? (
569
+ <span className="text-green-400">● connected</span>
570
+ ) : (
571
+ <span className="text-yellow-500">○ detached</span>
572
+ )}
573
+ </td>
574
+ <td className="py-1.5 text-right space-x-2">
575
+ {!inUse && (
576
+ <button
577
+ onClick={() => {
578
+ // Open in a new tab, restore saved label if available
579
+ const tree = makeTerminal(s.name);
580
+ const label = sessionLabelsRef.current[s.name] || s.name.replace('mw-', '');
581
+ const newTab: TabState = { id: nextId++, label, tree, ratios: {}, activeId: firstTerminalId(tree) };
582
+ setTabs(prev => [...prev, newTab]);
583
+ setActiveTabId(newTab.id);
584
+ setShowSessionPicker(false);
585
+ }}
586
+ className="text-[#7c5bf0] hover:text-white"
587
+ >
588
+ Attach
589
+ </button>
590
+ )}
591
+ <button
592
+ onClick={() => {
593
+ if (!confirm(`Kill session ${s.name}?`)) return;
594
+ const ws = new WebSocket(getWsUrl());
595
+ ws.onopen = () => {
596
+ ws.send(JSON.stringify({ type: 'kill', sessionName: s.name }));
597
+ setTimeout(() => { ws.close(); refreshSessions(); }, 500);
598
+ };
599
+ }}
600
+ className="text-red-400/60 hover:text-red-400"
601
+ >
602
+ Kill
603
+ </button>
604
+ </td>
605
+ </tr>
606
+ );
607
+ })}
608
+ </tbody>
609
+ </table>
610
+ )}
611
+ </div>
612
+ )}
613
+
614
+ {/* Close confirmation dialog */}
615
+ {closeConfirm && (
616
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setCloseConfirm(null)}>
617
+ <div className="bg-[#1a1a2e] border border-[#2a2a4a] rounded-lg p-4 shadow-xl max-w-sm" onClick={(e) => e.stopPropagation()}>
618
+ <h3 className="text-sm font-semibold text-white mb-2">Close Tab</h3>
619
+ <p className="text-xs text-gray-400 mb-1">
620
+ This tab has {closeConfirm.sessions.length} active session{closeConfirm.sessions.length > 1 ? 's' : ''}:
621
+ </p>
622
+ <div className="text-[10px] text-gray-500 font-mono mb-3 space-y-0.5">
623
+ {closeConfirm.sessions.map(s => (
624
+ <div key={s}>• {s.replace('mw-', '')}</div>
625
+ ))}
626
+ </div>
627
+ <div className="flex gap-2">
628
+ <button
629
+ onClick={() => closeTabWithAction('detach')}
630
+ className="flex-1 px-3 py-1.5 text-[11px] rounded bg-[#2a2a4a] text-gray-300 hover:bg-[#3a3a5a] hover:text-white"
631
+ >
632
+ Hide Tab
633
+ <span className="block text-[9px] text-gray-500 mt-0.5">Session keeps running</span>
634
+ </button>
635
+ <button
636
+ onClick={() => closeTabWithAction('kill')}
637
+ className="flex-1 px-3 py-1.5 text-[11px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
638
+ >
639
+ Kill Session
640
+ <span className="block text-[9px] text-red-400/60 mt-0.5">Permanently close</span>
641
+ </button>
642
+ </div>
643
+ <button
644
+ onClick={() => setCloseConfirm(null)}
645
+ className="w-full mt-2 px-3 py-1 text-[10px] text-gray-500 hover:text-gray-300"
646
+ >
647
+ Cancel
648
+ </button>
649
+ </div>
650
+ </div>
651
+ )}
652
+
653
+ {/* Terminal panes — render all tabs, hide inactive */}
654
+ {tabs.map(tab => (
655
+ <div key={tab.id} className={`flex-1 min-h-0 ${tab.id === activeTabId ? '' : 'hidden'}`}>
656
+ <PaneRenderer
657
+ node={tab.tree}
658
+ activeId={tab.activeId}
659
+ onFocus={tab.id === activeTabId ? setActiveId : () => {}}
660
+ ratios={tab.ratios}
661
+ setRatios={tab.id === activeTabId ? setRatios : () => {}}
662
+ onSessionConnected={onSessionConnected}
663
+ refreshKeys={refreshKeys}
664
+ />
665
+ </div>
666
+ ))}
667
+ </div>
668
+ );
669
+ });
670
+
671
+ export default WebTerminal;
672
+
673
+ // ─── Pane renderer ───────────────────────────────────────────
674
+
675
+ function PaneRenderer({
676
+ node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys,
677
+ }: {
678
+ node: SplitNode;
679
+ activeId: number;
680
+ onFocus: (id: number) => void;
681
+ ratios: Record<number, number>;
682
+ setRatios: React.Dispatch<React.SetStateAction<Record<number, number>>>;
683
+ onSessionConnected: (paneId: number, sessionName: string) => void;
684
+ refreshKeys: Record<number, number>;
685
+ }) {
686
+ if (node.type === 'terminal') {
687
+ return (
688
+ <div className={`h-full w-full ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
689
+ <MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} onSessionConnected={onSessionConnected} />
690
+ </div>
691
+ );
692
+ }
693
+
694
+ const ratio = ratios[node.id] ?? node.ratio;
695
+
696
+ return (
697
+ <DraggableSplit splitId={node.id} direction={node.direction} ratio={ratio} setRatios={setRatios}>
698
+ <PaneRenderer node={node.first} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} />
699
+ <PaneRenderer node={node.second} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} />
700
+ </DraggableSplit>
701
+ );
702
+ }
703
+
704
+ // ─── Draggable split — uses pointer capture for reliable drag ─
705
+
706
+ function DraggableSplit({
707
+ splitId, direction, ratio, setRatios, children,
708
+ }: {
709
+ splitId: number;
710
+ direction: 'horizontal' | 'vertical';
711
+ ratio: number;
712
+ setRatios: React.Dispatch<React.SetStateAction<Record<number, number>>>;
713
+ children: [React.ReactNode, React.ReactNode];
714
+ }) {
715
+ const containerRef = useRef<HTMLDivElement>(null);
716
+ const firstRef = useRef<HTMLDivElement>(null);
717
+ const secondRef = useRef<HTMLDivElement>(null);
718
+ const dividerRef = useRef<HTMLDivElement>(null);
719
+ const draggingRef = useRef(false);
720
+ const ratioRef = useRef(ratio);
721
+ const isVert = direction === 'vertical';
722
+
723
+ // Keep ref in sync — avoid re-registering listeners on every ratio change
724
+ ratioRef.current = ratio;
725
+
726
+ // Apply ratio to DOM (only when not dragging — drag updates imperatively)
727
+ useEffect(() => {
728
+ if (draggingRef.current) return;
729
+ if (!firstRef.current || !secondRef.current) return;
730
+ const prop = isVert ? 'width' : 'height';
731
+ firstRef.current.style[prop] = `calc(${ratio * 100}% - 4px)`;
732
+ secondRef.current.style[prop] = `calc(${(1 - ratio) * 100}% - 4px)`;
733
+ }, [ratio, isVert]);
734
+
735
+ // Pointer capture drag — registered once, uses refs
736
+ useEffect(() => {
737
+ const divider = dividerRef.current;
738
+ const container = containerRef.current;
739
+ const first = firstRef.current;
740
+ const second = secondRef.current;
741
+ if (!divider || !container || !first || !second) return;
742
+
743
+ const vertical = isVert;
744
+ const prop = vertical ? 'width' : 'height';
745
+ let lastRatio = ratioRef.current;
746
+
747
+ const onPointerDown = (e: PointerEvent) => {
748
+ e.preventDefault();
749
+ e.stopPropagation();
750
+ divider.setPointerCapture(e.pointerId);
751
+ draggingRef.current = true;
752
+ globalDragging = true;
753
+ lastRatio = ratioRef.current;
754
+ document.body.style.cursor = vertical ? 'col-resize' : 'row-resize';
755
+ document.body.style.userSelect = 'none';
756
+ };
757
+
758
+ const onPointerMove = (e: PointerEvent) => {
759
+ if (!draggingRef.current) return;
760
+ const rect = container.getBoundingClientRect();
761
+ let r = vertical
762
+ ? (e.clientX - rect.left) / rect.width
763
+ : (e.clientY - rect.top) / rect.height;
764
+ r = Math.max(0.1, Math.min(0.9, r));
765
+ lastRatio = r;
766
+ // Imperative DOM update — no React re-render during drag
767
+ first.style[prop] = `calc(${r * 100}% - 4px)`;
768
+ second.style[prop] = `calc(${(1 - r) * 100}% - 4px)`;
769
+ };
770
+
771
+ const onPointerUp = () => {
772
+ if (!draggingRef.current) return;
773
+ draggingRef.current = false;
774
+ globalDragging = false;
775
+ document.body.style.cursor = '';
776
+ document.body.style.userSelect = '';
777
+ // Commit final ratio to React state (single re-render)
778
+ setRatios(prev => ({ ...prev, [splitId]: lastRatio }));
779
+ // Trigger a global resize so all terminals fit() once after drag ends
780
+ window.dispatchEvent(new Event('terminal-drag-end'));
781
+ };
782
+
783
+ divider.addEventListener('pointerdown', onPointerDown);
784
+ divider.addEventListener('pointermove', onPointerMove);
785
+ divider.addEventListener('pointerup', onPointerUp);
786
+ divider.addEventListener('lostpointercapture', onPointerUp);
787
+
788
+ return () => {
789
+ divider.removeEventListener('pointerdown', onPointerDown);
790
+ divider.removeEventListener('pointermove', onPointerMove);
791
+ divider.removeEventListener('pointerup', onPointerUp);
792
+ divider.removeEventListener('lostpointercapture', onPointerUp);
793
+ };
794
+ // Only re-register if direction or splitId changes (not on every ratio change)
795
+ }, [isVert, splitId, setRatios]);
796
+
797
+ return (
798
+ <div ref={containerRef} className="h-full w-full" style={{ display: 'flex', flexDirection: isVert ? 'row' : 'column' }}>
799
+ <div ref={firstRef} style={{ minWidth: 0, minHeight: 0, overflow: 'hidden', [isVert ? 'width' : 'height']: `calc(${ratio * 100}% - 4px)` }}>
800
+ {children[0]}
801
+ </div>
802
+ <div
803
+ ref={dividerRef}
804
+ className={`shrink-0 ${isVert ? 'w-2 cursor-col-resize' : 'h-2 cursor-row-resize'} bg-[#2a2a4a] hover:bg-[#7c5bf0] active:bg-[#7c5bf0] transition-colors`}
805
+ style={{ touchAction: 'none', zIndex: 10 }}
806
+ />
807
+ <div ref={secondRef} style={{ minWidth: 0, minHeight: 0, overflow: 'hidden', [isVert ? 'width' : 'height']: `calc(${(1 - ratio) * 100}% - 4px)` }}>
808
+ {children[1]}
809
+ </div>
810
+ </div>
811
+ );
812
+ }
813
+
814
+ // ─── Terminal pane with tmux session support ──────────────────
815
+
816
+ const MemoTerminalPane = memo(function TerminalPane({
817
+ id,
818
+ sessionName,
819
+ onSessionConnected,
820
+ }: {
821
+ id: number;
822
+ sessionName?: string;
823
+ onSessionConnected: (paneId: number, sessionName: string) => void;
824
+ }) {
825
+ const containerRef = useRef<HTMLDivElement>(null);
826
+ const sessionNameRef = useRef(sessionName);
827
+ sessionNameRef.current = sessionName;
828
+
829
+ useEffect(() => {
830
+ if (!containerRef.current) return;
831
+
832
+ let disposed = false; // guard against post-cleanup writes (React Strict Mode)
833
+
834
+ const term = new Terminal({
835
+ cursorBlink: true,
836
+ fontSize: 13,
837
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
838
+ scrollback: 10000,
839
+ logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
840
+ theme: {
841
+ background: '#1a1a2e',
842
+ foreground: '#e0e0e0',
843
+ cursor: '#7c5bf0',
844
+ selectionBackground: '#7c5bf044',
845
+ black: '#1a1a2e',
846
+ red: '#ff6b6b',
847
+ green: '#69db7c',
848
+ yellow: '#ffd43b',
849
+ blue: '#7c5bf0',
850
+ magenta: '#da77f2',
851
+ cyan: '#66d9ef',
852
+ white: '#e0e0e0',
853
+ brightBlack: '#555',
854
+ brightRed: '#ff8787',
855
+ brightGreen: '#8ce99a',
856
+ brightYellow: '#ffe066',
857
+ brightBlue: '#9775fa',
858
+ brightMagenta: '#e599f7',
859
+ brightCyan: '#99e9f2',
860
+ brightWhite: '#ffffff',
861
+ },
862
+ });
863
+
864
+ const fit = new FitAddon();
865
+ term.loadAddon(fit);
866
+
867
+ // Wait for container to be visible and have stable dimensions before opening
868
+ let initDone = false;
869
+ const el = containerRef.current;
870
+
871
+ function initTerminal() {
872
+ if (initDone || disposed || !el) return;
873
+ // Don't init if inside a hidden tab or too small
874
+ if (el.closest('.hidden') || el.offsetWidth < 50 || el.offsetHeight < 30) return;
875
+ initDone = true;
876
+ term.open(el);
877
+ try { fit.fit(); } catch {}
878
+ connect();
879
+ }
880
+
881
+ // Try immediately, then observe for visibility changes
882
+ requestAnimationFrame(() => {
883
+ if (disposed) return;
884
+ initTerminal();
885
+ });
886
+
887
+ // If not visible yet (hidden tab), use IntersectionObserver to detect when it becomes visible
888
+ const visObserver = new IntersectionObserver((entries) => {
889
+ if (entries[0]?.isIntersecting) {
890
+ initTerminal();
891
+ }
892
+ });
893
+ visObserver.observe(el);
894
+
895
+ // ── WebSocket with auto-reconnect ──
896
+
897
+ const wsUrl = getWsUrl();
898
+ let ws: WebSocket | null = null;
899
+ let reconnectTimer = 0;
900
+ let connectedSession: string | null = null;
901
+ let createRetries = 0;
902
+ const MAX_CREATE_RETRIES = 2;
903
+ let reconnectAttempts = 0;
904
+
905
+ function connect() {
906
+ if (disposed) return;
907
+ const socket = new WebSocket(wsUrl);
908
+ ws = socket;
909
+
910
+ socket.onopen = () => {
911
+ if (disposed) { socket.close(); return; }
912
+ if (socket.readyState !== WebSocket.OPEN) return;
913
+ const cols = term.cols;
914
+ const rows = term.rows;
915
+
916
+ if (connectedSession) {
917
+ // Reconnect to the same session
918
+ socket.send(JSON.stringify({ type: 'attach', sessionName: connectedSession, cols, rows }));
919
+ } else {
920
+ const sn = sessionNameRef.current;
921
+ if (sn) {
922
+ socket.send(JSON.stringify({ type: 'attach', sessionName: sn, cols, rows }));
923
+ } else {
924
+ socket.send(JSON.stringify({ type: 'create', cols, rows }));
925
+ }
926
+ }
927
+ };
928
+
929
+ ws.onmessage = (event) => {
930
+ if (disposed) return;
931
+ try {
932
+ const msg = JSON.parse(event.data);
933
+ if (msg.type === 'output') {
934
+ term.write(msg.data);
935
+ } else if (msg.type === 'connected') {
936
+ connectedSession = msg.sessionName;
937
+ createRetries = 0;
938
+ reconnectAttempts = 0;
939
+ onSessionConnected(id, msg.sessionName);
940
+ // Force tmux to redraw by toggling size, then send reset
941
+ setTimeout(() => {
942
+ if (disposed || ws?.readyState !== WebSocket.OPEN) return;
943
+ const c = term.cols, r = term.rows;
944
+ ws!.send(JSON.stringify({ type: 'resize', cols: c - 1, rows: r }));
945
+ setTimeout(() => {
946
+ if (disposed || ws?.readyState !== WebSocket.OPEN) return;
947
+ ws!.send(JSON.stringify({ type: 'resize', cols: c, rows: r }));
948
+ }, 50);
949
+ }, 100);
950
+ const cmd = pendingCommands.get(id);
951
+ if (cmd) {
952
+ pendingCommands.delete(id);
953
+ setTimeout(() => {
954
+ if (!disposed && ws?.readyState === WebSocket.OPEN) {
955
+ ws.send(JSON.stringify({ type: 'input', data: cmd }));
956
+ }
957
+ }, 500);
958
+ }
959
+ } else if (msg.type === 'error') {
960
+ if (!connectedSession && createRetries < MAX_CREATE_RETRIES) {
961
+ createRetries++;
962
+ term.write(`\r\n\x1b[93m[${msg.message || 'error'} — retry ${createRetries}/${MAX_CREATE_RETRIES}...]\x1b[0m\r\n`);
963
+ if (ws?.readyState === WebSocket.OPEN) {
964
+ ws.send(JSON.stringify({ type: 'create', cols: term.cols, rows: term.rows }));
965
+ }
966
+ } else {
967
+ term.write(`\r\n\x1b[93m[${msg.message || 'error'}]\x1b[0m\r\n`);
968
+ }
969
+ } else if (msg.type === 'exit') {
970
+ term.write('\r\n\x1b[90m[session ended]\x1b[0m\r\n');
971
+ }
972
+ } catch {}
973
+ };
974
+
975
+ ws.onclose = () => {
976
+ if (disposed) return;
977
+ reconnectAttempts++;
978
+ // Exponential backoff: 2s, 4s, 8s, ... max 30s
979
+ const delay = Math.min(2000 * Math.pow(2, reconnectAttempts - 1), 30000);
980
+ term.write(`\r\n\x1b[90m[disconnected — reconnecting in ${delay / 1000}s...]\x1b[0m\r\n`);
981
+ reconnectTimer = window.setTimeout(connect, delay);
982
+ };
983
+
984
+ ws.onerror = () => {
985
+ // onclose will fire after this, triggering reconnect
986
+ };
987
+ }
988
+
989
+ // NOTE: connect() is called inside initTerminal() — do NOT call it here.
990
+ // Calling it both here and in initTerminal() causes duplicate WebSocket
991
+ // connections to the same tmux session, resulting in doubled output.
992
+
993
+ term.onData((data) => {
994
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
995
+ });
996
+
997
+ // ── Resize handling ──
998
+
999
+ let resizeTimer = 0;
1000
+ let lastW = 0;
1001
+ let lastH = 0;
1002
+
1003
+ const doFit = () => {
1004
+ if (disposed) return;
1005
+ const el = containerRef.current;
1006
+ if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
1007
+ // Skip if container is inside a hidden tab (prevents wrong resize)
1008
+ if (el.closest('.hidden')) return;
1009
+ // Skip unreasonably small sizes (layout transient)
1010
+ if (el.offsetWidth < 50 || el.offsetHeight < 30) return;
1011
+ const w = el.offsetWidth;
1012
+ const h = el.offsetHeight;
1013
+ if (w === lastW && h === lastH) return;
1014
+ lastW = w;
1015
+ lastH = h;
1016
+ try {
1017
+ fit.fit();
1018
+ if (ws?.readyState === WebSocket.OPEN) {
1019
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
1020
+ }
1021
+ } catch {}
1022
+ };
1023
+
1024
+ const handleResize = () => {
1025
+ if (globalDragging) return;
1026
+ clearTimeout(resizeTimer);
1027
+ resizeTimer = window.setTimeout(doFit, 150);
1028
+ };
1029
+
1030
+ const onDragEnd = () => {
1031
+ clearTimeout(resizeTimer);
1032
+ resizeTimer = window.setTimeout(doFit, 50);
1033
+ };
1034
+ window.addEventListener('terminal-drag-end', onDragEnd);
1035
+
1036
+ const resizeObserver = new ResizeObserver(() => {
1037
+ if (!initDone) { initTerminal(); return; }
1038
+ handleResize();
1039
+ });
1040
+ resizeObserver.observe(containerRef.current);
1041
+
1042
+ // ── Cleanup ──
1043
+
1044
+ const mountTime = Date.now();
1045
+
1046
+ return () => {
1047
+ disposed = true;
1048
+ visObserver.disconnect();
1049
+ clearTimeout(resizeTimer);
1050
+ clearTimeout(reconnectTimer);
1051
+ window.removeEventListener('terminal-drag-end', onDragEnd);
1052
+ resizeObserver.disconnect();
1053
+ // Strict Mode cleanup: if disposed within 2s of mount and we created a
1054
+ // new session (not attaching), kill the orphaned tmux session
1055
+ const isStrictModeCleanup = Date.now() - mountTime < 2000;
1056
+ const isNewSession = !sessionNameRef.current && connectedSession;
1057
+ if (ws) {
1058
+ if (isStrictModeCleanup && isNewSession && ws.readyState === WebSocket.OPEN) {
1059
+ ws.send(JSON.stringify({ type: 'kill', sessionName: connectedSession }));
1060
+ }
1061
+ ws.onclose = null;
1062
+ ws.close();
1063
+ }
1064
+ term.dispose();
1065
+ };
1066
+ }, [id, onSessionConnected]);
1067
+
1068
+ return <div ref={containerRef} className="h-full w-full" />;
1069
+ });