@nimbus-sh/worker 0.1.5 → 0.1.6

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 (135) hide show
  1. package/README.md +37 -0
  2. package/dist/_shared/crypto.d.ts +15 -0
  3. package/dist/_shared/crypto.d.ts.map +1 -0
  4. package/dist/_shared/crypto.js +122 -0
  5. package/dist/facets/manager.d.ts +24 -0
  6. package/dist/facets/manager.d.ts.map +1 -1
  7. package/dist/facets/manager.js +279 -70
  8. package/dist/facets/process.d.ts +11 -2
  9. package/dist/facets/process.d.ts.map +1 -1
  10. package/dist/facets/process.js +187 -21
  11. package/dist/facets/vite-dev-server.d.ts +2 -0
  12. package/dist/facets/vite-dev-server.d.ts.map +1 -1
  13. package/dist/facets/vite-dev-server.js +39 -12
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/loaders/child-process/spawn-facet.d.ts +1 -3
  18. package/dist/loaders/child-process/spawn-facet.d.ts.map +1 -1
  19. package/dist/loaders/child-process/spawn-facet.js +1 -12
  20. package/dist/loaders/child-process/spawn-pool.d.ts +1 -1
  21. package/dist/loaders/child-process/spawn-pool.d.ts.map +1 -1
  22. package/dist/loaders/child-process/spawn-pool.js +1 -6
  23. package/dist/npm/installer.d.ts.map +1 -1
  24. package/dist/npm/installer.js +47 -27
  25. package/dist/npm/pre-bundle-facet.d.ts.map +1 -1
  26. package/dist/npm/pre-bundle-facet.js +2 -1
  27. package/dist/npm/resolve-one-facet.d.ts.map +1 -1
  28. package/dist/npm/resolve-one-facet.js +41 -13
  29. package/dist/npm/resolver.d.ts.map +1 -1
  30. package/dist/npm/resolver.js +70 -35
  31. package/dist/router/index.d.ts.map +1 -1
  32. package/dist/router/index.js +45 -21
  33. package/dist/router/remote-api.d.ts.map +1 -1
  34. package/dist/router/remote-api.js +29 -8
  35. package/dist/runtime/barrel-synthesizer.d.ts +7 -0
  36. package/dist/runtime/barrel-synthesizer.d.ts.map +1 -1
  37. package/dist/runtime/barrel-synthesizer.js +11 -0
  38. package/dist/runtime/clang-runner.d.ts.map +1 -1
  39. package/dist/runtime/clang-runner.js +1 -8
  40. package/dist/runtime/esbuild-service.d.ts +12 -5
  41. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  42. package/dist/runtime/esbuild-service.js +28 -14
  43. package/dist/runtime/node-runner.d.ts +2 -0
  44. package/dist/runtime/node-runner.d.ts.map +1 -1
  45. package/dist/runtime/node-shims.d.ts +4 -2
  46. package/dist/runtime/node-shims.d.ts.map +1 -1
  47. package/dist/runtime/node-shims.js +404 -55
  48. package/dist/runtime/os-contracts.d.ts +128 -0
  49. package/dist/runtime/os-contracts.d.ts.map +1 -0
  50. package/dist/runtime/os-contracts.js +1 -0
  51. package/dist/runtime/package-manager.d.ts.map +1 -1
  52. package/dist/runtime/package-manager.js +32 -7
  53. package/dist/runtime/port-registry.d.ts +5 -0
  54. package/dist/runtime/port-registry.d.ts.map +1 -1
  55. package/dist/runtime/port-registry.js +22 -1
  56. package/dist/runtime/pyodide-runtime-assets.d.ts +19 -0
  57. package/dist/runtime/pyodide-runtime-assets.d.ts.map +1 -0
  58. package/dist/runtime/pyodide-runtime-assets.js +44 -0
  59. package/dist/runtime/python-repl.d.ts +3 -1
  60. package/dist/runtime/python-repl.d.ts.map +1 -1
  61. package/dist/runtime/python-repl.js +11 -29
  62. package/dist/runtime/python-runner.d.ts +8 -9
  63. package/dist/runtime/python-runner.d.ts.map +1 -1
  64. package/dist/runtime/python-runner.js +897 -61
  65. package/dist/runtime/python-socket-shim.d.ts +11 -0
  66. package/dist/runtime/python-socket-shim.d.ts.map +1 -0
  67. package/dist/runtime/python-socket-shim.js +526 -0
  68. package/dist/runtime/require-resolver.d.ts.map +1 -1
  69. package/dist/runtime/require-resolver.js +2 -11
  70. package/dist/runtime/ruby-gems.d.ts +25 -0
  71. package/dist/runtime/ruby-gems.d.ts.map +1 -0
  72. package/dist/runtime/ruby-gems.js +582 -0
  73. package/dist/runtime/ruby-runner.d.ts +2 -4
  74. package/dist/runtime/ruby-runner.d.ts.map +1 -1
  75. package/dist/runtime/ruby-runner.js +335 -34
  76. package/dist/runtime/runtime-catalog.d.ts +16 -4
  77. package/dist/runtime/runtime-catalog.d.ts.map +1 -1
  78. package/dist/runtime/runtime-catalog.js +21 -10
  79. package/dist/runtime/runtime-registry.d.ts +3 -0
  80. package/dist/runtime/runtime-registry.d.ts.map +1 -1
  81. package/dist/runtime/runtime-registry.js +4 -0
  82. package/dist/runtime/sqlite-runtime-fs-bridge.d.ts +43 -0
  83. package/dist/runtime/sqlite-runtime-fs-bridge.d.ts.map +1 -0
  84. package/dist/runtime/sqlite-runtime-fs-bridge.js +224 -0
  85. package/dist/runtime/static-server.d.ts +3 -0
  86. package/dist/runtime/static-server.d.ts.map +1 -0
  87. package/dist/runtime/static-server.js +121 -0
  88. package/dist/runtime/vfs-snapshot.d.ts +57 -0
  89. package/dist/runtime/vfs-snapshot.d.ts.map +1 -0
  90. package/dist/runtime/vfs-snapshot.js +141 -0
  91. package/dist/runtime/virtual-socket-kernel.d.ts +16 -0
  92. package/dist/runtime/virtual-socket-kernel.d.ts.map +1 -0
  93. package/dist/runtime/virtual-socket-kernel.js +360 -0
  94. package/dist/runtime/wasi-instance.d.ts +6 -3
  95. package/dist/runtime/wasi-instance.d.ts.map +1 -1
  96. package/dist/runtime/wasm-runner.d.ts +1 -25
  97. package/dist/runtime/wasm-runner.d.ts.map +1 -1
  98. package/dist/runtime/wasm-runner.js +1 -160
  99. package/dist/session/agent.d.ts +43 -0
  100. package/dist/session/agent.d.ts.map +1 -0
  101. package/dist/session/agent.js +1252 -0
  102. package/dist/session/init.d.ts.map +1 -1
  103. package/dist/session/init.js +15 -16
  104. package/dist/session/nimbus-session.d.ts +13 -2
  105. package/dist/session/nimbus-session.d.ts.map +1 -1
  106. package/dist/session/nimbus-session.js +55 -7
  107. package/dist/session/programmatic.d.ts +10 -0
  108. package/dist/session/programmatic.d.ts.map +1 -1
  109. package/dist/session/programmatic.js +121 -0
  110. package/dist/session/routes.d.ts.map +1 -1
  111. package/dist/session/routes.js +19 -1
  112. package/dist/session/rpc.d.ts +11 -0
  113. package/dist/session/rpc.d.ts.map +1 -1
  114. package/dist/session/rpc.js +56 -43
  115. package/dist/session/supervisor-rpc.d.ts +15 -0
  116. package/dist/session/supervisor-rpc.d.ts.map +1 -1
  117. package/dist/session/supervisor-rpc.js +32 -0
  118. package/dist/session/ws.d.ts +4 -3
  119. package/dist/session/ws.d.ts.map +1 -1
  120. package/dist/session/ws.js +11 -1
  121. package/dist/shell/unix-commands.js +12 -16
  122. package/dist/vfs/path.d.ts +8 -4
  123. package/dist/vfs/path.d.ts.map +1 -1
  124. package/dist/vfs/path.js +16 -4
  125. package/dist/vfs/sqlite-vfs.d.ts +2 -0
  126. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  127. package/dist/vfs/sqlite-vfs.js +11 -0
  128. package/dist/vfs/symlink-registry.d.ts +7 -1
  129. package/dist/vfs/symlink-registry.d.ts.map +1 -1
  130. package/dist/vfs/symlink-registry.js +16 -11
  131. package/package.json +4 -1
  132. package/public/index.html +240 -0
  133. package/public/s/index.html +1172 -146
  134. package/runtime-contracts/pyodide-workerd-adapter.json +18 -0
  135. package/scripts/bundle-runtime.mjs +99 -24
