devglide 0.1.1

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 (252) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +338 -0
  3. package/bin/claude-md-template.js +94 -0
  4. package/bin/devglide.js +387 -0
  5. package/package.json +85 -0
  6. package/pnpm-workspace.yaml +3 -0
  7. package/src/apps/coder/.turbo/turbo-lint.log +5 -0
  8. package/src/apps/coder/package.json +16 -0
  9. package/src/apps/coder/public/favicon.svg +7 -0
  10. package/src/apps/coder/public/page.css +275 -0
  11. package/src/apps/coder/public/page.js +528 -0
  12. package/src/apps/coder/server.js +3 -0
  13. package/src/apps/documentation/public/page.css +597 -0
  14. package/src/apps/documentation/public/page.js +609 -0
  15. package/src/apps/kanban/.turbo/turbo-lint.log +97 -0
  16. package/src/apps/kanban/.turbo/turbo-typecheck.log +5 -0
  17. package/src/apps/kanban/package.json +32 -0
  18. package/src/apps/kanban/public/favicon.svg +7 -0
  19. package/src/apps/kanban/public/page.css +1010 -0
  20. package/src/apps/kanban/public/page.js +1730 -0
  21. package/src/apps/kanban/public/vendor/marked.min.js +6 -0
  22. package/src/apps/kanban/public/vendor/sortable.min.js +2 -0
  23. package/src/apps/kanban/src/db.ts +319 -0
  24. package/src/apps/kanban/src/index.ts +14 -0
  25. package/src/apps/kanban/src/mcp-helpers.test.ts +88 -0
  26. package/src/apps/kanban/src/mcp-helpers.ts +60 -0
  27. package/src/apps/kanban/src/mcp.ts +59 -0
  28. package/src/apps/kanban/src/routes/attachments.ts +161 -0
  29. package/src/apps/kanban/src/routes/features.ts +233 -0
  30. package/src/apps/kanban/src/routes/issues.ts +373 -0
  31. package/src/apps/kanban/src/tools/feature-tools.ts +164 -0
  32. package/src/apps/kanban/src/tools/item-tools.ts +307 -0
  33. package/src/apps/kanban/src/tools/versioned-entry-tools.ts +72 -0
  34. package/src/apps/kanban/tsconfig.check.json +9 -0
  35. package/src/apps/kanban/tsconfig.json +9 -0
  36. package/src/apps/keymap/.turbo/turbo-lint.log +5 -0
  37. package/src/apps/keymap/package.json +16 -0
  38. package/src/apps/keymap/public/page.css +275 -0
  39. package/src/apps/keymap/public/page.js +294 -0
  40. package/src/apps/keymap/server.js +25 -0
  41. package/src/apps/log/.turbo/turbo-build.log +5 -0
  42. package/src/apps/log/.turbo/turbo-lint.log +45 -0
  43. package/src/apps/log/.turbo/turbo-typecheck.log +5 -0
  44. package/src/apps/log/node_modules/.bin/tsc +21 -0
  45. package/src/apps/log/node_modules/.bin/tsserver +21 -0
  46. package/src/apps/log/node_modules/.bin/tsx +21 -0
  47. package/src/apps/log/package.json +36 -0
  48. package/src/apps/log/public/console-sniffer.js +221 -0
  49. package/src/apps/log/public/favicon.svg +7 -0
  50. package/src/apps/log/public/page.css +322 -0
  51. package/src/apps/log/public/page.js +463 -0
  52. package/src/apps/log/src/index.ts +9 -0
  53. package/src/apps/log/src/mcp.ts +122 -0
  54. package/src/apps/log/src/routes/log.ts +333 -0
  55. package/src/apps/log/src/routes/status.ts +25 -0
  56. package/src/apps/log/src/server-sniffer.ts +118 -0
  57. package/src/apps/log/src/services/file-patterns.ts +39 -0
  58. package/src/apps/log/src/services/file-tailer.ts +228 -0
  59. package/src/apps/log/src/services/line-parser.ts +94 -0
  60. package/src/apps/log/src/services/log-writer.ts +39 -0
  61. package/src/apps/log/tsconfig.json +8 -0
  62. package/src/apps/prompts/.turbo/turbo-build.log +5 -0
  63. package/src/apps/prompts/.turbo/turbo-lint.log +24 -0
  64. package/src/apps/prompts/.turbo/turbo-typecheck.log +5 -0
  65. package/src/apps/prompts/mcp.ts +175 -0
  66. package/src/apps/prompts/node_modules/.bin/tsc +21 -0
  67. package/src/apps/prompts/node_modules/.bin/tsserver +21 -0
  68. package/src/apps/prompts/node_modules/.bin/tsx +21 -0
  69. package/src/apps/prompts/package.json +25 -0
  70. package/src/apps/prompts/public/page.css +315 -0
  71. package/src/apps/prompts/public/page.js +541 -0
  72. package/src/apps/prompts/services/prompt-store.ts +212 -0
  73. package/src/apps/prompts/src/index.ts +9 -0
  74. package/src/apps/prompts/tsconfig.json +8 -0
  75. package/src/apps/prompts/types.ts +27 -0
  76. package/src/apps/shell/.turbo/turbo-build.log +5 -0
  77. package/src/apps/shell/.turbo/turbo-lint.log +34 -0
  78. package/src/apps/shell/.turbo/turbo-typecheck.log +5 -0
  79. package/src/apps/shell/package.json +35 -0
  80. package/src/apps/shell/public/favicon.svg +7 -0
  81. package/src/apps/shell/public/page.css +407 -0
  82. package/src/apps/shell/public/page.js +1577 -0
  83. package/src/apps/shell/src/index.ts +150 -0
  84. package/src/apps/shell/src/mcp.ts +398 -0
  85. package/src/apps/shell/src/shell-types.ts +41 -0
  86. package/src/apps/shell/tsconfig.json +8 -0
  87. package/src/apps/test/.turbo/turbo-build.log +5 -0
  88. package/src/apps/test/.turbo/turbo-lint.log +27 -0
  89. package/src/apps/test/.turbo/turbo-typecheck.log +5 -0
  90. package/src/apps/test/node_modules/.bin/tsc +21 -0
  91. package/src/apps/test/node_modules/.bin/tsserver +21 -0
  92. package/src/apps/test/node_modules/.bin/tsx +21 -0
  93. package/src/apps/test/node_modules/.bin/uuid +21 -0
  94. package/src/apps/test/package.json +35 -0
  95. package/src/apps/test/public/favicon.svg +7 -0
  96. package/src/apps/test/public/page.css +499 -0
  97. package/src/apps/test/public/page.js +417 -0
  98. package/src/apps/test/public/scenario-runner.js +450 -0
  99. package/src/apps/test/src/index.ts +9 -0
  100. package/src/apps/test/src/mcp.ts +192 -0
  101. package/src/apps/test/src/routes/trigger.ts +285 -0
  102. package/src/apps/test/src/services/scenario-broadcaster.ts +60 -0
  103. package/src/apps/test/src/services/scenario-manager.ts +361 -0
  104. package/src/apps/test/src/services/scenario-store.ts +145 -0
  105. package/src/apps/test/tsconfig.json +8 -0
  106. package/src/apps/vocabulary/.turbo/turbo-build.log +5 -0
  107. package/src/apps/vocabulary/.turbo/turbo-lint.log +25 -0
  108. package/src/apps/vocabulary/.turbo/turbo-typecheck.log +5 -0
  109. package/src/apps/vocabulary/mcp.ts +173 -0
  110. package/src/apps/vocabulary/node_modules/.bin/tsc +21 -0
  111. package/src/apps/vocabulary/node_modules/.bin/tsserver +21 -0
  112. package/src/apps/vocabulary/node_modules/.bin/tsx +21 -0
  113. package/src/apps/vocabulary/package.json +25 -0
  114. package/src/apps/vocabulary/public/page.css +247 -0
  115. package/src/apps/vocabulary/public/page.js +444 -0
  116. package/src/apps/vocabulary/services/vocabulary-store.ts +179 -0
  117. package/src/apps/vocabulary/src/index.ts +10 -0
  118. package/src/apps/vocabulary/tsconfig.json +8 -0
  119. package/src/apps/vocabulary/types.ts +22 -0
  120. package/src/apps/voice/.turbo/turbo-build.log +5 -0
  121. package/src/apps/voice/.turbo/turbo-lint.log +43 -0
  122. package/src/apps/voice/.turbo/turbo-typecheck.log +5 -0
  123. package/src/apps/voice/node_modules/.bin/openai +21 -0
  124. package/src/apps/voice/node_modules/.bin/tsc +21 -0
  125. package/src/apps/voice/node_modules/.bin/tsserver +21 -0
  126. package/src/apps/voice/node_modules/.bin/tsx +21 -0
  127. package/src/apps/voice/package.json +35 -0
  128. package/src/apps/voice/public/favicon.svg +7 -0
  129. package/src/apps/voice/public/page.css +388 -0
  130. package/src/apps/voice/public/page.js +718 -0
  131. package/src/apps/voice/src/index.ts +10 -0
  132. package/src/apps/voice/src/mcp.ts +70 -0
  133. package/src/apps/voice/src/providers/index.ts +85 -0
  134. package/src/apps/voice/src/providers/openai-compatible.ts +94 -0
  135. package/src/apps/voice/src/providers/types.ts +27 -0
  136. package/src/apps/voice/src/routes/config.ts +118 -0
  137. package/src/apps/voice/src/routes/transcribe.ts +90 -0
  138. package/src/apps/voice/src/services/config-store.ts +129 -0
  139. package/src/apps/voice/src/services/stats.ts +108 -0
  140. package/src/apps/voice/src/transcribe.ts +11 -0
  141. package/src/apps/voice/src/utils/mime.ts +16 -0
  142. package/src/apps/voice/tsconfig.json +8 -0
  143. package/src/apps/workflow/.turbo/turbo-build.log +5 -0
  144. package/src/apps/workflow/.turbo/turbo-lint.log +96 -0
  145. package/src/apps/workflow/.turbo/turbo-typecheck.log +5 -0
  146. package/src/apps/workflow/engine/executors/decision-executor.ts +87 -0
  147. package/src/apps/workflow/engine/executors/file-executor.ts +90 -0
  148. package/src/apps/workflow/engine/executors/git-executor.ts +137 -0
  149. package/src/apps/workflow/engine/executors/http-executor.ts +65 -0
  150. package/src/apps/workflow/engine/executors/index.ts +28 -0
  151. package/src/apps/workflow/engine/executors/kanban-executor.ts +154 -0
  152. package/src/apps/workflow/engine/executors/llm-executor.ts +46 -0
  153. package/src/apps/workflow/engine/executors/log-executor.ts +62 -0
  154. package/src/apps/workflow/engine/executors/loop-executor.ts +14 -0
  155. package/src/apps/workflow/engine/executors/shell-executor.ts +107 -0
  156. package/src/apps/workflow/engine/executors/sub-workflow-executor.ts +61 -0
  157. package/src/apps/workflow/engine/executors/test-executor.ts +73 -0
  158. package/src/apps/workflow/engine/executors/trigger-executor.ts +39 -0
  159. package/src/apps/workflow/engine/expression-evaluator.ts +117 -0
  160. package/src/apps/workflow/engine/graph-runner.ts +438 -0
  161. package/src/apps/workflow/engine/node-executor.ts +104 -0
  162. package/src/apps/workflow/engine/node-registry.ts +15 -0
  163. package/src/apps/workflow/engine/variable-resolver.ts +109 -0
  164. package/src/apps/workflow/mcp.ts +223 -0
  165. package/src/apps/workflow/node_modules/.bin/tsc +21 -0
  166. package/src/apps/workflow/node_modules/.bin/tsserver +21 -0
  167. package/src/apps/workflow/node_modules/.bin/tsx +21 -0
  168. package/src/apps/workflow/package.json +25 -0
  169. package/src/apps/workflow/public/editor/canvas.js +366 -0
  170. package/src/apps/workflow/public/editor/drag-manager.js +326 -0
  171. package/src/apps/workflow/public/editor/edge-renderer.js +235 -0
  172. package/src/apps/workflow/public/editor/history-manager.js +147 -0
  173. package/src/apps/workflow/public/editor/layout-engine.js +159 -0
  174. package/src/apps/workflow/public/editor/node-renderer.js +199 -0
  175. package/src/apps/workflow/public/editor/selection-manager.js +193 -0
  176. package/src/apps/workflow/public/favicon.svg +7 -0
  177. package/src/apps/workflow/public/models/node-types.js +300 -0
  178. package/src/apps/workflow/public/models/workflow-model.js +257 -0
  179. package/src/apps/workflow/public/page.css +406 -0
  180. package/src/apps/workflow/public/page.js +658 -0
  181. package/src/apps/workflow/public/panels/inspector.js +360 -0
  182. package/src/apps/workflow/public/panels/palette.js +106 -0
  183. package/src/apps/workflow/public/panels/run-view.js +275 -0
  184. package/src/apps/workflow/public/panels/toolbar.js +232 -0
  185. package/src/apps/workflow/public/panels/workflow-list.js +237 -0
  186. package/src/apps/workflow/public/state/store.js +47 -0
  187. package/src/apps/workflow/services/custom-node-loader.ts +48 -0
  188. package/src/apps/workflow/services/legacy-converter.ts +72 -0
  189. package/src/apps/workflow/services/run-manager.ts +190 -0
  190. package/src/apps/workflow/services/workflow-store.ts +424 -0
  191. package/src/apps/workflow/services/workflow-validator.test.ts +103 -0
  192. package/src/apps/workflow/services/workflow-validator.ts +98 -0
  193. package/src/apps/workflow/src/index.ts +10 -0
  194. package/src/apps/workflow/templates/ci-pipeline.json +18 -0
  195. package/src/apps/workflow/templates/code-review.json +22 -0
  196. package/src/apps/workflow/templates/kanban-testing.json +24 -0
  197. package/src/apps/workflow/tsconfig.json +8 -0
  198. package/src/apps/workflow/types.ts +268 -0
  199. package/src/packages/auth-middleware.ts +14 -0
  200. package/src/packages/design-tokens/.turbo/turbo-build.log +10 -0
  201. package/src/packages/design-tokens/STYLEGUIDE.md +414 -0
  202. package/src/packages/design-tokens/build.js +413 -0
  203. package/src/packages/design-tokens/demo/index.html +1367 -0
  204. package/src/packages/design-tokens/demo/proposition-a.html +717 -0
  205. package/src/packages/design-tokens/demo/proposition-b.html +1239 -0
  206. package/src/packages/design-tokens/demo/proposition-c.html +1049 -0
  207. package/src/packages/design-tokens/dist/tailwind-preset.js +115 -0
  208. package/src/packages/design-tokens/dist/tokens.css +345 -0
  209. package/src/packages/design-tokens/dist/tokens.d.ts +229 -0
  210. package/src/packages/design-tokens/dist/tokens.js +386 -0
  211. package/src/packages/design-tokens/package.json +25 -0
  212. package/src/packages/design-tokens/tokens.json +228 -0
  213. package/src/packages/devtools-middleware.ts +22 -0
  214. package/src/packages/eslint-config/index.js +63 -0
  215. package/src/packages/eslint-config/node_modules/.bin/eslint +21 -0
  216. package/src/packages/eslint-config/package.json +18 -0
  217. package/src/packages/json-file-store.ts +232 -0
  218. package/src/packages/mcp-utils/.turbo/turbo-build.log +5 -0
  219. package/src/packages/mcp-utils/dist/index.d.ts +33 -0
  220. package/src/packages/mcp-utils/dist/index.d.ts.map +1 -0
  221. package/src/packages/mcp-utils/dist/index.js +126 -0
  222. package/src/packages/mcp-utils/dist/index.js.map +1 -0
  223. package/src/packages/mcp-utils/node_modules/.bin/tsc +21 -0
  224. package/src/packages/mcp-utils/node_modules/.bin/tsserver +21 -0
  225. package/src/packages/mcp-utils/package.json +32 -0
  226. package/src/packages/mcp-utils/src/index.ts +171 -0
  227. package/src/packages/mcp-utils/tsconfig.json +9 -0
  228. package/src/packages/paths.ts +18 -0
  229. package/src/packages/project-context/index.js +55 -0
  230. package/src/packages/project-context/package.json +13 -0
  231. package/src/packages/project-store.ts +127 -0
  232. package/src/packages/server-sniffer.ts +132 -0
  233. package/src/packages/shared-assets/favicon.svg +7 -0
  234. package/src/packages/shared-assets/keymap-registry.js +512 -0
  235. package/src/packages/shared-assets/logo.svg +6 -0
  236. package/src/packages/shared-assets/package.json +11 -0
  237. package/src/packages/shared-assets/ui-utils.js +48 -0
  238. package/src/packages/shared-assets/voice-widget.d.ts +37 -0
  239. package/src/packages/shared-assets/voice-widget.js +695 -0
  240. package/src/packages/shared-types/.turbo/turbo-build.log +5 -0
  241. package/src/packages/shared-types/dist/index.d.ts +39 -0
  242. package/src/packages/shared-types/dist/index.d.ts.map +1 -0
  243. package/src/packages/shared-types/node_modules/.bin/tsc +21 -0
  244. package/src/packages/shared-types/node_modules/.bin/tsserver +21 -0
  245. package/src/packages/shared-types/package.json +25 -0
  246. package/src/packages/shared-types/src/index.ts +41 -0
  247. package/src/packages/shared-types/tsconfig.json +11 -0
  248. package/src/packages/tsconfig/base.json +15 -0
  249. package/src/packages/tsconfig/next.json +14 -0
  250. package/src/packages/tsconfig/node.json +11 -0
  251. package/src/packages/tsconfig/package.json +10 -0
  252. package/turbo.json +25 -0