@@ -29,7 +29,7 @@
29
29
  .toolbar .sep { width: 1px; height: 16px; background: #30363d; margin: 0 4px; }
30
30
  .toolbar .vite-status { font-size: 11px; color: #8b949e; margin-left: auto; }
31
31
  .toolbar .vite-status.running { color: #3fb950; }
32
- /* Process count button. Sits next to the layout-mode buttons. Hidden
32
+ /* Process count button. Sits next to the workspace buttons. Hidden
33
33
  (display:none) until at least one process has been seen in this
34
34
  session so it doesn't clutter a brand-new session's toolbar. */
35
35
  .toolbar #btnProcs { display: none; gap: 6px; align-items: center; font-family: ui-monospace, Menlo, monospace; }
@@ -96,7 +96,35 @@
96
96
  /* Preview panel */
97
97
  .panel-preview { flex: 1; display: flex; flex-direction: column; background: #0d1117; overflow: hidden; min-width: 0; }
98
98
  .panel-preview.hidden { display: none; }
99
- .preview-bar { display: flex; align-items: center; gap: 6px; padding: 3px 8px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
99
+ .preview-bar { display: flex; align-items: center; gap: 6px; padding: 3px 8px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; min-width: 0; }
100
+ .preview-tabs {
101
+ display: flex; align-items: center; gap: 3px; min-width: 110px; max-width: 45%;
102
+ overflow-x: auto; scrollbar-width: none; flex: 0 1 auto;
103
+ }
104
+ .preview-tabs::-webkit-scrollbar { display: none; }
105
+ .preview-bar .preview-tab {
106
+ height: 22px; display: inline-flex; align-items: center; gap: 5px; min-width: 0;
107
+ max-width: 150px; padding: 0 8px; border-radius: 4px 4px 0 0;
108
+ border: 1px solid transparent; border-bottom-color: transparent;
109
+ background: transparent; color: #8b949e; font-size: 11px; line-height: 1;
110
+ white-space: nowrap; cursor: pointer;
111
+ }
112
+ .preview-bar .preview-tab:hover { background: #21262d; color: #c9d1d9; }
113
+ .preview-bar .preview-tab.active {
114
+ background: #0d1117; color: #f0f6fc; border-color: #30363d;
115
+ border-bottom-color: #0d1117;
116
+ }
117
+ .preview-bar .preview-tab.running::before {
118
+ content: ""; width: 6px; height: 6px; border-radius: 999px;
119
+ background: #3fb950; box-shadow: 0 0 8px rgba(63,185,80,0.55);
120
+ flex: 0 0 auto;
121
+ }
122
+ .preview-bar .preview-tab.markdown::before {
123
+ content: "md"; color: #79c0ff; font: 9px/1 ui-monospace, Menlo, monospace;
124
+ border: 1px solid rgba(121,192,255,0.35); border-radius: 3px; padding: 1px 2px;
125
+ flex: 0 0 auto;
126
+ }
127
+ .preview-tab-label { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
100
128
  .preview-bar .url-bar { flex: 1; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 2px 8px; font-size: 11px; border-radius: 4px; font-family: monospace; }
101
129
  .preview-bar button { background: none; border: none; color: #8b949e; cursor: pointer; font-size: 14px; padding: 2px 4px; }
102
130
  .preview-bar button:hover { color: #c9d1d9; }
@@ -151,26 +179,201 @@
151
179
  .footer .stat-value { color: #c9d1d9; }
152
180
  .footer .stat-value.good { color: #3fb950; }
153
181
 
154
- /* Layout modes */
155
- .main.terminal-only .panel-preview,
156
- .main.terminal-only .panel-editor,
157
- .main.terminal-only .resize-handle { display: none; }
158
- .main.terminal-only .panel-terminal { flex: 1; }
159
- .main.preview-only .panel-terminal,
160
- .main.preview-only .panel-editor,
161
- .main.preview-only .resize-handle { display: none; }
162
- .main.preview-only .panel-preview { flex: 1; }
163
-
164
- /* Editor layout mode:
182
+ /* Agent chat */
183
+ .panel-agent {
184
+ display: none; flex: 1; min-width: 0; min-height: 0;
185
+ background: #0f141b; color: #d6dee8; flex-direction: column;
186
+ }
187
+ .agent-shell {
188
+ flex: 1; min-height: 0; display: grid;
189
+ grid-template-rows: auto 1fr auto;
190
+ width: 100%;
191
+ background:
192
+ linear-gradient(180deg, rgba(88,166,255,0.04), rgba(63,185,80,0.015) 28%, transparent 48%),
193
+ #0f141b;
194
+ }
195
+ .agent-top {
196
+ display: flex; align-items: center; gap: 8px;
197
+ padding: 7px 12px; background: rgba(22,27,34,0.96);
198
+ border-bottom: 1px solid #30363d; min-height: 40px;
199
+ }
200
+ .agent-heading { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
201
+ .agent-title { font-size: 12px; font-weight: 650; color: #f0f6fc; white-space: nowrap; }
202
+ .agent-subtitle {
203
+ color: #8b949e; font-size: 11px; overflow: hidden; text-overflow: ellipsis;
204
+ white-space: nowrap; max-width: 220px;
205
+ }
206
+ .agent-status {
207
+ margin-left: auto; font-size: 11px; color: #8b949e;
208
+ font-family: ui-monospace, Menlo, monospace; display: inline-flex;
209
+ align-items: center; gap: 6px; min-width: 0; white-space: nowrap;
210
+ }
211
+ .agent-status::before {
212
+ content: ''; width: 6px; height: 6px; border-radius: 50%;
213
+ background: #6e7681; box-shadow: 0 0 0 1px rgba(255,255,255,0.05);
214
+ }
215
+ .agent-status.ready { color: #3fb950; }
216
+ .agent-status.ready::before { background: #3fb950; box-shadow: 0 0 8px rgba(63,185,80,0.55); }
217
+ .agent-status.warn { color: #d29922; }
218
+ .agent-status.warn::before { background: #d29922; box-shadow: 0 0 8px rgba(210,153,34,0.45); }
219
+ .agent-status.streaming { color: #58a6ff; }
220
+ .agent-status.streaming::before { background: #58a6ff; animation: pulse 1.1s ease-in-out infinite; }
221
+ .agent-top button,
222
+ .agent-composer button {
223
+ background: #21262d; border: 1px solid #30363d; color: #c9d1d9;
224
+ padding: 4px 10px; border-radius: 6px; cursor: pointer; font-size: 11px;
225
+ }
226
+ .agent-top button:hover,
227
+ .agent-composer button:hover { background: #30363d; }
228
+ .agent-top button:disabled,
229
+ .agent-composer button:disabled { opacity: 0.55; cursor: not-allowed; }
230
+ .agent-top .agent-secondary { color: #8b949e; }
231
+ .agent-top select {
232
+ background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
233
+ padding: 4px 8px; border-radius: 6px; font-size: 11px;
234
+ max-width: 260px;
235
+ }
236
+ .agent-messages {
237
+ min-height: 0; overflow: auto; padding: 22px 18px 26px;
238
+ display: flex; flex-direction: column; gap: 16px;
239
+ scrollbar-width: thin; scrollbar-color: #30363d transparent;
240
+ }
241
+ .agent-empty {
242
+ margin: auto; max-width: 560px; color: #8b949e; text-align: center;
243
+ font-size: 13px; line-height: 1.6; padding: 28px;
244
+ border: 1px dashed #30363d; border-radius: 8px;
245
+ background: rgba(22,27,34,0.55);
246
+ }
247
+ .agent-msg {
248
+ width: min(900px, 100%); margin: 0 auto;
249
+ display: flex; gap: 10px; align-items: flex-start;
250
+ }
251
+ .agent-msg.user { justify-content: flex-end; }
252
+ .agent-avatar {
253
+ width: 24px; height: 24px; border-radius: 6px; flex: 0 0 auto;
254
+ display: grid; place-items: center; margin-top: 2px;
255
+ border: 1px solid #30363d; background: #161b22;
256
+ color: #8b949e; font: 10px ui-monospace, Menlo, monospace;
257
+ }
258
+ .agent-msg.assistant .agent-avatar { color: #7ee787; border-color: rgba(63,185,80,0.35); background: rgba(63,185,80,0.08); }
259
+ .agent-msg.user .agent-avatar { display: none; }
260
+ .agent-content {
261
+ min-width: 0; max-width: min(760px, 86%); display: flex; flex-direction: column; gap: 8px;
262
+ }
263
+ .agent-msg.user .agent-content { align-items: flex-end; }
264
+ .agent-meta {
265
+ color: #6e7681; font: 10px ui-monospace, Menlo, monospace;
266
+ text-transform: uppercase; letter-spacing: 0.02em;
267
+ }
268
+ .agent-bubble {
269
+ border-radius: 8px; padding: 10px 12px; overflow-wrap: anywhere;
270
+ font-size: 13px; line-height: 1.58; color: #d6dee8;
271
+ }
272
+ .agent-msg.assistant .agent-bubble {
273
+ background: rgba(22,27,34,0.78); border: 1px solid rgba(48,54,61,0.85);
274
+ }
275
+ .agent-msg.user .agent-bubble {
276
+ background: linear-gradient(180deg, rgba(31,111,235,0.24), rgba(31,111,235,0.15));
277
+ border: 1px solid rgba(88,166,255,0.65); color: #f0f6fc;
278
+ max-width: min(680px, 76%);
279
+ }
280
+ .agent-bubble p { margin: 0 0 10px; }
281
+ .agent-bubble p:last-child { margin-bottom: 0; }
282
+ .agent-bubble ul, .agent-bubble ol { margin: 6px 0 10px 18px; }
283
+ .agent-bubble li { margin: 3px 0; }
284
+ .agent-bubble code {
285
+ background: rgba(110,118,129,0.25); border-radius: 4px;
286
+ padding: 0.12em 0.35em; color: #f0f6fc;
287
+ font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.92em;
288
+ }
289
+ .agent-bubble pre {
290
+ margin: 8px 0; padding: 10px 12px; border-radius: 8px;
291
+ border: 1px solid #30363d; background: #0d1117; overflow: auto;
292
+ white-space: pre;
293
+ }
294
+ .agent-bubble pre code { background: transparent; padding: 0; }
295
+ .agent-thinking {
296
+ display: inline-flex; align-items: center; gap: 7px;
297
+ color: #8b949e; font-size: 12px; padding: 4px 0;
298
+ }
299
+ .agent-thinking .dots { display: inline-flex; gap: 4px; }
300
+ .agent-thinking .dots span {
301
+ width: 5px; height: 5px; border-radius: 50%; background: #58a6ff;
302
+ animation: agent-dot 1s ease-in-out infinite;
303
+ }
304
+ .agent-thinking .dots span:nth-child(2) { animation-delay: 120ms; }
305
+ .agent-thinking .dots span:nth-child(3) { animation-delay: 240ms; }
306
+ @keyframes agent-dot { 0%, 70%, 100% { opacity: 0.32; transform: translateY(0); } 35% { opacity: 1; transform: translateY(-2px); } }
307
+ .agent-cursor {
308
+ display: inline-block; width: 2px; height: 1em; margin-left: 2px;
309
+ background: #58a6ff; vertical-align: -0.15em; animation: agent-cursor 1s steps(1) infinite;
310
+ }
311
+ @keyframes agent-cursor { 0%, 45% { opacity: 1; } 46%, 100% { opacity: 0; } }
312
+ .agent-reasoning {
313
+ border-left: 2px solid rgba(88,166,255,0.65); padding: 7px 10px;
314
+ background: rgba(88,166,255,0.06); color: #8b949e; border-radius: 0 8px 8px 0;
315
+ font-size: 12px; line-height: 1.5;
316
+ }
317
+ .agent-reasoning summary { cursor: pointer; color: #79c0ff; font-weight: 600; }
318
+ .agent-reasoning pre { margin-top: 7px; white-space: pre-wrap; font: 12px/1.5 ui-monospace, Menlo, monospace; color: #c9d1d9; }
319
+ .agent-tools { display: flex; flex-direction: column; gap: 7px; }
320
+ .agent-tool {
321
+ border: 1px solid #30363d; background: rgba(13,17,23,0.72);
322
+ border-radius: 8px; overflow: hidden;
323
+ }
324
+ .agent-tool summary {
325
+ display: flex; align-items: center; gap: 8px; cursor: pointer;
326
+ padding: 7px 9px; list-style: none; user-select: none;
327
+ }
328
+ .agent-tool summary::-webkit-details-marker { display: none; }
329
+ .agent-tool .tool-dot {
330
+ width: 7px; height: 7px; border-radius: 50%; background: #6e7681; flex: 0 0 auto;
331
+ }
332
+ .agent-tool.running .tool-dot { background: #58a6ff; box-shadow: 0 0 8px rgba(88,166,255,0.7); animation: pulse 1s ease-in-out infinite; }
333
+ .agent-tool.done .tool-dot { background: #3fb950; }
334
+ .agent-tool.error .tool-dot { background: #ff7b72; }
335
+ .agent-tool .tool-name { font: 12px ui-monospace, Menlo, monospace; color: #f0f6fc; }
336
+ .agent-tool .tool-state { margin-left: auto; color: #8b949e; font-size: 11px; }
337
+ .agent-tool.running .tool-state { color: #58a6ff; }
338
+ .agent-tool.done .tool-state { color: #3fb950; }
339
+ .agent-tool.error .tool-state { color: #ff7b72; }
340
+ .agent-tool-body {
341
+ border-top: 1px solid #30363d; padding: 8px 9px; display: grid;
342
+ gap: 8px; background: rgba(22,27,34,0.55);
343
+ }
344
+ .agent-tool-body label {
345
+ display: block; color: #6e7681; font-size: 10px; text-transform: uppercase;
346
+ margin-bottom: 4px; font-family: ui-monospace, Menlo, monospace;
347
+ }
348
+ .agent-tool-body pre {
349
+ margin: 0; max-height: 190px; overflow: auto; white-space: pre-wrap;
350
+ background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
351
+ padding: 8px; color: #c9d1d9; font: 11px/1.5 ui-monospace, Menlo, monospace;
352
+ }
353
+ .agent-composer {
354
+ display: grid; grid-template-columns: 1fr auto; gap: 8px;
355
+ padding: 12px 14px; background: rgba(22,27,34,0.96);
356
+ border-top: 1px solid #30363d;
357
+ }
358
+ .agent-composer textarea {
359
+ resize: none; min-height: 44px; max-height: 170px; background: #0d1117;
360
+ border: 1px solid #30363d; color: #f0f6fc; border-radius: 8px;
361
+ padding: 10px 12px; font: 13px/1.45 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; outline: none;
362
+ }
363
+ .agent-composer textarea:focus {
364
+ border-color: #58a6ff; box-shadow: 0 0 0 2px rgba(88,166,255,0.16);
365
+ }
366
+ .agent-composer button { min-width: 76px; font-weight: 650; }
367
+ .agent-composer button.streaming { border-color: #ff7b72; color: #ff7b72; }
368
+
369
+ /* Editor workspace:
165
370
  FILE TREE | editor-stack (editor TOP + terminal BOTTOM) | PREVIEW
166
371
 
167
- Editor + terminal live INSIDE .panel-left-stack. In 1D modes
168
- (terminal-only / split / preview-only) the stack is
169
- display:contents (layout-transparent — children flex directly
170
- into .main). In editor mode the stack flips to flex column so
171
- editor (top) + terminal (bottom) stack vertically. */
372
+ Editor, Agent, and terminal live inside .panel-left-stack. The top
373
+ surface switches between the editor and chat while the terminal and
374
+ preview stay visible. */
172
375
 
173
- /* Editor panel — hidden by default; shown by mode-specific rules. */
376
+ /* Editor panel — hidden by default; shown by workspace rules. */
174
377
  .panel-editor {
175
378
  flex: 1; display: none; flex-direction: column;
176
379
  background: #1e1e1e; /* VSCode dark editor background */
@@ -178,12 +381,11 @@
178
381
  }
179
382
  .panel-editor.active { display: flex; }
180
383
 
181
- /* Wrapper for the editor-stack column. display:contents in 1D
182
- modes (transparent). flex column in editor mode. */
384
+ /* Wrapper for the center workspace column. */
183
385
  .panel-left-stack { display: contents; }
184
386
 
185
387
  /* File-tree sidebar. Lives as a FIRST child of
186
- .main; visible only in 'editor' mode. ~250px default width,
388
+ .main; visible in the editor workspace. ~250px default width,
187
389
  resizable via .tree-resize-handle. */
188
390
  .panel-tree {
189
391
  display: none; flex-direction: column;
@@ -283,6 +485,11 @@
283
485
  .main.editor .panel-terminal {
284
486
  display: flex; flex: 0 0 40%; min-height: 80px;
285
487
  }
488
+ .main.editor .panel-agent { display: none; }
489
+ .main.editor .panel-left-stack.agent-surface .panel-editor { display: none; }
490
+ .main.editor .panel-left-stack.agent-surface .panel-agent {
491
+ display: flex; flex: 1 1 60%; min-height: 100px;
492
+ }
286
493
  .main.editor .vresize-handle { display: block; }
287
494
  .main.editor .resize-handle { display: block; }
288
495
  .main.editor .panel-preview { display: flex; flex: 1; min-width: 0; }
@@ -333,13 +540,9 @@
333
540
  </div>
334
541
  </div>
335
542
  <div class="toolbar" id="toolbar">
336
- <button id="btnTerminal" class="active" title="Terminal">Terminal</button>
337
- <button id="btnPreview" title="Preview">Preview</button>
338
- <button id="btnSplit" title="Split view">Split</button>
339
- <!-- Single canonical editor mode:
340
- file tree | editor (top) + terminal (bottom) | preview.
341
- Lazy-loads Monaco + the tree on first click. -->
342
- <button id="btnEditor" title="Editor (file tree + Monaco + terminal + preview)">Editor</button>
543
+ <!-- Single workspace: file tree | editor/agent + terminal | preview. -->
544
+ <button id="btnEditor" class="active" title="Editor">Editor</button>
545
+ <button id="btnAgent" title="Agent chat">Agent</button>
343
546
  <div class="sep"></div>
344
547
  <button id="btnProcs" title="Toggle process logs panel">
345
548
  <span class="procs-dot"></span>
@@ -350,9 +553,8 @@
350
553
  <span class="vite-status" id="viteStatus"></span>
351
554
  </div>
352
555
  <div class="main" id="mainPanel">
353
- <!-- File-tree sidebar. Visible ONLY
354
- in editor mode (CSS toggles display:flex on .main.editor).
355
- The .tree-resize-handle sits between tree and editor stack;
556
+ <!-- File-tree sidebar. Visible in the workspace.
557
+ The .tree-resize-handle sits between tree and the center stack;
356
558
  dragging it adjusts .panel-tree's width inline. -->
357
559
  <div class="panel-tree" id="treePanel">
358
560
  <div class="tree-toolbar">
@@ -367,11 +569,7 @@
367
569
  <div class="tree-body" id="treeBody"></div>
368
570
  </div>
369
571
  <div class="tree-resize-handle" id="treeResizeHandle"></div>
370
- <!-- The .panel-left-stack wrapper is display:contents in 1D modes
371
- (terminal-only, split, preview-only) — its children act as
372
- direct flex children of .main. In editor mode, CSS flips it to
373
- display:flex so editor and terminal stack vertically in the middle
374
- column. -->
572
+ <!-- The .panel-left-stack wrapper is the middle workspace column. -->
375
573
  <div class="panel-left-stack" id="leftStack">
376
574
  <div class="panel-editor" id="editorPanel">
377
575
  <div class="editor-bar">
@@ -394,9 +592,29 @@
394
592
  <span id="editorStatusEnc">UTF-8</span>
395
593
  </div>
396
594
  </div>
397
- <!-- Vertical resize handle between
398
- editor (top) and terminal (bottom) inside the editor-stack
399
- column. Visible only in editor mode (.main.editor). -->
595
+ <div class="panel-agent" id="agentPanel">
596
+ <div class="agent-shell">
597
+ <div class="agent-top">
598
+ <div class="agent-heading">
599
+ <div class="agent-title">Nimbus Agent</div>
600
+ <div class="agent-subtitle" id="agentSubtitle">Workspace operator</div>
601
+ </div>
602
+ <select id="agentAccount" title="Cloudflare account" style="display:none"></select>
603
+ <button id="agentConnect" style="display:none">Connect Cloudflare</button>
604
+ <button id="agentDisconnect" class="agent-secondary" style="display:none">Disconnect</button>
605
+ <button id="agentClear" class="agent-secondary" title="Clear chat">Clear</button>
606
+ <div class="agent-status" id="agentStatus">Checking...</div>
607
+ </div>
608
+ <div class="agent-messages" id="agentMessages">
609
+ <div class="agent-empty" id="agentEmpty">Cloudflare connection required.</div>
610
+ </div>
611
+ <form class="agent-composer" id="agentForm">
612
+ <textarea id="agentInput" rows="2" placeholder="Message Nimbus"></textarea>
613
+ <button id="agentSend" type="submit">Send</button>
614
+ </form>
615
+ </div>
616
+ </div>
617
+ <!-- Vertical resize handle between the top surface and terminal. -->
400
618
  <div class="vresize-handle" id="editorTerminalResizeHandle"></div>
401
619
  <div class="panel-terminal">
402
620
  <div id="terminal-container"></div>
@@ -409,7 +627,7 @@
409
627
  <div class="resize-handle" id="resizeHandle"></div>
410
628
  <div class="panel-preview" id="previewPanel">
411
629
  <div class="preview-bar">
412
- <span style="color:#3fb950;font-size:12px">&#9679;</span>
630
+ <div class="preview-tabs" id="previewTabs" aria-label="Preview tabs"></div>
413
631
  <input class="url-bar" id="urlBar" value="" readonly>
414
632
  <button id="btnReloadPreview" title="Reload">&#8635;</button>
415
633
  </div>
@@ -454,6 +672,11 @@
454
672
  const STATS_URL = SESSION_PREFIX + '/api/stats';
455
673
  const VITE_URL = SESSION_PREFIX + '/preview/';
456
674
  const WORKER_URL = SESSION_PREFIX + '/worker/';
675
+ const AGENT_STATUS_URL = SESSION_PREFIX + '/api/agent/status';
676
+ const AGENT_MESSAGES_URL = SESSION_PREFIX + '/api/agent/messages';
677
+ const AGENT_OAUTH_START_URL = SESSION_PREFIX + '/api/agent/oauth/start';
678
+ const AGENT_OAUTH_LOGOUT_URL = SESSION_PREFIX + '/api/agent/oauth/logout';
679
+ const AGENT_ACCOUNT_URL = SESSION_PREFIX + '/api/agent/account';
457
680
  // The iframe starts pointing at /preview/ which handles its own
458
681
  // "no dev server running" placeholder. When the user spawns a
459
682
  // wrangler dev (via `wrangler dev` in the terminal), stats polling
@@ -520,68 +743,67 @@
520
743
  const stText = document.getElementById('statusText');
521
744
  const previewFrame = document.getElementById('preview-frame');
522
745
  const previewPanel = document.getElementById('previewPanel');
746
+ const previewTabsEl = document.getElementById('previewTabs');
523
747
  const markdownPreview = document.getElementById('markdown-preview');
524
748
  const markdownPreviewBody = document.getElementById('markdown-preview-body');
525
749
  const mainPanel = document.getElementById('mainPanel');
526
750
  const viteStatus = document.getElementById('viteStatus');
527
751
  let ws = null, rd = 1000, statsTimer = null;
528
- // Current layout modes. Editor mode keeps the file tree, Monaco,
529
- // terminal, and preview visible together.
530
- let layout = 'split';
752
+ // The app has one workspace layout. Only the top center surface switches.
753
+ let layout = 'editor';
754
+ let workSurface = 'editor';
531
755
 
532
756
  // ── Layout controls ──
533
757
  function setLayout(mode) {
534
- layout = mode;
535
- mainPanel.className = 'main' + (mode !== 'split' ? ' ' + mode : '');
536
- document.getElementById('btnTerminal').classList.toggle('active', mode === 'terminal-only');
537
- document.getElementById('btnPreview').classList.toggle('active', mode === 'preview-only');
538
- document.getElementById('btnSplit').classList.toggle('active', mode === 'split');
539
- document.getElementById('btnEditor').classList.toggle('active', mode === 'editor');
540
- // Editor panel visibility — drives display:flex via .active.
758
+ workSurface = mode === 'agent' ? 'agent' : 'editor';
759
+ layout = 'editor';
760
+ mainPanel.className = 'main editor';
761
+ const leftStack = document.getElementById('leftStack');
762
+ leftStack.classList.toggle('agent-surface', workSurface === 'agent');
763
+ document.getElementById('btnEditor').classList.toggle('active', workSurface === 'editor');
764
+ document.getElementById('btnAgent').classList.toggle('active', workSurface === 'agent');
765
+ // Editor panel visibility — drives display:flex via .active unless
766
+ // the agent surface is currently selected.
541
767
  const editorPanel = document.getElementById('editorPanel');
542
- const wantEditor = (mode === 'editor');
768
+ const wantEditor = (workSurface === 'editor');
543
769
  editorPanel.classList.toggle('active', wantEditor);
544
- // Lazy-init Monaco + the file tree the FIRST time the user
545
- // switches to editor mode. Cold session pays neither cost.
770
+ FileTree.ensureLoaded().catch(() => {});
771
+ // Lazy-init Monaco the first time the editor surface is selected.
546
772
  if (wantEditor) {
547
- const editorReady = Editor.ensureLoaded();
548
- const treeReady = FileTree.ensureLoaded();
549
- Promise.allSettled([editorReady, treeReady])
773
+ Editor.ensureLoaded()
550
774
  .then(() => Editor.openDefaultWelcome())
551
775
  .catch(() => {});
552
776
  }
777
+ if (workSurface === 'agent') {
778
+ Agent.ensureLoaded().catch(() => {});
779
+ }
553
780
  setTimeout(() => {
554
- // Re-apply persisted pane dims for this layout.
555
- // PaneResizer uses inline flex which is mode-specific (split vs
556
- // editor have different pane structures); onLayoutChange picks
557
- // the right elements for the active mode.
781
+ // Re-apply persisted pane dims for this workspace.
558
782
  try { if (typeof PaneResizer !== 'undefined') PaneResizer.onLayoutChange(); } catch {}
559
783
  try { fitAddon.fit(); } catch {}
560
784
  // Re-layout Monaco after the panel resizes. automaticLayout:true
561
- // already handles this but explicit layout() on mode switch
785
+ // already handles this but explicit layout() on surface switch
562
786
  // avoids a one-frame flash where the editor sees its old size.
563
787
  if (window.__nimbusMonacoEditor) {
564
788
  try { window.__nimbusMonacoEditor.layout(); } catch {}
565
789
  }
566
790
  }, 50);
567
791
  }
568
- document.getElementById('btnTerminal').onclick = () => setLayout('terminal-only');
569
- document.getElementById('btnPreview').onclick = () => setLayout('preview-only');
570
- document.getElementById('btnSplit').onclick = () => setLayout('split');
571
792
  document.getElementById('btnEditor').onclick = () => setLayout('editor');
793
+ document.getElementById('btnAgent').onclick = () => setLayout('agent');
572
794
  document.getElementById('btnRefresh').onclick = () => reloadPreview();
573
795
  document.getElementById('btnReloadPreview').onclick = () => reloadPreview();
574
796
 
575
797
  // ── Editor module ──
576
798
  //
577
799
  // Lazy-loads Monaco via cdnjs UMD on first call to ensureLoaded().
578
- // Cold session (terminal-only flow) never pays the ~5MB download.
579
800
  //
580
801
  // Owns the open file, dirty state, Ctrl+P palette, Ctrl+S save.
581
802
  // All fs operations route through the live WS via fs-read /
582
803
  // fs-write / fs-list messages → init.ts handler → SqliteVFS.
583
804
  //
584
- // Perf-invariant: Monaco is not loaded on cold terminal-only sessions.
805
+ // Perf-invariant: Monaco is loaded by runtime appending, not by an eager
806
+ // <script src> in the served HTML.
585
807
  const Editor = (function() {
586
808
  // CDN choice: cdnjs hosts Monaco's UMD AMD-loader (`loader.min.js`)
587
809
  // which fetches the remaining vs/* chunks on demand. Total cold
@@ -1683,14 +1905,667 @@
1683
1905
  };
1684
1906
  })();
1685
1907
 
1686
- // Which preview backend the iframe currently points at. Driven by
1687
- // /api/stats polling: wrangler.running > vite.running > placeholder.
1688
- // Tracked separately from iframe.src so user reloads / hash changes
1689
- // don't confuse the switcher. Starts as null so the first
1690
- // applyPreviewTarget call always paints (even if nothing is running
1691
- // yet — that still wants the VITE_URL placeholder loaded).
1692
- let activePreview = null; // null | 'vite' | 'worker' | 'none'
1693
- let previewSurface = 'app'; // 'app' | 'markdown'
1908
+ // ── Agent chat module ─────────────────────────────────────────────
1909
+ const Agent = (function() {
1910
+ const statusEl = document.getElementById('agentStatus');
1911
+ const subtitleEl = document.getElementById('agentSubtitle');
1912
+ const messagesEl = document.getElementById('agentMessages');
1913
+ const emptyEl = document.getElementById('agentEmpty');
1914
+ const form = document.getElementById('agentForm');
1915
+ const input = document.getElementById('agentInput');
1916
+ const sendBtn = document.getElementById('agentSend');
1917
+ const connectBtn = document.getElementById('agentConnect');
1918
+ const disconnectBtn = document.getElementById('agentDisconnect');
1919
+ const clearBtn = document.getElementById('agentClear');
1920
+ const accountSelect = document.getElementById('agentAccount');
1921
+
1922
+ let loaded = false;
1923
+ let status = null;
1924
+ let messages = [];
1925
+ let busy = false;
1926
+ let currentAbort = null;
1927
+ let oauthPoll = null;
1928
+ let renderSeq = 0;
1929
+
1930
+ function setAgentStatus(text, tone) {
1931
+ statusEl.textContent = text;
1932
+ statusEl.className = 'agent-status' + (tone ? ' ' + tone : '');
1933
+ }
1934
+
1935
+ function canStartChat() {
1936
+ return !!status && status.configured && status.connected;
1937
+ }
1938
+
1939
+ function applyControls() {
1940
+ const oauth = status?.oauth || {};
1941
+ const owner = status?.ownerToken || {};
1942
+ const connected = !!status?.connected;
1943
+ const canStart = canStartChat();
1944
+ connectBtn.style.display = oauth.configured && !oauth.connected ? 'inline-block' : 'none';
1945
+ disconnectBtn.style.display = oauth.connected ? 'inline-block' : 'none';
1946
+ accountSelect.style.display = oauth.connected && Array.isArray(oauth.accounts) && oauth.accounts.length > 1
1947
+ ? 'inline-block'
1948
+ : 'none';
1949
+ input.disabled = !canStart || busy;
1950
+ sendBtn.disabled = !canStart || (!busy && !input.value.trim());
1951
+ sendBtn.textContent = busy ? 'Stop' : 'Send';
1952
+ sendBtn.classList.toggle('streaming', busy);
1953
+ if (!status) {
1954
+ emptyEl.textContent = 'Cloudflare connection required.';
1955
+ subtitleEl.textContent = 'Workspace operator';
1956
+ setAgentStatus('Checking...', '');
1957
+ } else if (!status.configured) {
1958
+ emptyEl.textContent = 'Configure Cloudflare OAuth or an owner API token to enable chat.';
1959
+ subtitleEl.textContent = 'Cloudflare Workers AI';
1960
+ setAgentStatus('AI not configured', 'warn');
1961
+ } else if (connected) {
1962
+ const mode = oauth.connected ? 'Cloudflare connected' : owner.configured ? 'Owner token' : 'Ready';
1963
+ emptyEl.textContent = 'Ask Nimbus to inspect or change this session.';
1964
+ subtitleEl.textContent = 'Shell · files · runtimes · previews';
1965
+ setAgentStatus((busy ? 'Streaming' : mode) + ' · ' + status.model, busy ? 'streaming' : 'ready');
1966
+ } else {
1967
+ emptyEl.textContent = 'Connect Cloudflare to use Workers AI.';
1968
+ subtitleEl.textContent = 'Use your Cloudflare quota';
1969
+ setAgentStatus('Connect Cloudflare', 'warn');
1970
+ }
1971
+ }
1972
+
1973
+ function renderAccounts() {
1974
+ const oauth = status?.oauth || {};
1975
+ const accounts = Array.isArray(oauth.accounts) ? oauth.accounts : [];
1976
+ accountSelect.replaceChildren();
1977
+ for (const account of accounts) {
1978
+ const opt = document.createElement('option');
1979
+ opt.value = account.id;
1980
+ opt.textContent = account.name || account.id;
1981
+ opt.selected = account.id === oauth.accountId;
1982
+ accountSelect.appendChild(opt);
1983
+ }
1984
+ }
1985
+
1986
+ function roleLabel(role, name) {
1987
+ if (role === 'tool') return name || 'tool';
1988
+ return role === 'assistant' ? 'Nimbus' : 'You';
1989
+ }
1990
+
1991
+ function formatTime(ts) {
1992
+ const date = new Date(ts || Date.now());
1993
+ if (Number.isNaN(date.getTime())) return '';
1994
+ return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
1995
+ }
1996
+
1997
+ function renderMessages(list = messages) {
1998
+ renderSeq++;
1999
+ const seq = renderSeq;
2000
+ const displayList = normalizeTranscriptForRender(list);
2001
+ messagesEl.replaceChildren();
2002
+ if (!displayList || displayList.length === 0) {
2003
+ messagesEl.appendChild(emptyEl);
2004
+ return;
2005
+ }
2006
+ for (const msg of displayList) {
2007
+ messagesEl.appendChild(renderMessage(msg, seq));
2008
+ }
2009
+ messagesEl.scrollTop = messagesEl.scrollHeight;
2010
+ }
2011
+
2012
+ function normalizeTranscriptForRender(list) {
2013
+ if (!Array.isArray(list)) return [];
2014
+ const out = [];
2015
+ let pendingTools = [];
2016
+ const flushPendingTools = () => {
2017
+ for (const tool of pendingTools) {
2018
+ out.push({ id: tool.toolCallId, role: 'assistant', content: '', createdAt: Date.now(), parts: [tool] });
2019
+ }
2020
+ pendingTools = [];
2021
+ };
2022
+ for (const msg of list) {
2023
+ if (msg?.role === 'tool') {
2024
+ pendingTools.push(legacyToolPart(msg));
2025
+ continue;
2026
+ }
2027
+ if (msg?.role === 'assistant' && pendingTools.length > 0) {
2028
+ const next = { ...msg, parts: [...pendingTools, ...normalizeTurnParts(msg)] };
2029
+ pendingTools = [];
2030
+ out.push(next);
2031
+ continue;
2032
+ }
2033
+ flushPendingTools();
2034
+ out.push(msg);
2035
+ }
2036
+ flushPendingTools();
2037
+ return out;
2038
+ }
2039
+
2040
+ function renderMessage(msg, seq) {
2041
+ if (msg.role === 'tool') return renderStoredToolMessage(msg);
2042
+
2043
+ const row = document.createElement('div');
2044
+ row.className = 'agent-msg ' + msg.role;
2045
+ const avatar = document.createElement('div');
2046
+ avatar.className = 'agent-avatar';
2047
+ avatar.textContent = msg.role === 'assistant' ? 'N' : '';
2048
+ const content = document.createElement('div');
2049
+ content.className = 'agent-content';
2050
+
2051
+ const meta = document.createElement('div');
2052
+ meta.className = 'agent-meta';
2053
+ meta.textContent = roleLabel(msg.role, msg.name) + (msg.createdAt ? ' · ' + formatTime(msg.createdAt) : '');
2054
+
2055
+ const isStreaming = !!msg.streaming;
2056
+
2057
+ if (msg.role === 'assistant') content.appendChild(meta);
2058
+
2059
+ if (msg.role === 'assistant') {
2060
+ const parts = normalizeTurnParts(msg);
2061
+ if (msg.error) {
2062
+ const bubble = document.createElement('div');
2063
+ bubble.className = 'agent-bubble';
2064
+ bubble.textContent = msg.error;
2065
+ content.appendChild(bubble);
2066
+ } else if (parts.length > 0) {
2067
+ renderTurnParts(content, parts, { streaming: isStreaming, seq });
2068
+ } else if (isStreaming) {
2069
+ content.appendChild(renderThinking('Thinking'));
2070
+ }
2071
+ } else if (msg.content || msg.error) {
2072
+ const bubble = document.createElement('div');
2073
+ bubble.className = 'agent-bubble';
2074
+ if (msg.error) {
2075
+ bubble.textContent = msg.error;
2076
+ } else {
2077
+ bubble.textContent = String(msg.content || '');
2078
+ }
2079
+ content.appendChild(bubble);
2080
+ }
2081
+
2082
+ if (msg.role === 'user') {
2083
+ content.appendChild(meta);
2084
+ row.append(content);
2085
+ } else {
2086
+ row.append(avatar, content);
2087
+ }
2088
+ return row;
2089
+ }
2090
+
2091
+ function normalizeTurnParts(msg) {
2092
+ if (Array.isArray(msg.parts)) return msg.parts.filter((part) => part && typeof part === 'object');
2093
+ const parts = [];
2094
+ if (msg.reasoning) parts.push({ type: 'reasoning', text: String(msg.reasoning) });
2095
+ if (Array.isArray(msg.tools)) {
2096
+ for (const tool of msg.tools) parts.push({ type: 'tool', ...tool });
2097
+ }
2098
+ if (msg.content) parts.push({ type: 'text', text: String(msg.content) });
2099
+ return parts;
2100
+ }
2101
+
2102
+ function renderTurnParts(content, parts, opts = {}) {
2103
+ const lastTextIndex = findLastTextPartIndex(parts);
2104
+ parts.forEach((part, index) => {
2105
+ if (part.type === 'reasoning') {
2106
+ if (part.text) content.appendChild(renderReasoning(part.text));
2107
+ } else if (part.type === 'tool') {
2108
+ content.appendChild(renderToolCard(part));
2109
+ } else if (part.type === 'text' && part.text) {
2110
+ const bubble = document.createElement('div');
2111
+ bubble.className = 'agent-bubble';
2112
+ renderMarkdownInto(bubble, part.text, {
2113
+ cursor: !!opts.streaming && index === lastTextIndex,
2114
+ seq: opts.seq,
2115
+ });
2116
+ content.appendChild(bubble);
2117
+ }
2118
+ });
2119
+ if (opts.streaming && (parts.length === 0 || shouldShowPostPartThinking(parts))) {
2120
+ content.appendChild(renderThinking('Thinking'));
2121
+ }
2122
+ }
2123
+
2124
+ function findLastTextPartIndex(parts) {
2125
+ for (let i = parts.length - 1; i >= 0; i--) {
2126
+ if (parts[i]?.type === 'text' && parts[i].text) return i;
2127
+ }
2128
+ return -1;
2129
+ }
2130
+
2131
+ function shouldShowPostPartThinking(parts) {
2132
+ const last = parts[parts.length - 1];
2133
+ return !!last && last.type === 'tool' && last.status !== 'running';
2134
+ }
2135
+
2136
+ function renderStoredToolMessage(msg) {
2137
+ const row = document.createElement('div');
2138
+ row.className = 'agent-msg assistant';
2139
+ const avatar = document.createElement('div');
2140
+ avatar.className = 'agent-avatar';
2141
+ avatar.textContent = 'T';
2142
+ const content = document.createElement('div');
2143
+ content.className = 'agent-content';
2144
+ content.appendChild(renderToolCard(legacyToolPart(msg)));
2145
+ row.append(avatar, content);
2146
+ return row;
2147
+ }
2148
+
2149
+ function legacyToolPart(msg) {
2150
+ const payload = parseToolPayload(msg);
2151
+ const output = payload.output ?? msg.content;
2152
+ return {
2153
+ type: 'tool',
2154
+ toolCallId: msg.id,
2155
+ toolName: payload.tool || msg.name || 'tool',
2156
+ input: payload.input,
2157
+ output,
2158
+ status: isToolOutputFailure(output) ? 'error' : 'done',
2159
+ durationMs: payload.durationMs,
2160
+ };
2161
+ }
2162
+
2163
+ function parseToolPayload(msg) {
2164
+ try {
2165
+ const parsed = JSON.parse(msg.content || '{}');
2166
+ if (parsed && typeof parsed === 'object') return parsed;
2167
+ } catch {}
2168
+ return { tool: msg.name || 'tool', output: msg.content || '' };
2169
+ }
2170
+
2171
+ function renderThinking(label) {
2172
+ const wrap = document.createElement('div');
2173
+ wrap.className = 'agent-thinking';
2174
+ const dots = document.createElement('span');
2175
+ dots.className = 'dots';
2176
+ dots.append(document.createElement('span'), document.createElement('span'), document.createElement('span'));
2177
+ const text = document.createElement('span');
2178
+ text.textContent = label;
2179
+ wrap.append(dots, text);
2180
+ return wrap;
2181
+ }
2182
+
2183
+ function renderReasoning(text) {
2184
+ const details = document.createElement('details');
2185
+ details.className = 'agent-reasoning';
2186
+ const summary = document.createElement('summary');
2187
+ summary.textContent = 'Thinking';
2188
+ const pre = document.createElement('pre');
2189
+ pre.textContent = text;
2190
+ details.append(summary, pre);
2191
+ return details;
2192
+ }
2193
+
2194
+ function renderToolCard(tool) {
2195
+ const status = tool.status || 'done';
2196
+ const details = document.createElement('details');
2197
+ details.className = 'agent-tool ' + status;
2198
+ if (status === 'running' || status === 'error') details.open = true;
2199
+
2200
+ const summary = document.createElement('summary');
2201
+ const dot = document.createElement('span');
2202
+ dot.className = 'tool-dot';
2203
+ const name = document.createElement('span');
2204
+ name.className = 'tool-name';
2205
+ name.textContent = tool.toolName || 'tool';
2206
+ const state = document.createElement('span');
2207
+ state.className = 'tool-state';
2208
+ state.textContent = toolStateLabel(tool);
2209
+ summary.append(dot, name, state);
2210
+
2211
+ const body = document.createElement('div');
2212
+ body.className = 'agent-tool-body';
2213
+ if (tool.input !== undefined) body.appendChild(renderToolSection('Input', tool.input));
2214
+ if (tool.output !== undefined) body.appendChild(renderToolSection(status === 'error' ? 'Error' : 'Output', tool.output));
2215
+ if (tool.error) body.appendChild(renderToolSection('Error', tool.error));
2216
+ details.append(summary, body);
2217
+ return details;
2218
+ }
2219
+
2220
+ function toolStateLabel(tool) {
2221
+ if (tool.status === 'running') return 'running';
2222
+ if (tool.status === 'error') return tool.durationMs ? 'failed · ' + formatDuration(tool.durationMs) : 'failed';
2223
+ return tool.durationMs ? 'done · ' + formatDuration(tool.durationMs) : 'done';
2224
+ }
2225
+
2226
+ function formatDuration(ms) {
2227
+ if (!Number.isFinite(ms) || ms < 100) return '<0.1s';
2228
+ return (ms / 1000).toFixed(1) + 's';
2229
+ }
2230
+
2231
+ function renderToolSection(label, value) {
2232
+ const section = document.createElement('div');
2233
+ const title = document.createElement('label');
2234
+ title.textContent = label;
2235
+ const pre = document.createElement('pre');
2236
+ pre.textContent = prettyValue(value);
2237
+ section.append(title, pre);
2238
+ return section;
2239
+ }
2240
+
2241
+ function prettyValue(value) {
2242
+ if (typeof value === 'string') return value;
2243
+ try { return JSON.stringify(value, null, 2); } catch { return String(value); }
2244
+ }
2245
+
2246
+ function isToolOutputFailure(output) {
2247
+ if (!output || typeof output !== 'object') return false;
2248
+ if (typeof output.error === 'string' && output.error.trim()) return true;
2249
+ if (output.success === false) return true;
2250
+ if (typeof output.exitCode === 'number' && output.exitCode !== 0) return true;
2251
+ if (output.exit && typeof output.exit === 'object' && typeof output.exit.code === 'number') {
2252
+ return output.exit.code !== 0;
2253
+ }
2254
+ return false;
2255
+ }
2256
+
2257
+ function renderMarkdownInto(el, text, opts = {}) {
2258
+ const token = String((opts.seq || renderSeq) + ':' + text.length + ':' + (opts.cursor ? '1' : '0'));
2259
+ el.dataset.renderToken = token;
2260
+ el.textContent = text;
2261
+ renderMarkdown(text).then((html) => {
2262
+ if (el.dataset.renderToken !== token) return;
2263
+ el.innerHTML = html;
2264
+ if (opts.cursor) {
2265
+ const cursor = document.createElement('span');
2266
+ cursor.className = 'agent-cursor';
2267
+ el.appendChild(cursor);
2268
+ }
2269
+ }).catch(() => {
2270
+ if (opts.cursor) {
2271
+ const cursor = document.createElement('span');
2272
+ cursor.className = 'agent-cursor';
2273
+ el.appendChild(cursor);
2274
+ }
2275
+ });
2276
+ }
2277
+
2278
+ async function refreshStatus() {
2279
+ const r = await fetch(AGENT_STATUS_URL, { headers: { Accept: 'application/json' } });
2280
+ const payload = await r.json();
2281
+ if (!r.ok) throw new Error(payload.error || 'Agent status failed');
2282
+ status = payload;
2283
+ renderAccounts();
2284
+ applyControls();
2285
+ return status;
2286
+ }
2287
+
2288
+ async function loadMessages() {
2289
+ const r = await fetch(AGENT_MESSAGES_URL, { headers: { Accept: 'application/json' } });
2290
+ const payload = await r.json();
2291
+ if (!r.ok) throw new Error(payload.error || 'Agent messages failed');
2292
+ messages = Array.isArray(payload.messages) ? payload.messages : [];
2293
+ renderMessages();
2294
+ }
2295
+
2296
+ async function connectCloudflare() {
2297
+ connectBtn.disabled = true;
2298
+ try {
2299
+ const r = await fetch(AGENT_OAUTH_START_URL, { method: 'POST', headers: { Accept: 'application/json' } });
2300
+ const payload = await r.json();
2301
+ if (!r.ok || !payload.authUrl) {
2302
+ throw new Error(payload.error || 'OAuth start failed');
2303
+ }
2304
+ const popup = window.open(payload.authUrl, 'nimbus-agent-oauth', 'width=720,height=760');
2305
+ if (!popup) location.href = payload.authUrl;
2306
+ setAgentStatus('Waiting for Cloudflare...', 'warn');
2307
+ if (oauthPoll) clearInterval(oauthPoll);
2308
+ oauthPoll = setInterval(() => {
2309
+ if (!popup || popup.closed) {
2310
+ clearInterval(oauthPoll);
2311
+ oauthPoll = null;
2312
+ refreshStatus().catch((e) => setAgentStatus(e?.message || String(e), 'warn'));
2313
+ }
2314
+ }, 1200);
2315
+ } catch (e) {
2316
+ setAgentStatus(e?.message || String(e), 'warn');
2317
+ } finally {
2318
+ connectBtn.disabled = false;
2319
+ }
2320
+ }
2321
+
2322
+ async function selectAccount() {
2323
+ const accountId = accountSelect.value;
2324
+ if (!accountId) return;
2325
+ await fetch(AGENT_ACCOUNT_URL, {
2326
+ method: 'POST',
2327
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
2328
+ body: JSON.stringify({ accountId }),
2329
+ }).then(async (r) => {
2330
+ const payload = await r.json().catch(() => ({}));
2331
+ if (!r.ok) throw new Error(payload.error || 'Account switch failed');
2332
+ });
2333
+ await refreshStatus();
2334
+ }
2335
+
2336
+ async function disconnectCloudflare() {
2337
+ disconnectBtn.disabled = true;
2338
+ try {
2339
+ const r = await fetch(AGENT_OAUTH_LOGOUT_URL, { method: 'POST', headers: { Accept: 'application/json' } });
2340
+ const payload = await r.json().catch(() => ({}));
2341
+ if (!r.ok) throw new Error(payload.error || 'Disconnect failed');
2342
+ status = null;
2343
+ await refreshStatus();
2344
+ } catch (e) {
2345
+ setAgentStatus(e?.message || String(e), 'warn');
2346
+ } finally {
2347
+ disconnectBtn.disabled = false;
2348
+ }
2349
+ }
2350
+
2351
+ async function clearMessages() {
2352
+ const r = await fetch(AGENT_MESSAGES_URL, { method: 'DELETE', headers: { Accept: 'application/json' } });
2353
+ const payload = await r.json().catch(() => ({}));
2354
+ if (!r.ok) {
2355
+ setAgentStatus(payload.error || 'Clear failed', 'warn');
2356
+ return;
2357
+ }
2358
+ messages = [];
2359
+ renderMessages();
2360
+ }
2361
+
2362
+ async function sendMessage(text) {
2363
+ const content = String(text || '').trim();
2364
+ if (busy) {
2365
+ stopStreaming();
2366
+ return;
2367
+ }
2368
+ if (!content) return;
2369
+ busy = true;
2370
+ currentAbort = new AbortController();
2371
+ applyControls();
2372
+ input.value = '';
2373
+ const optimistic = {
2374
+ id: 'pending-user',
2375
+ role: 'user',
2376
+ content,
2377
+ createdAt: Date.now(),
2378
+ };
2379
+ const pending = {
2380
+ id: 'pending-assistant',
2381
+ role: 'assistant',
2382
+ content: '',
2383
+ createdAt: Date.now(),
2384
+ streaming: true,
2385
+ parts: [],
2386
+ };
2387
+ let liveList = [...messages, optimistic, pending];
2388
+ renderMessages(liveList);
2389
+ const repaint = () => {
2390
+ liveList = [...messages, pending];
2391
+ renderMessages(liveList);
2392
+ };
2393
+ try {
2394
+ const r = await fetch(AGENT_MESSAGES_URL, {
2395
+ method: 'POST',
2396
+ headers: { 'Content-Type': 'application/json', Accept: 'application/x-ndjson' },
2397
+ body: JSON.stringify({ message: content, stream: true }),
2398
+ signal: currentAbort.signal,
2399
+ });
2400
+ if (!r.ok || !r.body) {
2401
+ const payload = await r.json().catch(() => ({}));
2402
+ throw new Error(payload.error || 'Agent request failed');
2403
+ }
2404
+ await readAgentStream(r.body, pending, repaint);
2405
+ } catch (e) {
2406
+ if (e?.name !== 'AbortError') {
2407
+ pending.streaming = false;
2408
+ pending.error = e?.message || String(e);
2409
+ repaint();
2410
+ }
2411
+ } finally {
2412
+ busy = false;
2413
+ currentAbort = null;
2414
+ applyControls();
2415
+ input.focus();
2416
+ }
2417
+ }
2418
+
2419
+ function stopStreaming() {
2420
+ if (currentAbort) currentAbort.abort();
2421
+ }
2422
+
2423
+ async function readAgentStream(body, liveAssistant, repaint) {
2424
+ const reader = body.getReader();
2425
+ const decoder = new TextDecoder();
2426
+ let buffer = '';
2427
+ while (true) {
2428
+ const { value, done } = await reader.read();
2429
+ if (done) break;
2430
+ buffer += decoder.decode(value, { stream: true });
2431
+ let lineEnd = buffer.indexOf('\n');
2432
+ while (lineEnd >= 0) {
2433
+ const line = buffer.slice(0, lineEnd).trim();
2434
+ buffer = buffer.slice(lineEnd + 1);
2435
+ if (line) applyStreamEvent(JSON.parse(line), liveAssistant, repaint);
2436
+ lineEnd = buffer.indexOf('\n');
2437
+ }
2438
+ }
2439
+ const tail = buffer.trim();
2440
+ if (tail) applyStreamEvent(JSON.parse(tail), liveAssistant, repaint);
2441
+ }
2442
+
2443
+ function applyStreamEvent(event, liveAssistant, repaint) {
2444
+ if (!event || typeof event !== 'object') return;
2445
+ if (event.type === 'start') {
2446
+ if (Array.isArray(event.messages)) messages = event.messages;
2447
+ } else if (event.type === 'assistant-start') {
2448
+ liveAssistant.id = event.messageId || liveAssistant.id;
2449
+ liveAssistant.createdAt = event.createdAt || liveAssistant.createdAt;
2450
+ } else if (event.type === 'text-delta') {
2451
+ const delta = String(event.delta || '');
2452
+ liveAssistant.content += delta;
2453
+ appendLiveTextPart(liveAssistant, 'text', delta);
2454
+ } else if (event.type === 'reasoning-delta') {
2455
+ appendLiveTextPart(liveAssistant, 'reasoning', String(event.delta || ''));
2456
+ } else if (event.type === 'tool-call') {
2457
+ upsertLiveTool(liveAssistant, {
2458
+ toolCallId: event.toolCallId,
2459
+ toolName: event.toolName,
2460
+ input: event.input,
2461
+ status: 'running',
2462
+ startedAt: Date.now(),
2463
+ });
2464
+ } else if (event.type === 'tool-result' || event.type === 'tool-error') {
2465
+ const tool = upsertLiveTool(liveAssistant, {
2466
+ toolCallId: event.toolCallId,
2467
+ toolName: event.toolName,
2468
+ input: event.input,
2469
+ });
2470
+ tool.status = event.type === 'tool-error' ? 'error' : (event.status || 'done');
2471
+ tool.output = event.type === 'tool-error' ? { error: event.error } : event.output;
2472
+ tool.error = event.type === 'tool-error' ? event.error : '';
2473
+ if (tool.startedAt) tool.durationMs = Date.now() - tool.startedAt;
2474
+ } else if (event.type === 'done') {
2475
+ liveAssistant.streaming = false;
2476
+ if (Array.isArray(event.messages)) {
2477
+ messages = event.messages;
2478
+ renderMessages();
2479
+ return;
2480
+ }
2481
+ if (event.message) {
2482
+ messages = [...messages, event.message];
2483
+ }
2484
+ } else if (event.type === 'error') {
2485
+ liveAssistant.streaming = false;
2486
+ liveAssistant.error = event.error || 'Agent request failed';
2487
+ if (Array.isArray(event.messages)) messages = event.messages;
2488
+ setAgentStatus(liveAssistant.error, 'warn');
2489
+ }
2490
+ repaint();
2491
+ }
2492
+
2493
+ function appendLiveTextPart(liveAssistant, type, delta) {
2494
+ if (!delta) return;
2495
+ if (!Array.isArray(liveAssistant.parts)) liveAssistant.parts = [];
2496
+ const last = liveAssistant.parts[liveAssistant.parts.length - 1];
2497
+ if (last && last.type === type) {
2498
+ last.text = String(last.text || '') + delta;
2499
+ } else {
2500
+ liveAssistant.parts.push({ type, text: delta });
2501
+ }
2502
+ }
2503
+
2504
+ function upsertLiveTool(liveAssistant, patch) {
2505
+ if (!Array.isArray(liveAssistant.parts)) liveAssistant.parts = [];
2506
+ let tool = liveAssistant.parts.find((item) => item.type === 'tool' && item.toolCallId === patch.toolCallId);
2507
+ if (!tool) {
2508
+ tool = { type: 'tool', status: 'running' };
2509
+ liveAssistant.parts.push(tool);
2510
+ }
2511
+ Object.assign(tool, patch);
2512
+ return tool;
2513
+ }
2514
+
2515
+ function wire() {
2516
+ form.addEventListener('submit', (e) => {
2517
+ e.preventDefault();
2518
+ if (busy) stopStreaming();
2519
+ else sendMessage(input.value);
2520
+ });
2521
+ input.addEventListener('keydown', (e) => {
2522
+ if (e.key === 'Enter' && !e.shiftKey) {
2523
+ e.preventDefault();
2524
+ sendMessage(input.value);
2525
+ }
2526
+ });
2527
+ input.addEventListener('input', applyControls);
2528
+ connectBtn.addEventListener('click', connectCloudflare);
2529
+ disconnectBtn.addEventListener('click', disconnectCloudflare);
2530
+ clearBtn.addEventListener('click', clearMessages);
2531
+ accountSelect.addEventListener('change', selectAccount);
2532
+ window.addEventListener('message', (event) => {
2533
+ if (event.origin !== location.origin) return;
2534
+ if (event.data && event.data.type === 'nimbus-agent-oauth') {
2535
+ if (oauthPoll) {
2536
+ clearInterval(oauthPoll);
2537
+ oauthPoll = null;
2538
+ }
2539
+ refreshStatus().catch(() => {});
2540
+ }
2541
+ });
2542
+ }
2543
+
2544
+ async function ensureLoaded() {
2545
+ if (!loaded) {
2546
+ loaded = true;
2547
+ wire();
2548
+ }
2549
+ const results = await Promise.allSettled([refreshStatus(), loadMessages()]);
2550
+ if (results[0].status === 'rejected') {
2551
+ setAgentStatus(results[0].reason?.message || String(results[0].reason), 'warn');
2552
+ input.disabled = true;
2553
+ sendBtn.disabled = true;
2554
+ }
2555
+ if (results[1].status === 'rejected') renderMessages([]);
2556
+ }
2557
+
2558
+ const api = { ensureLoaded, refreshStatus, loadMessages };
2559
+ window.NimbusAgent = api;
2560
+ return api;
2561
+ })();
2562
+
2563
+ // Tabbed preview surface. Markdown preview and live app previews share
2564
+ // the right pane without fighting over a single iframe.
2565
+ let previewBackendKind = 'none'; // 'vite' | 'worker' | 'none'
2566
+ let activePreviewTabId = null;
2567
+ let markdownRenderSeq = 0;
2568
+ const previewTabs = new Map();
1694
2569
  let markdownRendererPromise = null;
1695
2570
  let markdownRenderer = null;
1696
2571
 
@@ -1698,32 +2573,199 @@
1698
2573
  return kind === 'worker' ? WORKER_URL : VITE_URL;
1699
2574
  }
1700
2575
 
2576
+ function portPreviewUrl(port) {
2577
+ return SESSION_PREFIX + '/port/' + Number(port) + '/';
2578
+ }
2579
+
2580
+ function absolutePreviewUrl(url) {
2581
+ return new URL(url, location.origin).href;
2582
+ }
2583
+
2584
+ function shortPathLabel(path) {
2585
+ const parts = String(path || '').split('/').filter(Boolean);
2586
+ return parts[parts.length - 1] || 'Markdown';
2587
+ }
2588
+
2589
+ function appTabFor(kind) {
2590
+ const appKind = kind === 'worker' ? 'worker' : 'vite';
2591
+ return {
2592
+ id: 'app:' + appKind,
2593
+ type: 'app',
2594
+ kind: appKind,
2595
+ label: appKind === 'worker' ? 'Worker' : 'Preview',
2596
+ title: appKind === 'worker' ? 'Cloudflare Worker preview' : 'Vite preview',
2597
+ url: previewUrlFor(appKind),
2598
+ running: previewBackendKind === appKind,
2599
+ };
2600
+ }
2601
+
2602
+ function renderPreviewTabs() {
2603
+ if (!previewTabsEl) return;
2604
+ previewTabsEl.innerHTML = '';
2605
+ for (const tab of previewTabs.values()) {
2606
+ const button = document.createElement('button');
2607
+ button.type = 'button';
2608
+ button.className = [
2609
+ 'preview-tab',
2610
+ tab.type === 'markdown' ? 'markdown' : '',
2611
+ tab.running ? 'running' : '',
2612
+ tab.id === activePreviewTabId ? 'active' : '',
2613
+ ].filter(Boolean).join(' ');
2614
+ button.title = tab.title || tab.url || tab.path || tab.label;
2615
+ button.onclick = () => activatePreviewTab(tab.id);
2616
+
2617
+ const label = document.createElement('span');
2618
+ label.className = 'preview-tab-label';
2619
+ label.textContent = tab.label;
2620
+ button.appendChild(label);
2621
+
2622
+ previewTabsEl.appendChild(button);
2623
+ }
2624
+ }
2625
+
2626
+ function ensurePreviewTab(tab, options = {}) {
2627
+ const existing = previewTabs.get(tab.id);
2628
+ const merged = {
2629
+ ...(existing || {}),
2630
+ ...tab,
2631
+ createdAt: existing?.createdAt || Date.now(),
2632
+ updatedAt: Date.now(),
2633
+ };
2634
+ previewTabs.set(tab.id, merged);
2635
+ if (!existing && options.focusNew) {
2636
+ activatePreviewTab(tab.id);
2637
+ } else {
2638
+ renderPreviewTabs();
2639
+ }
2640
+ return merged;
2641
+ }
2642
+
2643
+ function firstAppTabId() {
2644
+ const port = [...previewTabs.values()].filter((tab) => tab.type === 'port').pop();
2645
+ if (port) return port.id;
2646
+ const preferred = previewBackendKind === 'worker' ? 'app:worker' : 'app:vite';
2647
+ if (previewTabs.has(preferred)) return preferred;
2648
+ if (previewTabs.has('app:vite')) return 'app:vite';
2649
+ if (previewTabs.has('app:worker')) return 'app:worker';
2650
+ return ensurePreviewTab(appTabFor('vite')).id;
2651
+ }
2652
+
2653
+ function activatePreviewTab(id) {
2654
+ const tab = previewTabs.get(id);
2655
+ if (!tab) return;
2656
+ activePreviewTabId = id;
2657
+ renderPreviewTabs();
2658
+ if (tab.type === 'markdown') {
2659
+ previewPanel.classList.add('markdown-mode');
2660
+ if (previewFrame) previewFrame.style.display = 'none';
2661
+ if (markdownPreview) markdownPreview.classList.add('active');
2662
+ setPreviewUrlBar('Markdown Preview · ' + tab.path);
2663
+ paintMarkdownPreview(tab.markdown || '').catch(() => {});
2664
+ return;
2665
+ }
2666
+
2667
+ previewPanel.classList.remove('markdown-mode');
2668
+ if (markdownPreview) markdownPreview.classList.remove('active');
2669
+ if (previewFrame) {
2670
+ previewFrame.style.display = 'block';
2671
+ const next = absolutePreviewUrl(tab.url);
2672
+ if (previewFrame.src !== next) previewFrame.src = tab.url;
2673
+ }
2674
+ setPreviewUrlBar(tab.url);
2675
+ }
2676
+
2677
+ function normalizePortStats(portStats) {
2678
+ const raw = Array.isArray(portStats?.ports)
2679
+ ? portStats.ports
2680
+ : Array.isArray(portStats)
2681
+ ? portStats
2682
+ : [];
2683
+ const seen = new Set();
2684
+ const ports = [];
2685
+ for (const entry of raw) {
2686
+ const port = Number(entry?.port);
2687
+ if (!Number.isInteger(port) || port <= 0 || seen.has(port)) continue;
2688
+ seen.add(port);
2689
+ ports.push({ port, pid: Number(entry?.pid) || 0 });
2690
+ }
2691
+ return ports;
2692
+ }
2693
+
2694
+ function syncPortPreviewTabs(portStats, canonicalPort, canonicalTabId) {
2695
+ const ports = normalizePortStats(portStats);
2696
+ const hiddenPort = Number.isInteger(canonicalPort) && canonicalPort > 0 ? canonicalPort : null;
2697
+ const visiblePorts = hiddenPort == null
2698
+ ? ports
2699
+ : ports.filter((p) => p.port !== hiddenPort);
2700
+ const live = new Set(visiblePorts.map((p) => 'port:' + p.port));
2701
+ let activeWasRemoved = false;
2702
+ let activeWasCanonicalAlias = false;
2703
+
2704
+ for (const [id, tab] of previewTabs) {
2705
+ if (tab.type === 'port' && !live.has(id)) {
2706
+ previewTabs.delete(id);
2707
+ if (activePreviewTabId === id) {
2708
+ activeWasRemoved = true;
2709
+ activeWasCanonicalAlias = hiddenPort != null && tab.port === hiddenPort;
2710
+ }
2711
+ }
2712
+ }
2713
+
2714
+ for (const entry of visiblePorts) {
2715
+ const id = 'port:' + entry.port;
2716
+ const existed = previewTabs.has(id);
2717
+ ensurePreviewTab({
2718
+ id,
2719
+ type: 'port',
2720
+ port: entry.port,
2721
+ pid: entry.pid,
2722
+ label: ':' + entry.port,
2723
+ title: 'Port ' + entry.port,
2724
+ url: portPreviewUrl(entry.port),
2725
+ running: true,
2726
+ }, { focusNew: !existed });
2727
+ }
2728
+
2729
+ if (activeWasRemoved || !activePreviewTabId || !previewTabs.has(activePreviewTabId)) {
2730
+ activatePreviewTab(activeWasCanonicalAlias && canonicalTabId ? canonicalTabId : firstAppTabId());
2731
+ } else {
2732
+ renderPreviewTabs();
2733
+ }
2734
+ }
2735
+
1701
2736
  function setPreviewUrlBar(value) {
1702
2737
  const urlBar = document.getElementById('urlBar');
1703
2738
  if (urlBar) urlBar.value = value;
1704
2739
  }
1705
2740
 
1706
2741
  function showAppPreview() {
1707
- previewSurface = 'app';
1708
- if (markdownPreview) markdownPreview.classList.remove('active');
1709
- if (previewFrame) previewFrame.style.display = 'block';
1710
- previewPanel.classList.remove('markdown-mode');
1711
- const kind = (activePreview === null || activePreview === 'none') ? 'vite' : activePreview;
1712
- const url = previewUrlFor(kind);
1713
- if (previewFrame && (previewFrame.src === 'about:blank' || !previewFrame.src.includes(url))) {
1714
- previewFrame.src = url;
1715
- }
1716
- setPreviewUrlBar(url);
2742
+ ensurePreviewTab(appTabFor(previewBackendKind));
2743
+ activatePreviewTab(firstAppTabId());
1717
2744
  }
1718
2745
 
1719
2746
  async function showMarkdownPreview(path, markdown) {
1720
- previewSurface = 'markdown';
1721
- previewPanel.classList.add('markdown-mode');
1722
- if (previewFrame) previewFrame.style.display = 'none';
1723
- if (markdownPreview) markdownPreview.classList.add('active');
1724
- setPreviewUrlBar('Markdown Preview · ' + path);
2747
+ const id = 'markdown:' + path;
2748
+ ensurePreviewTab({
2749
+ id,
2750
+ type: 'markdown',
2751
+ label: shortPathLabel(path),
2752
+ title: path,
2753
+ path,
2754
+ markdown: markdown || '',
2755
+ running: false,
2756
+ });
2757
+ activatePreviewTab(id);
2758
+ if (activePreviewTabId !== id) return;
2759
+ await paintMarkdownPreview(markdown || '');
2760
+ }
2761
+
2762
+ async function paintMarkdownPreview(markdown) {
2763
+ const seq = ++markdownRenderSeq;
1725
2764
  const html = await renderMarkdown(markdown || '');
1726
- if (markdownPreviewBody) markdownPreviewBody.innerHTML = html;
2765
+ const active = activePreviewTabId ? previewTabs.get(activePreviewTabId) : null;
2766
+ if (seq === markdownRenderSeq && active?.type === 'markdown' && markdownPreviewBody) {
2767
+ markdownPreviewBody.innerHTML = html;
2768
+ }
1727
2769
  }
1728
2770
 
1729
2771
  async function renderMarkdown(markdown) {
@@ -1763,48 +2805,47 @@
1763
2805
  return div.innerHTML;
1764
2806
  }
1765
2807
 
1766
- function applyPreviewTarget(kind) {
1767
- // Idempotent: only update iframe + URL bar when the backend kind
1768
- // actually changes. Avoids tearing down the inner app on every
1769
- // 5-second stats poll.
1770
- if (activePreview === kind && previewSurface === 'app') return;
1771
- activePreview = kind;
1772
- if (previewSurface !== 'app') return;
1773
- const url = previewUrlFor(kind);
1774
- // Always use the VITE_URL path when nothing is running — that
1775
- // endpoint serves the styled "no dev server" placeholder, which
1776
- // self-refreshes via its own polling loop.
1777
- previewFrame.src = url;
1778
- setPreviewUrlBar(url);
1779
- // If the user had collapsed the preview because nothing was up,
1780
- // surface it automatically when the first backend comes online.
1781
- if (kind !== 'none' && layout === 'terminal-only') setLayout('split');
2808
+ function previewBackendPort(kind, stats) {
2809
+ const raw = kind === 'vite' ? stats?.vite?.port : null;
2810
+ const n = Number(raw);
2811
+ return Number.isInteger(n) && n > 0 ? n : null;
2812
+ }
2813
+
2814
+ function applyPreviewTarget(kind, stats) {
2815
+ const previousKind = previewBackendKind;
2816
+ previewBackendKind = kind === 'worker' || kind === 'vite' ? kind : 'none';
2817
+ const appKind = previewBackendKind === 'worker' ? 'worker' : 'vite';
2818
+ const appTab = ensurePreviewTab(appTabFor(appKind));
2819
+ const shouldFocusApp = previousKind !== previewBackendKind && previewBackendKind !== 'none';
2820
+ if (shouldFocusApp) activatePreviewTab(appTab.id);
2821
+ if (!activePreviewTabId) activatePreviewTab(appTab.id);
2822
+ syncPortPreviewTabs(stats?.ports, previewBackendPort(previewBackendKind, stats), appTab.id);
1782
2823
  }
1783
2824
 
1784
2825
  function reloadPreview() {
1785
- if (previewSurface === 'markdown') {
2826
+ const active = activePreviewTabId ? previewTabs.get(activePreviewTabId) : null;
2827
+ if (active?.type === 'markdown') {
1786
2828
  Editor.refreshMarkdownPreview().catch(() => {});
1787
2829
  return;
1788
2830
  }
1789
2831
  // Refreshing without a running backend should re-poll the
1790
2832
  // placeholder so users see dev-server status update promptly.
1791
- if (previewFrame.src === 'about:blank' ||
1792
- (!previewFrame.src.includes('/preview') && !previewFrame.src.includes('/worker'))) {
1793
- previewFrame.src = previewUrlFor(
1794
- (activePreview === 'none' || activePreview === null) ? 'vite' : activePreview
1795
- );
2833
+ if (!active || previewFrame.src === 'about:blank') {
2834
+ activatePreviewTab(firstAppTabId());
1796
2835
  } else {
1797
2836
  try { previewFrame.contentWindow?.location.reload(); }
1798
2837
  catch { previewFrame.src = previewFrame.src; }
1799
2838
  }
1800
2839
  }
1801
2840
 
2841
+ applyPreviewTarget('none', null);
2842
+
1802
2843
  // ── Pane resize manager ──
1803
2844
  //
1804
- // Owns the 3 resize handles in editor mode + 1 in split mode:
1805
- // H1 tree ↔ editor-stack (col, in editor mode) — TreeResizeHandle in FileTree IIFE
1806
- // H2 editor-stack ↔ preview (col, in editor + split) — #resizeHandle (mode-aware)
1807
- // V1 editor ↔ terminal (row, in editor mode) — #editorTerminalResizeHandle
2845
+ // Owns the three workspace resize handles:
2846
+ // H1 tree ↔ center stack (col) — TreeResizeHandle in FileTree IIFE
2847
+ // H2 center stack ↔ preview (col) — #resizeHandle
2848
+ // V1 top surface ↔ terminal (row) — #editorTerminalResizeHandle
1808
2849
  //
1809
2850
  // Each handle persists its dimension to localStorage so a reload
1810
2851
  // restores the user's layout. Keyed per-session.
@@ -1853,41 +2894,25 @@
1853
2894
  applyMonacoLayout();
1854
2895
  }
1855
2896
 
1856
- // Map middlePct → flex basis on the two children that actually
1857
- // compete for horizontal space in each mode:
1858
- // - split mode: .panel-terminal vs .panel-preview
1859
- // - editor mode: .panel-left-stack vs .panel-preview
1860
- // In editor mode .panel-left-stack is flex:1 (CSS rule); we
1861
- // override with explicit basis. Same for .panel-terminal in split.
2897
+ // Map middlePct to the center workspace and preview columns.
1862
2898
  function applyMiddlePreviewFlex() {
1863
2899
  const stack = document.getElementById('leftStack');
1864
2900
  const term = mainPanel.querySelector('.panel-terminal');
1865
2901
  const prev = mainPanel.querySelector('.panel-preview');
1866
2902
  const middlePct = Math.max(20, Math.min(80, dims.middlePct));
1867
- if (layout === 'editor') {
1868
- // stack drives middle width; terminal is INSIDE stack.
1869
- if (stack) stack.style.flex = middlePct + ' 0 0%';
1870
- if (prev) prev.style.flex = (100 - middlePct) + ' 0 0%';
1871
- if (term) term.style.flex = ''; // unset; vertical handler owns it
1872
- } else if (layout === 'split') {
1873
- if (term) term.style.flex = middlePct + ' 0 0%';
1874
- if (prev) prev.style.flex = (100 - middlePct) + ' 0 0%';
1875
- if (stack) stack.style.flex = '';
1876
- } else {
1877
- // Other modes (terminal-only / preview-only) don't use either
1878
- // handle visibly; clear inline so CSS defaults take effect.
1879
- if (term) term.style.flex = '';
1880
- if (prev) prev.style.flex = '';
1881
- if (stack) stack.style.flex = '';
1882
- }
2903
+ if (stack) stack.style.flex = middlePct + ' 0 0%';
2904
+ if (prev) prev.style.flex = (100 - middlePct) + ' 0 0%';
2905
+ if (term) term.style.flex = '';
1883
2906
  }
1884
2907
 
1885
2908
  function applyEditorTerminalFlex() {
1886
2909
  if (layout !== 'editor') return;
1887
- const editor = document.getElementById('editorPanel');
2910
+ const topSurface = workSurface === 'agent'
2911
+ ? document.getElementById('agentPanel')
2912
+ : document.getElementById('editorPanel');
1888
2913
  const term = mainPanel.querySelector('.panel-terminal');
1889
2914
  const editorPct = Math.max(10, Math.min(90, dims.editorPct));
1890
- if (editor) editor.style.flex = editorPct + ' 0 0%';
2915
+ if (topSurface) topSurface.style.flex = editorPct + ' 0 0%';
1891
2916
  if (term) term.style.flex = (100 - editorPct) + ' 0 0%';
1892
2917
  }
1893
2918
 
@@ -1975,9 +3000,7 @@
1975
3000
  if (tree) tree.style.width = dims.treeWidth + 'px';
1976
3001
  }
1977
3002
 
1978
- // Re-apply dims any time setLayout() switches modes — the inline
1979
- // flex values are mode-specific (split vs editor have different
1980
- // pane structures).
3003
+ // Re-apply dims any time setLayout() switches surfaces.
1981
3004
  function onLayoutChange() {
1982
3005
  applyMiddlePreviewFlex();
1983
3006
  applyEditorTerminalFlex();
@@ -2014,7 +3037,7 @@
2014
3037
  let kind = 'none';
2015
3038
  if (s.wrangler?.running) kind = 'worker';
2016
3039
  else if (s.vite?.running) kind = 'vite';
2017
- applyPreviewTarget(kind);
3040
+ applyPreviewTarget(kind, s);
2018
3041
 
2019
3042
  if (s.wrangler?.running) {
2020
3043
  viteStatus.textContent = 'worker running (' + (s.wrangler.name || '?') + ')';
@@ -2503,7 +3526,10 @@
2503
3526
  // Restore persisted pane dims before activating layout so the first paint
2504
3527
  // shows the user's prior arrangement.
2505
3528
  PaneResizer.restoreDims();
2506
- setLayout('editor');
3529
+ const initialMode = new URLSearchParams(location.search).get('agent') === '1'
3530
+ ? 'agent'
3531
+ : 'editor';
3532
+ setLayout(initialMode);
2507
3533
  connect();
2508
3534
  </script>
2509
3535
  </body>