@@ -0,0 +1,1577 @@
1
+ // ── Shell App — Native Page Module ────────────────────────────────────
2
+ // ES module that exports mount(container, ctx), unmount(container),
3
+ // and onProjectChange(project).
4
+ //
5
+ // Replaces the iframe-based page module with a native implementation
6
+ // that renders terminal panes directly in the app shell container.
7
+
8
+ import { shellSocket as socket } from '/state.js';
9
+
10
+ // ── Module state ─────────────────────────────────────────────────────
11
+
12
+ let _container = null;
13
+ let _resizeTimer = null;
14
+ let _voiceHandler = null;
15
+ let _keydownHandler = null;
16
+ let _xtermLoaded = false;
17
+ let _mountedOnce = false;
18
+ let _restoring = false; // true during snapshot batch restore — suppresses premature fits
19
+
20
+ const panes = new Map(); // id -> pane object
21
+ let activePaneId = null;
22
+ let activeTab = 'grid';
23
+ let activeProject = null;
24
+
25
+ // Track socket handlers for cleanup
26
+ const _socketHandlers = {};
27
+
28
+ // ── Terminal theme ───────────────────────────────────────────────────
29
+
30
+ const TERMINAL_THEME = {
31
+ background: '#1c2128', foreground: '#adbac7', cursor: '#7ee787',
32
+ selectionBackground: '#7ee78744',
33
+ black: '#1c1c1c', red: '#f85149', green: '#7ee787', yellow: '#e3b341',
34
+ blue: '#58a6ff', magenta: '#bc8cff', cyan: '#76e3ea', white: '#b1bac4',
35
+ brightBlack: '#6e7681', brightRed: '#ff7b72', brightGreen: '#56d364',
36
+ brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
37
+ brightCyan: '#87deea', brightWhite: '#ffffff',
38
+ };
39
+
40
+ // ── Helpers ──────────────────────────────────────────────────────────
41
+
42
+ const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
43
+ const isMobile = () => window.innerWidth <= 640;
44
+ const isMobileDevice = 'ontouchstart' in window;
45
+
46
+ function makeLabel(num, folder) {
47
+ return folder ? `${num}: ${folder}` : `${num}`;
48
+ }
49
+
50
+ // ── Dynamic xterm.js loader ─────────────────────────────────────────
51
+
52
+ function loadScript(src) {
53
+ return new Promise((resolve, reject) => {
54
+ const existing = document.querySelector(`script[src="${src}"]`);
55
+ if (existing) {
56
+ if (existing.dataset.loaded) { resolve(); return; }
57
+ existing.addEventListener('load', resolve, { once: true });
58
+ existing.addEventListener('error', reject, { once: true });
59
+ return;
60
+ }
61
+ const s = document.createElement('script');
62
+ s.src = src;
63
+ s.onload = () => { s.dataset.loaded = '1'; resolve(); };
64
+ s.onerror = reject;
65
+ document.head.appendChild(s);
66
+ });
67
+ }
68
+
69
+ function loadCSS(href) {
70
+ return new Promise((resolve, reject) => {
71
+ const existing = document.querySelector(`link[href="${href}"]`);
72
+ if (existing) { resolve(); return; }
73
+ const link = document.createElement('link');
74
+ link.rel = 'stylesheet';
75
+ link.href = href;
76
+ link.onload = resolve;
77
+ link.onerror = reject;
78
+ document.head.appendChild(link);
79
+ });
80
+ }
81
+
82
+ async function ensureXterm() {
83
+ if (_xtermLoaded && window.Terminal && window.FitAddon && window.WebLinksAddon) return;
84
+
85
+ // Monaco's AMD loader pollutes window.define, causing xterm's UMD wrapper
86
+ // to register as an AMD module instead of setting window.Terminal.
87
+ // Temporarily hide define while loading xterm scripts.
88
+ const savedDefine = window.define;
89
+ window.define = undefined;
90
+
91
+ try {
92
+ await loadCSS('https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css');
93
+
94
+ // Load xterm first, then addons (they depend on it)
95
+ await loadScript('https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js');
96
+
97
+ await Promise.all([
98
+ loadScript('https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js'),
99
+ loadScript('https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js'),
100
+ ]);
101
+
102
+ _xtermLoaded = true;
103
+ } finally {
104
+ window.define = savedDefine;
105
+ }
106
+ }
107
+
108
+ // ── HTML ─────────────────────────────────────────────────────────────
109
+
110
+ const BODY_HTML = `
111
+ <header>
112
+ <div class="brand">Shell</div>
113
+ <div class="header-meta">
114
+ <span class="pane-count" data-ref="paneCount">0 panes</span>
115
+ <div class="mobile-actions" data-ref="mobileActions">
116
+ <button class="mobile-action-btn" data-action="new-terminal" title="New Terminal">&gt;_</button>
117
+ <button class="mobile-action-btn" data-action="new-browser" title="New Browser">&#x25A1;</button>
118
+ </div>
119
+ </div>
120
+ </header>
121
+ <div class="shell-disconnect-banner" data-ref="disconnect">Disconnected — reconnecting...</div>
122
+ <div class="shell-tab-bar" data-ref="tabBar" role="tablist">
123
+ <button class="shell-tab active" data-tab="grid" role="tab">Dashboard</button>
124
+ </div>
125
+ <div class="shell-pane-container" data-ref="paneContainer" role="tabpanel">
126
+ <div class="shell-empty-state" data-ref="emptyState">
127
+ <div class="hint">No terminals open</div>
128
+ <div class="sub">Use keyboard shortcuts to open a shell or browser</div>
129
+ </div>
130
+ </div>
131
+ `;
132
+
133
+ // ── Refs helper ─────────────────────────────────────────────────────
134
+
135
+ function getRefs(container) {
136
+ return {
137
+ tabBar: container.querySelector('[data-ref="tabBar"]'),
138
+ paneContainer: container.querySelector('[data-ref="paneContainer"]'),
139
+ emptyState: container.querySelector('[data-ref="emptyState"]'),
140
+ disconnect: container.querySelector('[data-ref="disconnect"]'),
141
+ paneCount: container.querySelector('[data-ref="paneCount"]'),
142
+ mobileActions: container.querySelector('[data-ref="mobileActions"]'),
143
+ };
144
+ }
145
+
146
+ function updatePaneCount(refs) {
147
+ if (!refs.paneCount) return;
148
+ const visible = [...panes.values()].filter(p => !p.element.classList.contains('project-hidden')).length;
149
+ refs.paneCount.textContent = `${visible} pane${visible !== 1 ? 's' : ''}`;
150
+ }
151
+
152
+ // ── Tab management ──────────────────────────────────────────────────
153
+
154
+ function addTab(refs, id, title) {
155
+ const tab = document.createElement('button');
156
+ tab.className = 'shell-tab';
157
+ tab.dataset.tab = id;
158
+ tab.setAttribute('role', 'tab');
159
+
160
+ const label = document.createElement('span');
161
+ label.className = 'shell-tab-label';
162
+ label.textContent = title;
163
+
164
+ const closeBtn = document.createElement('span');
165
+ closeBtn.className = 'shell-tab-close';
166
+ closeBtn.textContent = '\u2715';
167
+ closeBtn.setAttribute('aria-label', `Close ${title}`);
168
+ closeBtn.addEventListener('click', (e) => {
169
+ e.stopPropagation();
170
+ panes.get(id)?.destroy();
171
+ });
172
+
173
+ tab.appendChild(label);
174
+ tab.appendChild(closeBtn);
175
+ tab.addEventListener('click', () => setActiveTab(refs, id));
176
+ tab.addEventListener('mousedown', (e) => {
177
+ if (e.button === 1) {
178
+ e.preventDefault();
179
+ e.stopPropagation();
180
+ panes.get(id)?.destroy();
181
+ }
182
+ });
183
+ tab.addEventListener('auxclick', (e) => {
184
+ if (e.button === 1) {
185
+ e.preventDefault();
186
+ e.stopPropagation();
187
+ }
188
+ });
189
+ refs.tabBar.appendChild(tab);
190
+ }
191
+
192
+ function removeTab(refs, id) {
193
+ refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"]`)?.remove();
194
+ }
195
+
196
+ // ── Navigation helpers ──────────────────────────────────────────────
197
+
198
+ function getNavigableTabs(refs) {
199
+ return [...refs.tabBar.querySelectorAll('.shell-tab:not(.project-hidden)')]
200
+ .map(t => t.dataset.tab);
201
+ }
202
+
203
+ // ── Active tab ──────────────────────────────────────────────────────
204
+
205
+ function setActiveTab(refs, tabId) {
206
+ _applyActiveTab(refs, tabId);
207
+ socket.emit('state:set-active-tab', { tabId });
208
+ }
209
+
210
+ function _applyActiveTab(refs, tabId) {
211
+ if (activeTab !== 'grid' && activeTab !== tabId) {
212
+ panes.get(activeTab)?.disableKeyboard?.();
213
+ }
214
+
215
+ activeTab = tabId;
216
+
217
+ refs.tabBar.querySelectorAll('.shell-tab').forEach(t => {
218
+ t.classList.toggle('active', t.dataset.tab === tabId);
219
+ });
220
+
221
+ // Scroll the tab into view horizontally only — scrollIntoView() can bubble
222
+ // up and scroll parent containers to the top, causing the terminal to jump.
223
+ const activeTabEl = refs.tabBar.querySelector(`.shell-tab[data-tab="${tabId}"]`);
224
+ if (activeTabEl) {
225
+ const barRect = refs.tabBar.getBoundingClientRect();
226
+ const tabRect = activeTabEl.getBoundingClientRect();
227
+ if (tabRect.left < barRect.left) {
228
+ refs.tabBar.scrollLeft -= barRect.left - tabRect.left + 8;
229
+ } else if (tabRect.right > barRect.right) {
230
+ refs.tabBar.scrollLeft += tabRect.right - barRect.right + 8;
231
+ }
232
+ }
233
+
234
+ if (tabId === 'grid') {
235
+ refs.paneContainer.style.gridTemplateRows = '';
236
+ for (const pane of panes.values()) {
237
+ if (!pane.element.classList.contains('project-hidden')) pane.element.style.display = '';
238
+ }
239
+ relayout(refs);
240
+ // Focus the active pane's terminal in grid view (delayed to avoid double-cursor flicker)
241
+ if (!isMobile() && activePaneId) {
242
+ setTimeout(() => {
243
+ panes.get(activePaneId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
244
+ }, 300);
245
+ }
246
+ } else {
247
+ for (const [id, pane] of panes) {
248
+ pane.element.style.display = id === tabId ? '' : 'none';
249
+ }
250
+ refs.emptyState.style.display = 'none';
251
+ refs.paneContainer.style.gridTemplateColumns = '1fr';
252
+ refs.paneContainer.style.gridTemplateRows = '1fr';
253
+ setActivePaneHighlight(tabId);
254
+ requestAnimationFrame(() => {
255
+ document.fonts.ready.then(() => {
256
+ const pane = panes.get(tabId);
257
+ pane?.fit();
258
+ pane?.scrollToBottom();
259
+ if (!isMobile()) {
260
+ pane?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
261
+ }
262
+ });
263
+ });
264
+ }
265
+ }
266
+
267
+ // ── Layout ──────────────────────────────────────────────────────────
268
+
269
+ function relayout(refs) {
270
+ updatePaneCount(refs);
271
+ const visiblePanes = [...panes.values()].filter(p => !p.element.classList.contains('project-hidden'));
272
+ const count = visiblePanes.length;
273
+
274
+ if (count === 0) {
275
+ refs.paneContainer.style.gridTemplateColumns = '';
276
+ refs.emptyState.style.display = 'flex';
277
+ return;
278
+ }
279
+ refs.emptyState.style.display = 'none';
280
+
281
+ if (activeTab !== 'grid') return;
282
+
283
+ for (const pane of visiblePanes) pane.element.style.display = '';
284
+
285
+ const mobile = window.innerWidth <= 640;
286
+ const cols = mobile ? 1 : count === 1 ? 1 : count <= 4 ? 2 : 3;
287
+ const rows = Math.ceil(count / cols);
288
+ refs.paneContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
289
+ refs.paneContainer.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
290
+
291
+ // Wait for DOM to settle and fonts to load before fitting terminals.
292
+ // rAF ensures layout is flushed, fonts.ready ensures correct metrics.
293
+ requestAnimationFrame(() => {
294
+ document.fonts.ready.then(() => {
295
+ for (const pane of visiblePanes) {
296
+ pane.fit();
297
+ }
298
+ });
299
+ });
300
+ }
301
+
302
+ function setActivePaneHighlight(id) {
303
+ if (activePaneId) panes.get(activePaneId)?.element.classList.remove('active');
304
+ activePaneId = id;
305
+ if (id) panes.get(id)?.element.classList.add('active');
306
+ }
307
+
308
+ // ── Drag-to-reorder helpers ─────────────────────────────────────────
309
+
310
+ let _draggedPaneId = null;
311
+
312
+ function _attachDragHandlers(header, wrapper, id) {
313
+ header.addEventListener('dragstart', (e) => {
314
+ // Don't start drag from close button
315
+ if (e.target.closest('.pane-close')) {
316
+ e.preventDefault();
317
+ return;
318
+ }
319
+ _draggedPaneId = id;
320
+ wrapper.classList.add('dragging');
321
+ e.dataTransfer.effectAllowed = 'move';
322
+ e.dataTransfer.setData('text/plain', id);
323
+ });
324
+
325
+ header.addEventListener('dragend', () => {
326
+ wrapper.classList.remove('dragging');
327
+ _draggedPaneId = null;
328
+ // Clean up all drag-over highlights
329
+ document.querySelectorAll('.page-shell .pane.drag-over').forEach(el => el.classList.remove('drag-over'));
330
+ });
331
+
332
+ wrapper.addEventListener('dragover', (e) => {
333
+ if (!_draggedPaneId || _draggedPaneId === id) return;
334
+ e.preventDefault();
335
+ e.dataTransfer.dropEffect = 'move';
336
+ wrapper.classList.add('drag-over');
337
+ });
338
+
339
+ wrapper.addEventListener('dragleave', (e) => {
340
+ // Only remove if we actually left this pane (not entering a child)
341
+ if (!wrapper.contains(e.relatedTarget)) {
342
+ wrapper.classList.remove('drag-over');
343
+ }
344
+ });
345
+
346
+ wrapper.addEventListener('drop', (e) => {
347
+ e.preventDefault();
348
+ wrapper.classList.remove('drag-over');
349
+ if (!_draggedPaneId || _draggedPaneId === id) return;
350
+
351
+ const container = wrapper.parentElement;
352
+ if (!container) return;
353
+
354
+ const draggedPane = panes.get(_draggedPaneId);
355
+ const targetPane = panes.get(id);
356
+ if (!draggedPane || !targetPane) return;
357
+
358
+ // Swap DOM positions
359
+ const draggedEl = draggedPane.element;
360
+ const targetEl = targetPane.element;
361
+ const draggedNext = draggedEl.nextElementSibling;
362
+ const targetNext = targetEl.nextElementSibling;
363
+
364
+ if (draggedNext === targetEl) {
365
+ // Dragged is immediately before target
366
+ container.insertBefore(targetEl, draggedEl);
367
+ } else if (targetNext === draggedEl) {
368
+ // Target is immediately before dragged
369
+ container.insertBefore(draggedEl, targetEl);
370
+ } else {
371
+ // General case: swap positions
372
+ const placeholder = document.createElement('div');
373
+ container.insertBefore(placeholder, draggedEl);
374
+ container.insertBefore(draggedEl, targetNext);
375
+ container.insertBefore(targetEl, placeholder);
376
+ placeholder.remove();
377
+ }
378
+
379
+ // Build new order from DOM
380
+ const newOrder = [...container.querySelectorAll('.pane[data-id]')]
381
+ .map(el => el.dataset.id)
382
+ .filter(pid => panes.has(pid));
383
+
384
+ // Reorder tabs in the tab bar to match
385
+ const tabBar = document.querySelector('.page-shell .shell-tab-bar');
386
+ if (tabBar) {
387
+ for (const pid of newOrder) {
388
+ const tab = tabBar.querySelector(`.shell-tab[data-tab="${pid}"]`);
389
+ if (tab) tabBar.appendChild(tab);
390
+ }
391
+ }
392
+
393
+ // Emit to server for persistence
394
+ socket.emit('state:reorder-panes', { order: newOrder });
395
+
396
+ _draggedPaneId = null;
397
+ });
398
+ }
399
+
400
+ // ── Terminal pane creation ──────────────────────────────────────────
401
+
402
+ function createTerminalPane({ id, shellType, title, onClose, onFocus, skipInitialFit = false }) {
403
+ const wrapper = document.createElement('div');
404
+ wrapper.className = 'pane';
405
+ wrapper.dataset.id = id;
406
+
407
+ const header = document.createElement('div');
408
+ header.className = 'pane-header';
409
+ header.draggable = true;
410
+
411
+ const titleEl = document.createElement('span');
412
+ titleEl.className = 'pane-title';
413
+ titleEl.textContent = title;
414
+
415
+ const closeBtn = document.createElement('button');
416
+ closeBtn.className = 'pane-close';
417
+ closeBtn.title = 'Close';
418
+ closeBtn.textContent = '\u2715';
419
+
420
+ header.appendChild(titleEl);
421
+ header.appendChild(closeBtn);
422
+
423
+ // ── Drag-to-reorder ──────────────────────────────────────────────
424
+ _attachDragHandlers(header, wrapper, id);
425
+
426
+ const termDiv = document.createElement('div');
427
+ termDiv.className = 'pane-terminal';
428
+
429
+ wrapper.appendChild(header);
430
+ wrapper.appendChild(termDiv);
431
+
432
+ // xterm.js
433
+ const term = new window.Terminal({
434
+ theme: TERMINAL_THEME,
435
+ fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace",
436
+ fontSize: 14,
437
+ lineHeight: 1.2,
438
+ cursorBlink: true,
439
+ allowProposedApi: true,
440
+ scrollback: 5000,
441
+ });
442
+
443
+ const fitAddon = new window.FitAddon.FitAddon();
444
+ const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
445
+ term.loadAddon(fitAddon);
446
+ term.loadAddon(webLinksAddon);
447
+
448
+ term.open(termDiv);
449
+
450
+ let disposed = false;
451
+
452
+ // Alternate screen buffer detection
453
+ term.buffer.onBufferChange((buf) => {
454
+ if (buf.type === 'alternate') {
455
+ termDiv.classList.add('alt-screen');
456
+ } else {
457
+ termDiv.classList.remove('alt-screen');
458
+ }
459
+ });
460
+
461
+ // Fallback copy
462
+ function _fallbackCopy(text) {
463
+ const ta = document.createElement('textarea');
464
+ ta.value = text;
465
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
466
+ document.body.appendChild(ta);
467
+ ta.select();
468
+ try { document.execCommand('copy'); } catch {}
469
+ document.body.removeChild(ta);
470
+ term.focus();
471
+ }
472
+
473
+ // Custom key event handler
474
+ term.attachCustomKeyEventHandler((e) => {
475
+ if (typeof KeymapRegistry !== 'undefined') {
476
+ const action = KeymapRegistry.resolve(e);
477
+ if (action && (action.startsWith('shell:') || action.startsWith('voice:'))) return false;
478
+ }
479
+
480
+ if (e.type !== 'keydown') return true;
481
+
482
+ // Ctrl+C / Ctrl+Shift+C / Cmd+C -> copy
483
+ if ((e.ctrlKey && e.code === 'KeyC' && !e.shiftKey && !e.altKey) ||
484
+ (e.ctrlKey && e.shiftKey && e.code === 'KeyC' && !e.altKey) ||
485
+ (isMac && e.metaKey && e.code === 'KeyC' && !e.altKey)) {
486
+ const sel = term.getSelection();
487
+ if (sel) {
488
+ e.preventDefault();
489
+ if (navigator.clipboard?.writeText) {
490
+ navigator.clipboard.writeText(sel).catch(() => _fallbackCopy(sel));
491
+ } else {
492
+ _fallbackCopy(sel);
493
+ }
494
+ term.clearSelection();
495
+ term.focus();
496
+ return false;
497
+ }
498
+ if (e.shiftKey || (isMac && e.metaKey && !e.ctrlKey)) return false;
499
+ }
500
+
501
+ // Ctrl+V / Cmd+V -> paste
502
+ if ((e.ctrlKey || (isMac && e.metaKey)) && e.code === 'KeyV' && !e.altKey) {
503
+ return false;
504
+ }
505
+
506
+ return true;
507
+ });
508
+
509
+ // Defer fit until element is in DOM and fonts are loaded.
510
+ // Skip during batch creation (snapshot restore) — relayout handles fit after grid is set.
511
+ if (!skipInitialFit) {
512
+ requestAnimationFrame(() => {
513
+ document.fonts.ready.then(() => {
514
+ if (disposed) return;
515
+ fitAddon.fit();
516
+ socket.emit('terminal:resize', { id, cols: term.cols, rows: term.rows });
517
+ term.scrollToBottom();
518
+ });
519
+ });
520
+ }
521
+
522
+ // Mobile keyboard management
523
+ if (isMobileDevice) {
524
+ requestAnimationFrame(() => {
525
+ const h = termDiv.querySelector('.xterm-helper-textarea');
526
+ if (!h) return;
527
+ h.setAttribute('inputmode', 'none');
528
+ h.addEventListener('blur', () => h.setAttribute('inputmode', 'none'));
529
+ });
530
+ }
531
+
532
+ function disableKeyboard() {
533
+ if (!isMobileDevice) return;
534
+ const h = termDiv.querySelector('.xterm-helper-textarea');
535
+ if (!h) return;
536
+ h.setAttribute('inputmode', 'none');
537
+ h.blur();
538
+ }
539
+
540
+ function enableKeyboard() {
541
+ if (!isMobileDevice) return;
542
+ const h = termDiv.querySelector('.xterm-helper-textarea');
543
+ if (h) h.removeAttribute('inputmode');
544
+ }
545
+
546
+ // Auto-scroll tracking — only scroll to bottom when user hasn't scrolled up
547
+ let _atBottom = true;
548
+ term.onScroll(() => {
549
+ const buf = term.buffer.active;
550
+ _atBottom = buf.viewportY >= buf.baseY;
551
+ });
552
+
553
+ function autoScroll() {
554
+ if (_atBottom) term.scrollToBottom();
555
+ }
556
+
557
+ // Socket handlers
558
+ const dataHandler = ({ id: eid, data }) => {
559
+ if (disposed) return;
560
+ if (eid === id) {
561
+ term.write(data);
562
+ autoScroll();
563
+ }
564
+ };
565
+ const exitHandler = ({ id: eid, code }) => {
566
+ if (disposed) return;
567
+ if (eid === id) {
568
+ term.write(`\r\n\x1b[33m[Process exited with code ${code}]\x1b[0m\r\n`);
569
+ }
570
+ };
571
+
572
+ socket.on('terminal:data', dataHandler);
573
+ socket.on('terminal:exit', exitHandler);
574
+
575
+ // Terminal input -> socket (with CSI/OSC response filtering)
576
+ const csiResponseRe =
577
+ /\x1b\[\??\d+;\d+R|\x1b\[\?[\d;]+c|\x1b\[>[\d;]+c|\x1b\[[03]n|\x1b\[\?[\d;]+\$y/g;
578
+ const oscResponseRe =
579
+ /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
580
+
581
+ const createdAt = Date.now();
582
+ const FILTER_WINDOW_MS = 3000;
583
+
584
+ term.onData((data) => {
585
+ let out = data;
586
+ if (Date.now() - createdAt < FILTER_WINDOW_MS) {
587
+ out = data.replace(csiResponseRe, '').replace(oscResponseRe, '');
588
+ }
589
+ if (out) socket.emit('terminal:input', { id, data: out });
590
+ });
591
+
592
+ // Resize observer — suppressed during batch restore to prevent premature fits
593
+ let roTimer;
594
+ const ro = new ResizeObserver(() => {
595
+ if (disposed || _restoring) return;
596
+ clearTimeout(roTimer);
597
+ roTimer = setTimeout(() => {
598
+ if (disposed || _restoring) return;
599
+ try {
600
+ fitAddon.fit();
601
+ autoScroll();
602
+ socket.emit('terminal:resize', { id, cols: term.cols, rows: term.rows });
603
+ } catch {}
604
+ }, 100);
605
+ });
606
+ ro.observe(termDiv);
607
+
608
+ // Focus tracking
609
+ let lastTapTime = 0;
610
+ termDiv.addEventListener('pointerdown', (e) => {
611
+ if (e.pointerType === 'touch') {
612
+ const now = Date.now();
613
+ const doubleTap = now - lastTapTime < 300;
614
+ lastTapTime = now;
615
+ if (doubleTap) {
616
+ enableKeyboard();
617
+ onFocus(id, false);
618
+ } else {
619
+ onFocus(id, true);
620
+ }
621
+ } else {
622
+ onFocus(id, false);
623
+ }
624
+ });
625
+
626
+ // Close button
627
+ closeBtn.addEventListener('click', () => destroy());
628
+
629
+ function fit() {
630
+ try {
631
+ fitAddon.fit();
632
+ autoScroll();
633
+ socket.emit('terminal:resize', { id, cols: term.cols, rows: term.rows });
634
+ } catch {}
635
+ }
636
+
637
+ function sendInput(text) {
638
+ socket.emit('terminal:input', { id, data: text });
639
+ term.focus();
640
+ }
641
+
642
+ function destroy() {
643
+ socket.emit('terminal:close', { id });
644
+ onClose(id);
645
+ }
646
+
647
+ function cleanup() {
648
+ disposed = true;
649
+ clearTimeout(roTimer);
650
+ socket.off('terminal:data', dataHandler);
651
+ socket.off('terminal:exit', exitHandler);
652
+ ro.disconnect();
653
+ term.dispose();
654
+ wrapper.remove();
655
+ }
656
+
657
+ function setTitle(text) {
658
+ titleEl.textContent = text;
659
+ }
660
+
661
+ function writeScrollback(data) {
662
+ if (!data) return;
663
+ term.write(data, () => {
664
+ // Sync alt-screen class after replay — the onBufferChange event may have
665
+ // been missed if the alt-screen entry sequence was truncated from scrollback.
666
+ if (term.buffer.active.type === 'alternate') {
667
+ termDiv.classList.add('alt-screen');
668
+ } else {
669
+ termDiv.classList.remove('alt-screen');
670
+ }
671
+
672
+ // During batch restore, skip — centralized fit + scroll happens after grid is set
673
+ if (_restoring || disposed) return;
674
+ requestAnimationFrame(() => {
675
+ document.fonts.ready.then(() => {
676
+ if (disposed) return;
677
+ if (termDiv.offsetWidth > 0 && termDiv.offsetHeight > 0) {
678
+ fitAddon.fit();
679
+ socket.emit('terminal:resize', { id, cols: term.cols, rows: term.rows });
680
+ }
681
+ _atBottom = true;
682
+ term.scrollToBottom();
683
+ });
684
+ });
685
+ });
686
+ }
687
+
688
+ function scrollToBottom() { _atBottom = true; term.scrollToBottom(); }
689
+
690
+ return { id, element: wrapper, fit, sendInput, destroy, cleanup, setTitle, disableKeyboard, writeScrollback, scrollToBottom };
691
+ }
692
+
693
+ // ── Browser pane creation ───────────────────────────────────────────
694
+
695
+ function createBrowserPaneLocal({ id, url, title, onClose, onFocus, onTitleChange }) {
696
+ const wrapper = document.createElement('div');
697
+ wrapper.className = 'pane pane-browser';
698
+ wrapper.dataset.id = id;
699
+
700
+ const header = document.createElement('div');
701
+ header.className = 'pane-header';
702
+ header.draggable = true;
703
+
704
+ const titleEl = document.createElement('span');
705
+ titleEl.className = 'pane-title';
706
+ titleEl.textContent = title || 'Browser';
707
+
708
+ const closeBtn = document.createElement('button');
709
+ closeBtn.className = 'pane-close';
710
+ closeBtn.title = 'Close';
711
+ closeBtn.textContent = '\u2715';
712
+
713
+ header.appendChild(titleEl);
714
+ header.appendChild(closeBtn);
715
+
716
+ // ── Drag-to-reorder ──────────────────────────────────────────────
717
+ _attachDragHandlers(header, wrapper, id);
718
+
719
+ // Navigation bar
720
+ const navBar = document.createElement('div');
721
+ navBar.className = 'browser-nav';
722
+
723
+ const backBtn = document.createElement('button');
724
+ backBtn.className = 'browser-nav-btn';
725
+ backBtn.textContent = '\u2190';
726
+ backBtn.title = 'Back';
727
+
728
+ const fwdBtn = document.createElement('button');
729
+ fwdBtn.className = 'browser-nav-btn';
730
+ fwdBtn.textContent = '\u2192';
731
+ fwdBtn.title = 'Forward';
732
+
733
+ const reloadBtn = document.createElement('button');
734
+ reloadBtn.className = 'browser-nav-btn';
735
+ reloadBtn.textContent = '\u21BB';
736
+ reloadBtn.title = 'Reload';
737
+
738
+ const urlInput = document.createElement('input');
739
+ urlInput.type = 'text';
740
+ urlInput.className = 'browser-url-input';
741
+ urlInput.value = url || '';
742
+ urlInput.placeholder = 'Enter URL...';
743
+ urlInput.spellcheck = false;
744
+
745
+ navBar.append(backBtn, fwdBtn, reloadBtn, urlInput);
746
+
747
+ // Iframe
748
+ const iframe = document.createElement('iframe');
749
+ iframe.className = 'browser-iframe';
750
+ iframe.sandbox = 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
751
+ iframe.allow = 'autoplay; fullscreen; focus-without-user-activation';
752
+ iframe.setAttribute('referrerpolicy', 'no-referrer');
753
+
754
+ // Error overlay
755
+ const errorOverlay = document.createElement('div');
756
+ errorOverlay.className = 'browser-error';
757
+ errorOverlay.style.display = 'none';
758
+
759
+ // Loading indicator
760
+ const loadingEl = document.createElement('div');
761
+ loadingEl.className = 'browser-error';
762
+ loadingEl.textContent = 'Loading\u2026';
763
+ loadingEl.style.display = 'none';
764
+
765
+ const iframeWrap = document.createElement('div');
766
+ iframeWrap.className = 'browser-viewport';
767
+ iframeWrap.appendChild(iframe);
768
+ iframeWrap.appendChild(errorOverlay);
769
+ iframeWrap.appendChild(loadingEl);
770
+
771
+ wrapper.append(header, navBar, iframeWrap);
772
+
773
+ // URL rewriting
774
+ function resolveUrl(rawUrl) {
775
+ try {
776
+ const u = new URL(rawUrl);
777
+ const isYouTube = /^(www\.)?youtube\.com$/.test(u.hostname);
778
+ if (isYouTube && u.pathname === '/watch') {
779
+ const v = u.searchParams.get('v');
780
+ if (v) return { url: `https://www.youtube.com/embed/${v}?autoplay=1`, isEmbed: true };
781
+ }
782
+ if (u.hostname === 'youtu.be') {
783
+ const v = u.pathname.slice(1);
784
+ if (v) return { url: `https://www.youtube.com/embed/${v}?autoplay=1`, isEmbed: true };
785
+ }
786
+ if (isYouTube && u.pathname === '/playlist') {
787
+ const list = u.searchParams.get('list');
788
+ if (list) return { url: `https://www.youtube.com/embed/videoseries?list=${list}`, isEmbed: true };
789
+ }
790
+ if (isYouTube || u.hostname === 'youtu.be') {
791
+ return { url: rawUrl, isEmbed: true };
792
+ }
793
+ } catch {}
794
+ return { url: rawUrl, isEmbed: false };
795
+ }
796
+
797
+ function _isLocalUrl(rawUrl) {
798
+ try {
799
+ const u = new URL(rawUrl);
800
+ const h = u.hostname;
801
+ if (h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h === '::1') return true;
802
+ if (/^10\./.test(h)) return true;
803
+ if (/^192\.168\./.test(h)) return true;
804
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
805
+ return false;
806
+ } catch {}
807
+ return false;
808
+ }
809
+
810
+ const CLICK_INTERCEPTOR = '<script>(function(){' +
811
+ 'document.addEventListener("click",function(e){' +
812
+ 'var a=e.target.closest("a");' +
813
+ 'if(a&&a.href){e.preventDefault();e.stopPropagation();' +
814
+ 'window.parent.postMessage({type:"proxy-navigate",url:a.href},"*");}' +
815
+ '},true);' +
816
+ 'document.addEventListener("submit",function(e){' +
817
+ 'e.preventDefault();e.stopPropagation();' +
818
+ 'var f=e.target;var url=f.action||window.location.href;' +
819
+ 'window.parent.postMessage({type:"proxy-navigate",url:url},"*");' +
820
+ '},true);' +
821
+ 'var _open=window.open;window.open=function(url){' +
822
+ 'if(url){window.parent.postMessage({type:"proxy-navigate",url:url},"*");}' +
823
+ '};' +
824
+ '})()<\/script>';
825
+
826
+ // Navigation logic
827
+ let navHistory = [];
828
+ let historyIdx = -1;
829
+ let loadAbort = null;
830
+
831
+ function _extractDomain(rawUrl) {
832
+ try { return new URL(rawUrl).hostname.replace(/^www\./, ''); } catch { return ''; }
833
+ }
834
+
835
+ function _updateTitle(rawUrl) {
836
+ const domain = _extractDomain(rawUrl);
837
+ const label = domain || 'Browser';
838
+ titleEl.textContent = label;
839
+ if (onTitleChange) onTitleChange(id, label);
840
+ }
841
+
842
+ function _resolveProtocol(rawUrl) {
843
+ if (/^https?:\/\//i.test(rawUrl) || /^\/\//.test(rawUrl)) return rawUrl;
844
+ if (/^\//.test(rawUrl)) return window.location.origin + rawUrl;
845
+ if (/^(localhost|[\w.-]+\.\w{2,})/.test(rawUrl)) {
846
+ return (/^localhost/.test(rawUrl) ? 'http://' : 'https://') + rawUrl;
847
+ }
848
+ return 'https://' + rawUrl;
849
+ }
850
+
851
+ function navigate(newUrl) {
852
+ if (!newUrl) return;
853
+ if (historyIdx < navHistory.length - 1) {
854
+ navHistory = navHistory.slice(0, historyIdx + 1);
855
+ }
856
+ navHistory.push(newUrl);
857
+ historyIdx = navHistory.length - 1;
858
+ urlInput.value = newUrl;
859
+ updateNavButtons();
860
+ _updateTitle(_resolveProtocol(newUrl));
861
+ _loadUrl(newUrl);
862
+ }
863
+
864
+ async function _loadUrl(rawUrl) {
865
+ const withProto = _resolveProtocol(rawUrl);
866
+ const resolved = resolveUrl(withProto);
867
+ errorOverlay.style.display = 'none';
868
+ loadingEl.style.display = 'none';
869
+
870
+ if (loadAbort) { loadAbort.abort(); loadAbort = null; }
871
+
872
+ if (_isLocalUrl(withProto)) {
873
+ iframe.removeAttribute('srcdoc');
874
+ iframe.src = resolved.url;
875
+ return;
876
+ }
877
+
878
+ if (resolved.isEmbed) {
879
+ iframe.removeAttribute('srcdoc');
880
+ iframe.src = resolved.url;
881
+ return;
882
+ }
883
+
884
+ loadAbort = new AbortController();
885
+ loadingEl.style.display = 'flex';
886
+ iframe.removeAttribute('src');
887
+ iframe.srcdoc = '';
888
+
889
+ try {
890
+ const resp = await fetch(`/proxy?url=${encodeURIComponent(resolved.url)}`, {
891
+ signal: loadAbort.signal,
892
+ });
893
+
894
+ if (!resp.ok) {
895
+ const errBody = await resp.text();
896
+ let msg;
897
+ try { msg = JSON.parse(errBody).error; } catch { msg = `HTTP ${resp.status}`; }
898
+ throw new Error(msg);
899
+ }
900
+
901
+ const finalUrl = resp.headers.get('X-Final-URL') || resolved.url;
902
+ let html = await resp.text();
903
+
904
+ const u = new URL(finalUrl);
905
+ const basePath = u.pathname.replace(/[^/]*$/, '');
906
+ const baseHref = `${u.protocol}//${u.host}${basePath}`;
907
+
908
+ if (/<head[^>]*>/i.test(html)) {
909
+ html = html.replace(/<head([^>]*)>/i, `<head$1><base href="${baseHref}">${CLICK_INTERCEPTOR}`);
910
+ } else if (/<html[^>]*>/i.test(html)) {
911
+ html = html.replace(/<html([^>]*)>/i, `<html$1><head><base href="${baseHref}">${CLICK_INTERCEPTOR}</head>`);
912
+ } else {
913
+ html = `<head><base href="${baseHref}">${CLICK_INTERCEPTOR}</head>${html}`;
914
+ }
915
+
916
+ html = html.replace(/<link[^>]*rel=["']manifest["'][^>]*>/gi, '');
917
+ html = html.replace(/<meta[^>]*http-equiv=["']Content-Security-Policy["'][^>]*>/gi, '');
918
+
919
+ if (finalUrl !== resolved.url) {
920
+ urlInput.value = finalUrl;
921
+ if (historyIdx >= 0) navHistory[historyIdx] = finalUrl;
922
+ _updateTitle(finalUrl);
923
+ }
924
+
925
+ loadingEl.style.display = 'none';
926
+ iframe.srcdoc = html;
927
+ } catch (err) {
928
+ if (err.name === 'AbortError') return;
929
+ loadingEl.style.display = 'none';
930
+ errorOverlay.textContent = `Failed to load: ${err.message}`;
931
+ errorOverlay.style.display = 'flex';
932
+ } finally {
933
+ loadAbort = null;
934
+ }
935
+ }
936
+
937
+ function goBack() {
938
+ if (historyIdx > 0) {
939
+ historyIdx--;
940
+ urlInput.value = navHistory[historyIdx];
941
+ updateNavButtons();
942
+ _updateTitle(_resolveProtocol(navHistory[historyIdx]));
943
+ _loadUrl(navHistory[historyIdx]);
944
+ }
945
+ }
946
+
947
+ function goForward() {
948
+ if (historyIdx < navHistory.length - 1) {
949
+ historyIdx++;
950
+ urlInput.value = navHistory[historyIdx];
951
+ updateNavButtons();
952
+ _updateTitle(_resolveProtocol(navHistory[historyIdx]));
953
+ _loadUrl(navHistory[historyIdx]);
954
+ }
955
+ }
956
+
957
+ function reload() {
958
+ if (historyIdx >= 0) {
959
+ errorOverlay.style.display = 'none';
960
+ _loadUrl(navHistory[historyIdx]);
961
+ }
962
+ }
963
+
964
+ function updateNavButtons() {
965
+ backBtn.disabled = historyIdx <= 0;
966
+ fwdBtn.disabled = historyIdx >= navHistory.length - 1;
967
+ }
968
+
969
+ // Handle navigation from injected click interceptor
970
+ const messageHandler = (e) => {
971
+ if (e.source !== iframe.contentWindow) return;
972
+ if (e.data && e.data.type === 'proxy-navigate') {
973
+ navigate(e.data.url);
974
+ }
975
+ };
976
+ window.addEventListener('message', messageHandler);
977
+
978
+ // Event handlers
979
+ urlInput.addEventListener('keydown', (e) => {
980
+ if (e.key === 'Enter') {
981
+ e.preventDefault();
982
+ navigate(urlInput.value.trim());
983
+ }
984
+ e.stopPropagation();
985
+ });
986
+
987
+ urlInput.addEventListener('focus', () => urlInput.select());
988
+ backBtn.addEventListener('click', goBack);
989
+ fwdBtn.addEventListener('click', goForward);
990
+ reloadBtn.addEventListener('click', reload);
991
+
992
+ iframe.addEventListener('error', () => {
993
+ errorOverlay.textContent = 'Failed to load page';
994
+ errorOverlay.style.display = 'flex';
995
+ });
996
+
997
+ // Focus tracking
998
+ wrapper.addEventListener('pointerdown', (e) => {
999
+ const isTouch = e.pointerType === 'touch';
1000
+ onFocus(id, isTouch);
1001
+ });
1002
+
1003
+ // Close
1004
+ closeBtn.addEventListener('click', () => destroy());
1005
+
1006
+ function destroy() {
1007
+ socket.emit('terminal:close', { id });
1008
+ onClose(id);
1009
+ }
1010
+
1011
+ function cleanup() {
1012
+ window.removeEventListener('message', messageHandler);
1013
+ if (loadAbort) { loadAbort.abort(); loadAbort = null; }
1014
+ iframe.removeAttribute('srcdoc');
1015
+ iframe.src = 'about:blank';
1016
+ wrapper.remove();
1017
+ }
1018
+
1019
+ function setTitle(text) { titleEl.textContent = text; }
1020
+ function fit() {}
1021
+ function scrollToBottom() {}
1022
+ function disableKeyboard() {}
1023
+ function writeScrollback() {}
1024
+
1025
+ updateNavButtons();
1026
+ if (url) navigate(url);
1027
+
1028
+ return {
1029
+ id,
1030
+ element: wrapper,
1031
+ fit,
1032
+ sendInput() {},
1033
+ destroy,
1034
+ cleanup,
1035
+ setTitle,
1036
+ disableKeyboard,
1037
+ writeScrollback,
1038
+ scrollToBottom,
1039
+ };
1040
+ }
1041
+
1042
+ // ── Server-driven pane lifecycle ────────────────────────────────────
1043
+
1044
+ async function _addPaneFromServer(refs, { id, shellType, title, num, cwd, url, projectId }, scrollback, skipRelayout = false) {
1045
+ if (panes.has(id)) return;
1046
+
1047
+ // Ensure xterm.js is loaded before creating terminal panes
1048
+ if (shellType !== 'browser' && !window.Terminal) {
1049
+ await ensureXterm();
1050
+ if (!_container) return; // unmounted while waiting
1051
+ }
1052
+
1053
+ const onClose = () => { /* state:pane-removed from server handles cleanup */ };
1054
+ const onFocus = (focusedId, isTouch = false) => {
1055
+ const switching = activePaneId !== focusedId;
1056
+ setActivePaneHighlight(focusedId);
1057
+ socket.emit('state:set-active-pane', { paneId: focusedId });
1058
+ if (switching) panes.get(focusedId)?.scrollToBottom();
1059
+ if (!isTouch) {
1060
+ panes.get(focusedId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
1061
+ }
1062
+ };
1063
+
1064
+ const onTitleChange = (paneId, label) => {
1065
+ const fullLabel = makeLabel(panes.get(paneId)?._num ?? '', label);
1066
+ const tabEl = refs.tabBar.querySelector(`.shell-tab[data-tab="${paneId}"] .shell-tab-label`);
1067
+ if (tabEl) tabEl.textContent = fullLabel;
1068
+ };
1069
+
1070
+ const browserLabel = url ? undefined : 'Browser';
1071
+ const initialLabel = shellType === 'browser' ? makeLabel(num, browserLabel || title) : title;
1072
+ const pane = shellType === 'browser'
1073
+ ? createBrowserPaneLocal({ id, url, title: initialLabel, onClose, onFocus, onTitleChange })
1074
+ : createTerminalPane({ id, shellType, title, onClose, onFocus, skipInitialFit: skipRelayout });
1075
+
1076
+ pane._num = num;
1077
+ pane._cwd = cwd || null;
1078
+ pane._projectId = projectId || null;
1079
+ panes.set(id, pane);
1080
+ refs.paneContainer.appendChild(pane.element);
1081
+ addTab(refs, id, initialLabel);
1082
+
1083
+ // If CWD is known, show folder in label
1084
+ if (cwd) {
1085
+ const folder = cwd.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '/';
1086
+ const label = makeLabel(num, folder);
1087
+ pane.setTitle(label);
1088
+ const tabEl = refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"] .shell-tab-label`);
1089
+ if (tabEl) tabEl.textContent = label;
1090
+ }
1091
+
1092
+ if (scrollback) pane.writeScrollback(scrollback);
1093
+
1094
+ if (!skipRelayout) relayout(refs);
1095
+ }
1096
+
1097
+ function _removePaneLocal(refs, id) {
1098
+ const pane = panes.get(id);
1099
+ if (!pane) return;
1100
+
1101
+ const keys = [...panes.keys()];
1102
+ const closedIdx = keys.indexOf(id);
1103
+ const prevKey = closedIdx > 0 ? keys[closedIdx - 1] : keys[closedIdx + 1] ?? null;
1104
+
1105
+ pane.cleanup();
1106
+ panes.delete(id);
1107
+ removeTab(refs, id);
1108
+
1109
+ if (activePaneId === id) setActivePaneHighlight(prevKey);
1110
+ if (activeTab === id) activeTab = 'grid';
1111
+ relayout(refs);
1112
+
1113
+ if (activePaneId && activeTab === 'grid') {
1114
+ setTimeout(() => {
1115
+ panes.get(activePaneId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
1116
+ }, 50);
1117
+ }
1118
+ }
1119
+
1120
+ // ── Request a new pane ──────────────────────────────────────────────
1121
+
1122
+ function requestPane({ shellType, cwd }) {
1123
+ socket.emit('terminal:create', {
1124
+ shellType,
1125
+ cwd: cwd || activeProject?.path || null,
1126
+ cols: 80,
1127
+ rows: 24,
1128
+ currentTab: activeTab,
1129
+ });
1130
+ }
1131
+
1132
+ // ── Project filtering ───────────────────────────────────────────────
1133
+
1134
+ function _applyProjectFilter(refs) {
1135
+ const pid = activeProject?.id || null;
1136
+ for (const [id, pane] of panes) {
1137
+ const visible = !pid || !pane._projectId || pane._projectId === pid;
1138
+ pane.element.classList.toggle('project-hidden', !visible);
1139
+ const tab = refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"]`);
1140
+ if (tab) tab.classList.toggle('project-hidden', !visible);
1141
+ }
1142
+ }
1143
+
1144
+ function _switchProject(refs, newProject) {
1145
+ activeProject = newProject;
1146
+ _applyProjectFilter(refs);
1147
+
1148
+ if (activeTab !== 'grid') {
1149
+ const activePane = panes.get(activeTab);
1150
+ if (activePane?.element.classList.contains('project-hidden')) {
1151
+ _applyActiveTab(refs, 'grid');
1152
+ socket.emit('state:set-active-tab', { tabId: 'grid' });
1153
+ return;
1154
+ }
1155
+ }
1156
+ relayout(refs);
1157
+ }
1158
+
1159
+ // ── Wire socket events ──────────────────────────────────────────────
1160
+
1161
+ function wireSocketEvents(refs) {
1162
+ _socketHandlers['state:snapshot'] = async ({ panes: serverPanes, activeTab: at, activePaneId: ap, scrollbacks, activeProject: snapshotProject }) => {
1163
+ const serverIds = new Set(serverPanes.map(p => p.id));
1164
+
1165
+ if (snapshotProject && !activeProject) activeProject = snapshotProject;
1166
+
1167
+ // Phase 1: Create panes WITHOUT scrollback. Suppress fits during batch
1168
+ // creation so terminals stay at default 80×24 until the grid is set up.
1169
+ _restoring = true;
1170
+ try {
1171
+ for (const paneData of serverPanes) {
1172
+ await _addPaneFromServer(refs, paneData, null /* scrollback deferred */, true);
1173
+ }
1174
+
1175
+ // Clean up panes that no longer exist on server
1176
+ for (const [id, pane] of panes) {
1177
+ if (!serverIds.has(id)) {
1178
+ pane.cleanup();
1179
+ panes.delete(id);
1180
+ removeTab(refs, id);
1181
+ }
1182
+ }
1183
+
1184
+ // Clean up orphan tabs
1185
+ for (const tab of refs.tabBar.querySelectorAll('.shell-tab:not([data-tab="grid"])')) {
1186
+ if (!serverIds.has(tab.dataset.tab)) tab.remove();
1187
+ }
1188
+ } finally {
1189
+ _restoring = false;
1190
+ }
1191
+
1192
+ // Phase 2: Establish grid layout and fit terminals to actual container size.
1193
+ _applyProjectFilter(refs);
1194
+ _applyActiveTab(refs, at || 'grid');
1195
+
1196
+ // Phase 3: Write scrollback AFTER terminals are fit to correct dimensions.
1197
+ // This prevents xterm from baking content at 80×24 (wrong line wrapping).
1198
+ // Wait for relayout's deferred fit (rAF → fonts.ready) to complete first.
1199
+ requestAnimationFrame(() => {
1200
+ document.fonts.ready.then(() => {
1201
+ // Fit one more time to be sure dimensions are correct before writing
1202
+ for (const pane of panes.values()) pane.fit();
1203
+
1204
+ for (const paneData of serverPanes) {
1205
+ const sb = scrollbacks?.[paneData.id];
1206
+ if (sb) panes.get(paneData.id)?.writeScrollback(sb);
1207
+ }
1208
+ });
1209
+ });
1210
+
1211
+ // Safety re-fit after layout fully settles (handles CSS transitions, late container sizing)
1212
+ setTimeout(() => {
1213
+ if (!_container) return;
1214
+ for (const pane of panes.values()) {
1215
+ pane.fit();
1216
+ }
1217
+ }, 300);
1218
+
1219
+ if (ap) {
1220
+ setActivePaneHighlight(ap);
1221
+ if ((at || 'grid') === 'grid') {
1222
+ setTimeout(() => {
1223
+ panes.get(ap)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
1224
+ }, 100);
1225
+ }
1226
+ }
1227
+ };
1228
+
1229
+ _socketHandlers['state:pane-added'] = (paneData) => {
1230
+ _addPaneFromServer(refs, paneData, null);
1231
+ };
1232
+
1233
+ _socketHandlers['state:pane-removed'] = ({ id }) => {
1234
+ _removePaneLocal(refs, id);
1235
+ };
1236
+
1237
+ _socketHandlers['state:active-tab'] = ({ tabId }) => {
1238
+ _applyActiveTab(refs, tabId);
1239
+ };
1240
+
1241
+ _socketHandlers['project:active'] = (project) => {
1242
+ _switchProject(refs, project);
1243
+ };
1244
+
1245
+ _socketHandlers['state:active-pane'] = ({ paneId }) => {
1246
+ setActivePaneHighlight(paneId);
1247
+ if (paneId && activeTab === 'grid') {
1248
+ setTimeout(() => {
1249
+ panes.get(paneId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
1250
+ }, 50);
1251
+ }
1252
+ };
1253
+
1254
+ _socketHandlers['terminal:cwd'] = ({ id, cwd }) => {
1255
+ const pane = panes.get(id);
1256
+ if (!pane) return;
1257
+ pane._cwd = cwd;
1258
+ const folder = cwd.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '/';
1259
+ const label = makeLabel(pane._num, folder);
1260
+ const tab = refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"]`);
1261
+ if (tab) {
1262
+ tab.querySelector('.shell-tab-label').textContent = label;
1263
+ tab.title = cwd;
1264
+ }
1265
+ pane.setTitle(label);
1266
+ };
1267
+
1268
+ _socketHandlers['state:panes-reordered'] = ({ order }) => {
1269
+ if (!Array.isArray(order)) return;
1270
+ // Reorder DOM elements to match server order
1271
+ for (const id of order) {
1272
+ const pane = panes.get(id);
1273
+ if (pane && !pane.element.classList.contains('project-hidden')) {
1274
+ refs.paneContainer.appendChild(pane.element);
1275
+ }
1276
+ }
1277
+ // Also reorder tabs to match
1278
+ for (const id of order) {
1279
+ const tab = refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"]`);
1280
+ if (tab) refs.tabBar.appendChild(tab);
1281
+ }
1282
+ // Re-fit after DOM reorder — moving elements can change terminal dimensions
1283
+ relayout(refs);
1284
+ };
1285
+
1286
+ _socketHandlers['state:panes-renumbered'] = (updates) => {
1287
+ for (const { id, num } of updates) {
1288
+ const pane = panes.get(id);
1289
+ if (!pane) continue;
1290
+ pane._num = num;
1291
+ const folder = pane._cwd
1292
+ ? pane._cwd.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '/'
1293
+ : null;
1294
+ const label = makeLabel(num, folder);
1295
+ pane.setTitle(label);
1296
+ const tab = refs.tabBar.querySelector(`.shell-tab[data-tab="${id}"]`);
1297
+ if (tab) tab.querySelector('.shell-tab-label').textContent = label;
1298
+ }
1299
+ };
1300
+
1301
+ _socketHandlers['disconnect'] = () => {
1302
+ refs.disconnect.style.display = 'flex';
1303
+ };
1304
+
1305
+ _socketHandlers['connect'] = () => {
1306
+ refs.disconnect.style.display = 'none';
1307
+ // Re-sync after reconnect — snapshot handler safely skips existing panes
1308
+ socket.emit('state:request-snapshot');
1309
+ };
1310
+
1311
+ // Register all handlers
1312
+ for (const [event, handler] of Object.entries(_socketHandlers)) {
1313
+ socket.on(event, handler);
1314
+ }
1315
+ }
1316
+
1317
+ function unwireSocketEvents() {
1318
+ for (const [event, handler] of Object.entries(_socketHandlers)) {
1319
+ socket.off(event, handler);
1320
+ }
1321
+ // Clear the handlers map
1322
+ for (const key of Object.keys(_socketHandlers)) {
1323
+ delete _socketHandlers[key];
1324
+ }
1325
+ }
1326
+
1327
+ // ── Exports ─────────────────────────────────────────────────────────
1328
+
1329
+ export async function mount(container, ctx) {
1330
+ _container = container;
1331
+
1332
+ // 1. Scope the container
1333
+ container.classList.add('page-shell');
1334
+
1335
+ // 2. Build HTML
1336
+ container.innerHTML = BODY_HTML;
1337
+
1338
+ // 3. Get refs
1339
+ const refs = getRefs(container);
1340
+
1341
+ // 4. Set initial project
1342
+ activeProject = ctx?.project || null;
1343
+
1344
+ // 5. Load xterm.js dynamically
1345
+ await ensureXterm();
1346
+
1347
+ // Guard: if unmounted while loading xterm, bail out
1348
+ if (!_container) return;
1349
+
1350
+ // 6. Wire socket events
1351
+ wireSocketEvents(refs);
1352
+
1353
+ if (panes.size > 0) {
1354
+ // Reattach existing panes (returning from another page)
1355
+ for (const [id, pane] of panes) {
1356
+ refs.paneContainer.appendChild(pane.element);
1357
+ const title = pane.element.querySelector('.pane-title')?.textContent || '';
1358
+ addTab(refs, id, title);
1359
+ }
1360
+ _applyProjectFilter(refs);
1361
+ _applyActiveTab(refs, activeTab);
1362
+
1363
+ // Re-fit after reattachment and auto-focus the active terminal
1364
+ requestAnimationFrame(() => {
1365
+ document.fonts.ready.then(() => {
1366
+ for (const pane of panes.values()) {
1367
+ pane.fit();
1368
+ pane.scrollToBottom();
1369
+ }
1370
+ // Delayed focus to avoid double-cursor flicker with apps like Claude Code
1371
+ if (!isMobile()) {
1372
+ const focusId = activeTab !== 'grid' ? activeTab : activePaneId;
1373
+ if (focusId) {
1374
+ setTimeout(() => {
1375
+ panes.get(focusId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
1376
+ }, 300);
1377
+ }
1378
+ }
1379
+ });
1380
+ });
1381
+ } else {
1382
+ // Fresh mount — request snapshot from server
1383
+ socket.emit('state:request-snapshot');
1384
+ }
1385
+
1386
+ // 7. Wire Grid tab click
1387
+ refs.tabBar.querySelector('[data-tab="grid"]').addEventListener('click', () => setActiveTab(refs, 'grid'));
1388
+
1389
+ // 7a. Wire mobile action buttons (new terminal / new browser)
1390
+ refs.mobileActions?.addEventListener('click', (e) => {
1391
+ const btn = e.target.closest('[data-action]');
1392
+ if (!btn) return;
1393
+ if (btn.dataset.action === 'new-terminal') requestPane({ shellType: 'default' });
1394
+ if (btn.dataset.action === 'new-browser') socket.emit('browser:create', { url: '', currentTab: activeTab });
1395
+ });
1396
+
1397
+ // 7b. Auto-focus active terminal when clicking the shell container background
1398
+ container.addEventListener('click', (e) => {
1399
+ if (isMobile()) return;
1400
+ // Only handle clicks on the container/pane-container background, not on interactive elements
1401
+ const target = e.target;
1402
+ if (target !== container && target !== refs.paneContainer && !target.matches('.shell-empty-state, .shell-empty-state *')) return;
1403
+ const focusId = activeTab !== 'grid' ? activeTab : activePaneId;
1404
+ if (focusId) {
1405
+ setTimeout(() => {
1406
+ panes.get(focusId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
1407
+ }, 300);
1408
+ }
1409
+ });
1410
+
1411
+ // 8. Voice input — listen for voice:result on document
1412
+ _voiceHandler = (e) => {
1413
+ const text = e.detail?.text;
1414
+ if (text && activePaneId) panes.get(activePaneId)?.sendInput(text);
1415
+ };
1416
+ document.addEventListener('voice:result', _voiceHandler);
1417
+
1418
+ // 9. Keyboard shortcuts
1419
+ _keydownHandler = (e) => {
1420
+ if (typeof KeymapRegistry === 'undefined') return;
1421
+ const action = KeymapRegistry.resolve(e);
1422
+ if (!action) return;
1423
+
1424
+ switch (action) {
1425
+ case 'shell:terminal-up':
1426
+ case 'shell:terminal-down':
1427
+ case 'shell:terminal-left':
1428
+ case 'shell:terminal-right': {
1429
+ e.preventDefault();
1430
+ if (activeTab !== 'grid') return;
1431
+ const ids = [...panes.entries()]
1432
+ .filter(([, p]) => !p.element.classList.contains('project-hidden'))
1433
+ .map(([id]) => id);
1434
+ if (ids.length === 0) return;
1435
+ const idx = ids.indexOf(activePaneId);
1436
+ if (idx === -1) return;
1437
+ const count = ids.length;
1438
+ const mobile = window.innerWidth <= 640;
1439
+ const cols = mobile ? 1 : count === 1 ? 1 : count <= 4 ? 2 : 3;
1440
+ const row = Math.floor(idx / cols);
1441
+ const col = idx % cols;
1442
+ let target = idx;
1443
+ if (action === 'shell:terminal-right') target = row * cols + Math.min(col + 1, Math.min(cols, count - row * cols) - 1);
1444
+ else if (action === 'shell:terminal-left') target = row * cols + Math.max(col - 1, 0);
1445
+ else if (action === 'shell:terminal-down') {
1446
+ const below = (row + 1) * cols + col;
1447
+ target = below < count ? below : idx;
1448
+ } else if (action === 'shell:terminal-up') {
1449
+ const above = (row - 1) * cols + col;
1450
+ target = above >= 0 ? above : idx;
1451
+ }
1452
+ if (target === idx) break;
1453
+ const targetId = ids[target];
1454
+ setActivePaneHighlight(targetId);
1455
+ socket.emit('state:set-active-pane', { paneId: targetId });
1456
+ panes.get(targetId)?.scrollToBottom();
1457
+ panes.get(targetId)?.element.querySelector('.xterm-helper-textarea')?.focus({ preventScroll: true });
1458
+ break;
1459
+ }
1460
+ case 'shell:new-terminal': {
1461
+ e.preventDefault();
1462
+ requestPane({ shellType: 'default' });
1463
+ break;
1464
+ }
1465
+ case 'shell:new-browser': {
1466
+ e.preventDefault();
1467
+ socket.emit('browser:create', { url: '', currentTab: activeTab });
1468
+ break;
1469
+ }
1470
+ case 'shell:close-pane': {
1471
+ e.preventDefault();
1472
+ if (activePaneId) panes.get(activePaneId)?.destroy();
1473
+ break;
1474
+ }
1475
+ default: {
1476
+ const termMatch = action.match(/^shell:terminal-(\d)$/);
1477
+ if (termMatch) {
1478
+ e.preventDefault();
1479
+ const num = parseInt(termMatch[1]);
1480
+ const termTabs = [...refs.tabBar.querySelectorAll('.shell-tab:not(.project-hidden)')]
1481
+ .map(t => t.dataset.tab)
1482
+ .filter(id => id !== 'grid');
1483
+ const targetId = termTabs[num - 1];
1484
+ if (targetId) setActiveTab(refs, targetId);
1485
+ }
1486
+ break;
1487
+ }
1488
+ }
1489
+ };
1490
+ document.addEventListener('keydown', _keydownHandler);
1491
+
1492
+ // 10. Swipe navigation (mobile)
1493
+ let touchStartX = 0;
1494
+ refs.paneContainer.addEventListener('touchstart', (e) => {
1495
+ touchStartX = e.touches[0].clientX;
1496
+ }, { passive: true });
1497
+
1498
+ refs.paneContainer.addEventListener('touchend', (e) => {
1499
+ const dx = e.changedTouches[0].clientX - touchStartX;
1500
+ if (Math.abs(dx) >= 50) {
1501
+ const ids = getNavigableTabs(refs);
1502
+ const idx = ids.indexOf(activeTab);
1503
+ if (idx === -1) return;
1504
+ const next = dx < 0
1505
+ ? (idx + 1) % ids.length
1506
+ : (idx - 1 + ids.length) % ids.length;
1507
+ setActiveTab(refs, ids[next]);
1508
+ }
1509
+ }, { passive: true });
1510
+
1511
+ // 11. Window resize handler
1512
+ const resizeHandler = () => {
1513
+ clearTimeout(_resizeTimer);
1514
+ _resizeTimer = setTimeout(() => relayout(refs), 100);
1515
+ };
1516
+ window.addEventListener('resize', resizeHandler);
1517
+ // Store for cleanup
1518
+ _container._resizeHandler = resizeHandler;
1519
+
1520
+ _mountedOnce = true;
1521
+ }
1522
+
1523
+ export function unmount(container) {
1524
+ // 1. Remove keyboard handler
1525
+ if (_keydownHandler) {
1526
+ document.removeEventListener('keydown', _keydownHandler);
1527
+ _keydownHandler = null;
1528
+ }
1529
+
1530
+ // 2. Remove voice handler
1531
+ if (_voiceHandler) {
1532
+ document.removeEventListener('voice:result', _voiceHandler);
1533
+ _voiceHandler = null;
1534
+ }
1535
+
1536
+ // 3. Remove resize handler
1537
+ if (container._resizeHandler) {
1538
+ window.removeEventListener('resize', container._resizeHandler);
1539
+ container._resizeHandler = null;
1540
+ }
1541
+ clearTimeout(_resizeTimer);
1542
+ _resizeTimer = null;
1543
+
1544
+ // 4. Unwire module-level socket events (per-pane handlers stay wired)
1545
+ unwireSocketEvents();
1546
+
1547
+ // 5. Detach pane elements (keep xterm instances alive for reattachment)
1548
+ for (const [, pane] of panes) {
1549
+ pane.element.remove();
1550
+ }
1551
+ // Don't clear panes, activePaneId, or activeTab — they persist across navigations
1552
+
1553
+ // 6. Remove scope class & clear HTML
1554
+ _restoring = false;
1555
+ container.classList.remove('page-shell');
1556
+ container.innerHTML = '';
1557
+
1558
+ // 7. Clear module reference
1559
+ _container = null;
1560
+ }
1561
+
1562
+ export function onProjectChange(project) {
1563
+ activeProject = project;
1564
+ if (!_container) return;
1565
+ const refs = getRefs(_container);
1566
+ _applyProjectFilter(refs);
1567
+
1568
+ if (activeTab !== 'grid') {
1569
+ const activePane = panes.get(activeTab);
1570
+ if (activePane?.element.classList.contains('project-hidden')) {
1571
+ _applyActiveTab(refs, 'grid');
1572
+ socket.emit('state:set-active-tab', { tabId: 'grid' });
1573
+ return;
1574
+ }
1575
+ }
1576
+ relayout(refs);
1577
+ }