cli-jaw 2.17.41 → 2.17.42
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.
- package/README.md +3 -3
- package/dist/bin/commands/browser.js +52 -5
- package/dist/bin/commands/browser.js.map +1 -1
- package/dist/src/agent/args.js +69 -19
- package/dist/src/agent/args.js.map +1 -1
- package/dist/src/agent/codex-app-events.js +31 -1
- package/dist/src/agent/codex-app-events.js.map +1 -1
- package/dist/src/agent/cursor-model-inventory.js +140 -0
- package/dist/src/agent/cursor-model-inventory.js.map +1 -0
- package/dist/src/agent/grok-models.js +80 -0
- package/dist/src/agent/grok-models.js.map +1 -0
- package/dist/src/browser/actions.js +383 -20
- package/dist/src/browser/actions.js.map +1 -1
- package/dist/src/browser/grounding-candidate.js +272 -0
- package/dist/src/browser/grounding-candidate.js.map +1 -0
- package/dist/src/browser/grounding-eval.js +247 -0
- package/dist/src/browser/grounding-eval.js.map +1 -0
- package/dist/src/browser/image-size.js +63 -0
- package/dist/src/browser/image-size.js.map +1 -0
- package/dist/src/browser/index.js +1 -0
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/browser/occlusion.js +148 -0
- package/dist/src/browser/occlusion.js.map +1 -0
- package/dist/src/browser/verify-candidate.js +162 -0
- package/dist/src/browser/verify-candidate.js.map +1 -0
- package/dist/src/browser/vision-input.js +74 -0
- package/dist/src/browser/vision-input.js.map +1 -0
- package/dist/src/browser/vision-provider.js +75 -0
- package/dist/src/browser/vision-provider.js.map +1 -0
- package/dist/src/browser/vision.js +360 -47
- package/dist/src/browser/vision.js.map +1 -1
- package/dist/src/browser/web-ai/candidate-reconcile.js +14 -5
- package/dist/src/browser/web-ai/candidate-reconcile.js.map +1 -1
- package/dist/src/cli/claude-model-discovery.js +175 -0
- package/dist/src/cli/claude-model-discovery.js.map +1 -0
- package/dist/src/cli/claude-models.js +46 -0
- package/dist/src/cli/claude-models.js.map +1 -1
- package/dist/src/cli/registry-live.js +104 -1
- package/dist/src/cli/registry-live.js.map +1 -1
- package/dist/src/cli/registry.js +28 -5
- package/dist/src/cli/registry.js.map +1 -1
- package/dist/src/code-mode/host.js +6 -0
- package/dist/src/code-mode/host.js.map +1 -1
- package/dist/src/code-mode/manager.js +37 -8
- package/dist/src/code-mode/manager.js.map +1 -1
- package/dist/src/code-mode/providers/catalog.js +53 -1
- package/dist/src/code-mode/providers/catalog.js.map +1 -1
- package/dist/src/code-mode/providers/codex-app.js +6 -0
- package/dist/src/code-mode/providers/codex-app.js.map +1 -1
- package/dist/src/code-mode/providers/live-models.js +85 -0
- package/dist/src/code-mode/providers/live-models.js.map +1 -0
- package/dist/src/code-mode/providers/provider-live-models.js +120 -0
- package/dist/src/code-mode/providers/provider-live-models.js.map +1 -0
- package/dist/src/code-mode/session.js +37 -0
- package/dist/src/code-mode/session.js.map +1 -1
- package/dist/src/prompt/templates/a1-system.md +24 -14
- package/dist/src/prompt/templates/control-system.md +6 -11
- package/dist/src/prompt/templates/employee.md +5 -5
- package/dist/src/routes/browser.js +17 -5
- package/dist/src/routes/browser.js.map +1 -1
- package/package.json +1 -1
- package/public/css/activity.css +20 -1
- package/public/css/layout.css +7 -3
- package/public/dist/.vite/manifest.json +104 -105
- package/public/dist/assets/Agent-CQ4EBgY8.js +1 -0
- package/public/dist/assets/CodeCanvas-DWsx6sv5.js +2 -0
- package/public/dist/assets/DashboardMeta-Dh-tasbY.js +1 -0
- package/public/dist/assets/Display-NrqY_IaN.js +1 -0
- package/public/dist/assets/{Permissions-BzbnqenZ.js → Permissions-Bh947tUG.js} +1 -1
- package/public/dist/assets/Permissions-DzNJCt8K.js +1 -0
- package/public/dist/assets/app-BCBnxiGr.css +1 -0
- package/public/dist/assets/{app-x5Kray4W.js → app-Q0prKOFx.js} +4 -4
- package/public/dist/assets/{bgtask-badge-W9djRQVu.js → bgtask-badge-DAcWaUfS.js} +1 -1
- package/public/dist/assets/bgtask-badge-DHA2OQm8.js +1 -0
- package/public/dist/assets/{employees--_dmDw_a.js → employees-CWS3Juca.js} +1 -1
- package/public/dist/assets/{iframe-renderer-D4BmLtAq.js → iframe-renderer-DCDKBMb7.js} +2 -2
- package/public/dist/assets/iframe-renderer-RpvBly2x.js +1 -0
- package/public/dist/assets/manager-Nk7-RYPs.css +1 -0
- package/public/dist/assets/manager-XkR-DRaQ.js +5 -0
- package/public/dist/assets/manager-shortcuts-jsiX-zV6.js +1 -0
- package/public/dist/assets/memory-4YH8sz0L.js +1 -0
- package/public/dist/assets/{memory-DENQWzM8.js → memory-DhNdKZLO.js} +1 -1
- package/public/dist/assets/{message-history-BsrLcGJj.js → message-history-DbRTl1P0.js} +1 -1
- package/public/dist/assets/message-history-lVcLpAw8.js +1 -0
- package/public/dist/assets/{render-CF6Q1oIh.js → render-DP8SJtdZ.js} +1 -1
- package/public/dist/assets/settings--EGy3_B9.js +1 -0
- package/public/dist/assets/settings-85n9NjvX.js +1 -0
- package/public/dist/assets/{settings-B-C_jdZR.js → settings-C9_D-pn0.js} +32 -32
- package/public/dist/assets/settings-core-8wBl1mg-.js +1 -0
- package/public/dist/assets/{settings-core-BzAJLsKN.js → settings-core-BcVD2FgV.js} +1 -1
- package/public/dist/assets/{settings-embedding-dEMyByVi.js → settings-embedding-BSpq785l.js} +1 -1
- package/public/dist/assets/{settings-embedding-BSA42oKM.css → settings-embedding-LIMq5ebM.css} +1 -1
- package/public/dist/assets/{sidebar-fFEOpvJl.js → sidebar-BVhLb5Ft.js} +3 -3
- package/public/dist/assets/skills-6GFkHTlQ.js +1 -0
- package/public/dist/assets/{skills-Da_b06ci.js → skills-X7qxmHka.js} +1 -1
- package/public/dist/assets/{slash-commands-BbPUHHF4.js → slash-commands-BnDH4IU_.js} +1 -1
- package/public/dist/assets/slash-commands-qu8fPAZ3.js +1 -0
- package/public/dist/assets/{trace-drawer-B-QmzUfP.js → trace-drawer-DasaS1z4.js} +1 -1
- package/public/dist/assets/{ui-CkRUrIfp.js → ui-BtiBTXng.js} +1 -1
- package/public/dist/assets/ui-BucJ1zMA.js +1 -0
- package/public/dist/index.html +2 -2
- package/public/dist/manager/index.html +3 -3
- package/public/dist/settings/index.html +2 -2
- package/public/js/features/settings.ts +16 -0
- package/public/manager/src/App.tsx +10 -12
- package/public/manager/src/AppChrome.tsx +2 -3
- package/public/manager/src/InstancePreview.tsx +47 -0
- package/public/manager/src/SidebarRailRouter.tsx +34 -30
- package/public/manager/src/code/CodeComposer.tsx +31 -6
- package/public/manager/src/code/CodeSessionList.tsx +25 -7
- package/public/manager/src/code/CodeToastHost.tsx +49 -0
- package/public/manager/src/code/CodeTranscript.tsx +136 -26
- package/public/manager/src/code/CodeWorkbench.tsx +16 -2
- package/public/manager/src/code/ComposerFooter.tsx +130 -46
- package/public/manager/src/code/ContextUsageMeter.tsx +72 -0
- package/public/manager/src/code/ProviderGlyph.tsx +58 -0
- package/public/manager/src/code/code-composer.css +159 -175
- package/public/manager/src/code/code-controller-runtime.ts +3 -1
- package/public/manager/src/code/code-session-state.ts +7 -1
- package/public/manager/src/code/code-toasts.ts +73 -0
- package/public/manager/src/code/code-workbench.css +42 -1
- package/public/manager/src/code/code.css +130 -15
- package/public/manager/src/code/context-usage.ts +74 -0
- package/public/manager/src/code/pending-user-item.ts +46 -0
- package/public/manager/src/code/session-order.ts +70 -0
- package/public/manager/src/code/tool-summary.ts +101 -0
- package/public/manager/src/code/use-dictation.ts +130 -0
- package/public/manager/src/components/CommandCenter.tsx +4 -8
- package/public/manager/src/components/InstanceDetailPanel.tsx +0 -18
- package/public/manager/src/components/Workbench.tsx +6 -10
- package/public/manager/src/components/WorkbenchHeader.tsx +0 -11
- package/public/manager/src/dashboard-settings/dashboard-settings-ui.ts +3 -1
- package/public/manager/src/hooks/useDashboardView.ts +38 -38
- package/public/manager/src/manager-components.css +0 -8
- package/public/manager/src/manager-shortcut-runner.ts +0 -1
- package/public/manager/src/manager-shortcuts.ts +0 -2
- package/public/manager/src/settings/pages/Agent.tsx +5 -2
- package/public/manager/src/settings/pages/DashboardMeta.tsx +20 -1
- package/public/manager/src/settings/pages/Permissions.tsx +23 -4
- package/public/manager/src/settings/pages/components/agent/PermissionQuickSection.tsx +6 -2
- package/public/manager/src/settings/pages/components/agent/agent-save.ts +24 -1
- package/public/manager/src/settings/settings-icons.ts +3 -1
- package/public/manager/src/settings/settings-registry.ts +3 -3
- package/public/manager/src/settings-controls.css +0 -1
- package/public/manager/src/settings-standalone.tsx +8 -8
- package/public/manager/src/types.ts +0 -1
- package/scripts/check-private-boundary.mjs +101 -0
- package/scripts/grounding-eval.mjs +223 -0
- package/public/dist/assets/Agent-B8ZEWJ6B.js +0 -1
- package/public/dist/assets/CodeCanvas-Bj3lTSu6.js +0 -2
- package/public/dist/assets/DashboardMeta-CmZavI4H.js +0 -1
- package/public/dist/assets/Display-DFkjKcMk.js +0 -1
- package/public/dist/assets/Permissions-CJftCO6p.js +0 -1
- package/public/dist/assets/app-DVbrIl8f.css +0 -1
- package/public/dist/assets/bgtask-badge-CuAc8bAl.js +0 -1
- package/public/dist/assets/iframe-renderer-By2WO5bd.js +0 -1
- package/public/dist/assets/manager-CxLXWCGi.css +0 -1
- package/public/dist/assets/manager-DA3dF3eN.js +0 -5
- package/public/dist/assets/manager-shortcuts-CzGkE0ZG.js +0 -1
- package/public/dist/assets/memory-thjr2apY.js +0 -1
- package/public/dist/assets/message-history-CfU7_P5t.js +0 -1
- package/public/dist/assets/settings-DXA5L4DR.js +0 -1
- package/public/dist/assets/settings-Dy_IaNVH.js +0 -1
- package/public/dist/assets/settings-core-D7sAQqhC.js +0 -1
- package/public/dist/assets/skills-BcIievZp.js +0 -1
- package/public/dist/assets/slash-commands-B-5Shayl.js +0 -1
- package/public/dist/assets/ui-CJLdMfRx.js +0 -1
|
@@ -298,9 +298,77 @@
|
|
|
298
298
|
min-height: 0;
|
|
299
299
|
height: 100%;
|
|
300
300
|
overflow: hidden;
|
|
301
|
+
/* Contains the toast stack: a stacking context, not just a positioning
|
|
302
|
+
origin, so the notice cannot paint over the mobile drawer or its scrim,
|
|
303
|
+
which sit at z-index 21 and 20 on the window. */
|
|
304
|
+
position: relative;
|
|
305
|
+
z-index: 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* Notices sit over the transcript, not over the workspace header. The host is a
|
|
309
|
+
zero-height flow element placed directly above the transcript, so "top" means
|
|
310
|
+
the top of the conversation rather than the top of the pane; absolute
|
|
311
|
+
positioning from the pane would land the card inside the header band and
|
|
312
|
+
swallow clicks meant for the workspace picker. The host ignores pointer
|
|
313
|
+
events so it never blocks the text underneath; the cards take them back. */
|
|
314
|
+
.code-toast-host {
|
|
315
|
+
position: relative;
|
|
316
|
+
z-index: 3;
|
|
317
|
+
height: 0;
|
|
318
|
+
overflow: visible;
|
|
319
|
+
display: flex;
|
|
320
|
+
flex-direction: column;
|
|
321
|
+
align-items: center;
|
|
322
|
+
gap: 8px;
|
|
323
|
+
margin: 0 auto;
|
|
324
|
+
max-width: min(560px, calc(100% - 32px));
|
|
325
|
+
pointer-events: none;
|
|
326
|
+
}
|
|
327
|
+
.code-toast-region { display: flex; flex-direction: column; gap: 8px; align-items: center; }
|
|
328
|
+
/* An empty live region has to stay in the DOM to be observed when content
|
|
329
|
+
arrives, but it should not reserve layout while it holds nothing. */
|
|
330
|
+
.code-toast-region:empty { display: none; }
|
|
331
|
+
.code-toast-region:not(:empty) { margin-top: 12px; }
|
|
332
|
+
.code-toast {
|
|
333
|
+
pointer-events: auto;
|
|
334
|
+
display: flex;
|
|
335
|
+
align-items: flex-start;
|
|
336
|
+
gap: 8px;
|
|
337
|
+
padding: 8px 10px;
|
|
338
|
+
border-radius: 10px;
|
|
339
|
+
border: 1px solid var(--border-subtle);
|
|
340
|
+
background: var(--bg-panel);
|
|
341
|
+
box-shadow: 0 6px 20px rgb(0 0 0 / 28%);
|
|
342
|
+
font-size: 12px;
|
|
343
|
+
line-height: 1.35;
|
|
344
|
+
color: var(--text-primary);
|
|
345
|
+
animation: code-toast-enter 0.2s cubic-bezier(0.77, 0, 0.175, 1);
|
|
346
|
+
}
|
|
347
|
+
.code-toast-message { min-width: 0; overflow-wrap: anywhere; }
|
|
348
|
+
.code-toast-dismiss {
|
|
349
|
+
flex-shrink: 0;
|
|
350
|
+
min-width: 20px;
|
|
351
|
+
min-height: 20px;
|
|
352
|
+
padding: 0;
|
|
353
|
+
border: 0;
|
|
354
|
+
border-radius: 5px;
|
|
355
|
+
background: transparent;
|
|
356
|
+
color: var(--text-dim);
|
|
357
|
+
cursor: pointer;
|
|
358
|
+
line-height: 1;
|
|
359
|
+
}
|
|
360
|
+
.code-toast-dismiss:hover { background: var(--bg-raised); color: var(--text-primary); }
|
|
361
|
+
.code-toast-warning { border-color: color-mix(in srgb, var(--status-warn) 48%, var(--border-subtle)); }
|
|
362
|
+
.code-toast-error { border-color: color-mix(in srgb, var(--status-error) 52%, var(--border-subtle)); }
|
|
363
|
+
@keyframes code-toast-enter {
|
|
364
|
+
from { opacity: 0; transform: translateY(-6px); }
|
|
365
|
+
to { opacity: 1; transform: translateY(0); }
|
|
366
|
+
}
|
|
367
|
+
@media (prefers-reduced-motion: reduce) {
|
|
368
|
+
.code-toast { animation: none; }
|
|
301
369
|
}
|
|
302
370
|
|
|
303
|
-
/* Session header (title +
|
|
371
|
+
/* Session header (title + plan) */
|
|
304
372
|
.code-session-header {
|
|
305
373
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
306
374
|
padding: 6px 16px;
|
|
@@ -309,19 +377,37 @@
|
|
|
309
377
|
font-size: 11px;
|
|
310
378
|
}
|
|
311
379
|
.code-session-title { font-weight: 600; color: var(--text-primary); }
|
|
312
|
-
.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
display: inline-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
380
|
+
/* Context usage sits in the composer footer, next to the model it belongs to.
|
|
381
|
+
The ring appears only when the runtime reported a window: a proportion of an
|
|
382
|
+
unknown total is not a proportion. */
|
|
383
|
+
.code-context-usage { position: relative; display: inline-flex; align-items: center; }
|
|
384
|
+
.code-context-trigger {
|
|
385
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
386
|
+
height: 28px; padding: 0 8px;
|
|
387
|
+
border: 1px solid transparent; border-radius: 8px;
|
|
388
|
+
background: transparent; color: var(--text-secondary);
|
|
389
|
+
font-size: 11px; font-variant-numeric: tabular-nums; cursor: pointer;
|
|
390
|
+
}
|
|
391
|
+
.code-context-trigger:hover, .code-context-trigger:focus-visible { background: var(--bg-raised); color: var(--text-primary); }
|
|
392
|
+
.code-context-ring { width: 16px; height: 16px; transform: rotate(-90deg); flex-shrink: 0; }
|
|
393
|
+
.code-context-ring-track { fill: none; stroke: var(--border-subtle); stroke-width: 2.5; }
|
|
394
|
+
.code-context-ring-fill { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; }
|
|
395
|
+
.code-context-trigger.is-high .code-context-ring-fill { stroke: var(--status-warn); }
|
|
396
|
+
.code-context-trigger.is-critical .code-context-ring-fill { stroke: var(--status-error); }
|
|
397
|
+
.code-context-trigger.is-critical .code-context-value { color: var(--status-error); }
|
|
398
|
+
.code-context-detail {
|
|
399
|
+
position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);
|
|
400
|
+
z-index: 4; min-width: 180px; padding: 8px 10px;
|
|
401
|
+
border: 1px solid var(--border-subtle); border-radius: 10px;
|
|
402
|
+
background: var(--bg-panel); box-shadow: 0 6px 20px rgb(0 0 0 / 28%);
|
|
403
|
+
font-size: 11px; color: var(--text-secondary);
|
|
404
|
+
}
|
|
405
|
+
.code-context-detail-total { margin: 0 0 4px; color: var(--text-primary); font-weight: 650; }
|
|
406
|
+
.code-context-detail-note { margin: 0 0 6px; }
|
|
407
|
+
.code-context-breakdown { display: grid; gap: 2px; margin: 0; }
|
|
408
|
+
.code-context-breakdown > div { display: flex; justify-content: space-between; gap: 12px; }
|
|
409
|
+
.code-context-breakdown dt, .code-context-breakdown dd { margin: 0; }
|
|
410
|
+
.code-context-breakdown dd { font-variant-numeric: tabular-nums; color: var(--text-primary); }
|
|
325
411
|
.code-plan-entries { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
326
412
|
.code-plan-entry { font-size: 10px; color: var(--text-secondary); }
|
|
327
413
|
.code-plan-completed { color: var(--status-success); }
|
|
@@ -382,8 +468,25 @@
|
|
|
382
468
|
color: var(--text-secondary);
|
|
383
469
|
margin-bottom: 4px; display: block;
|
|
384
470
|
}
|
|
385
|
-
.code-message-user .code-message-role { color: var(--text-primary); }
|
|
386
471
|
.code-message-assistant .code-message-role { color: var(--status-success); }
|
|
472
|
+
|
|
473
|
+
/*
|
|
474
|
+
* The user's own text is the one thing on this surface they wrote themselves,
|
|
475
|
+
* so it gets a right-aligned bubble and the assistant keeps the full column as
|
|
476
|
+
* plain prose. Asymmetry does the role separation that a repeated label
|
|
477
|
+
* otherwise has to do in words.
|
|
478
|
+
*/
|
|
479
|
+
.code-message-user { display: flex; flex-direction: column; align-items: flex-end; }
|
|
480
|
+
.code-message-user .code-message-role { color: var(--text-dim); }
|
|
481
|
+
.code-message-user .code-message-text {
|
|
482
|
+
max-width: min(78%, 36rem);
|
|
483
|
+
padding: 10px 14px;
|
|
484
|
+
border: 1px solid var(--border-subtle);
|
|
485
|
+
border-radius: 12px 12px 4px 12px;
|
|
486
|
+
background: var(--bg-raised);
|
|
487
|
+
}
|
|
488
|
+
/* A prompt the server has not echoed yet is dimmed, never hidden. */
|
|
489
|
+
.code-message-user.is-unsent .code-message-text { opacity: 0.72; border-style: dashed; }
|
|
387
490
|
.code-message-text {
|
|
388
491
|
font-size: 13px; line-height: 1.6; color: var(--text-primary);
|
|
389
492
|
}
|
|
@@ -494,6 +597,18 @@
|
|
|
494
597
|
}
|
|
495
598
|
|
|
496
599
|
.code-session-attention { grid-column: 1 / -1; color: var(--text-secondary); font-size: 10px; }
|
|
600
|
+
.code-session-attention-approvals { color: var(--status-warn); font-weight: 650; }
|
|
601
|
+
/* The uneventful cases stay readable to assistive technology and out of the
|
|
602
|
+
way visually. A label on every row is a label on no row, and it costs the
|
|
603
|
+
one session that is actually waiting its visibility. */
|
|
604
|
+
.code-session-attention[data-quiet], .code-session-status[data-quiet] {
|
|
605
|
+
position: absolute;
|
|
606
|
+
width: 1px; height: 1px;
|
|
607
|
+
margin: -1px; padding: 0;
|
|
608
|
+
overflow: hidden;
|
|
609
|
+
clip-path: inset(50%);
|
|
610
|
+
white-space: nowrap;
|
|
611
|
+
}
|
|
497
612
|
.code-session-row { margin-bottom: 6px; }
|
|
498
613
|
.code-session-actions { font-size: 11px; color: var(--text-secondary); margin: 0 8px; }
|
|
499
614
|
.code-session-actions summary { cursor: pointer; min-height: 24px; }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CodeContextUsage } from '../../../../src/code-mode/wire';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How much of the model's context the conversation is using.
|
|
5
|
+
*
|
|
6
|
+
* The runtime may report a token count without a window, or nothing at all, so
|
|
7
|
+
* there are three answers and not one. A missing window is not an unlimited
|
|
8
|
+
* one and a missing count is not zero, which is why neither collapses into a
|
|
9
|
+
* number the reader would take as measured.
|
|
10
|
+
*/
|
|
11
|
+
export type ContextUsageView =
|
|
12
|
+
| { state: 'hidden' }
|
|
13
|
+
| { state: 'counted'; usedTokens: number }
|
|
14
|
+
| { state: 'measured'; usedTokens: number; maxTokens: number; percent: number; over: boolean };
|
|
15
|
+
|
|
16
|
+
export function contextUsageView(usage: CodeContextUsage | undefined): ContextUsageView {
|
|
17
|
+
if (!usage || !Number.isFinite(usage.totalTokens) || usage.totalTokens < 0) return { state: 'hidden' };
|
|
18
|
+
const max = usage.modelContextWindow;
|
|
19
|
+
if (max === null || !Number.isFinite(max) || max <= 0) return { state: 'counted', usedTokens: usage.totalTokens };
|
|
20
|
+
// A count past the window is possible -- a model change moves the window
|
|
21
|
+
// under a conversation that is already large. The ring cannot draw past
|
|
22
|
+
// full, so the percentage is capped for the ring's sake, but the fact that
|
|
23
|
+
// it was capped is carried rather than hidden: a label reading "100% used,
|
|
24
|
+
// 533k of 258k" is self-contradictory, and quietly capping is how a wrong
|
|
25
|
+
// number would look exactly like a full context.
|
|
26
|
+
const raw = Math.round((usage.totalTokens / max) * 100);
|
|
27
|
+
return { state: 'measured', usedTokens: usage.totalTokens, maxTokens: max,
|
|
28
|
+
percent: Math.min(100, raw), over: raw > 100 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Rounded to keep the control narrow. Each unit is chosen after its own
|
|
33
|
+
* rounding: 999,999 in thousands rounds to 1000, which is a four-digit "k"
|
|
34
|
+
* that should have been "1.0M", and 999.5 rounds to 1000, which is not the
|
|
35
|
+
* bare small number that branch is for.
|
|
36
|
+
*/
|
|
37
|
+
function scaled(value: number, divisor: number): string {
|
|
38
|
+
const n = value / divisor;
|
|
39
|
+
return n < 10 ? n.toFixed(1) : String(Math.round(n));
|
|
40
|
+
}
|
|
41
|
+
export function formatTokens(value: number): string {
|
|
42
|
+
if (!Number.isFinite(value) || value < 0) return '—';
|
|
43
|
+
const rounded = Math.round(value);
|
|
44
|
+
if (rounded < 1000) return String(rounded);
|
|
45
|
+
const thousands = scaled(rounded, 1000);
|
|
46
|
+
return Number(thousands) < 1000 ? `${thousands}k` : `${scaled(rounded, 1_000_000)}M`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The count itself, grouped. The rounded form cannot be reconstructed into it. */
|
|
50
|
+
export function formatExactTokens(value: number | null): string {
|
|
51
|
+
return value === null || !Number.isFinite(value) || value < 0 ? '—' : value.toLocaleString('en-US');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** An unreported part of the breakdown reads as absent, never as zero. */
|
|
55
|
+
export function formatOptionalTokens(value: number | null): string {
|
|
56
|
+
return value === null ? '—' : formatTokens(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Thresholds are about when to start paying attention, so they are named rather
|
|
61
|
+
* than left as bare numbers at the call site.
|
|
62
|
+
*/
|
|
63
|
+
export function contextUsageLevel(percent: number): 'normal' | 'high' | 'critical' {
|
|
64
|
+
if (percent >= 90) return 'critical';
|
|
65
|
+
if (percent >= 75) return 'high';
|
|
66
|
+
return 'normal';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function contextUsageLabel(view: ContextUsageView): string {
|
|
70
|
+
if (view.state === 'hidden') return '';
|
|
71
|
+
if (view.state === 'counted') return `Context: ${formatTokens(view.usedTokens)} tokens used, window unknown`;
|
|
72
|
+
if (view.over) return `Context: over the window, ${formatTokens(view.usedTokens)} of ${formatTokens(view.maxTokens)} tokens`;
|
|
73
|
+
return `Context: ${view.percent}% used, ${formatTokens(view.usedTokens)} of ${formatTokens(view.maxTokens)} tokens`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { CodeItem } from '../../../../src/code-mode/wire';
|
|
2
|
+
import type { CodeDraft } from './code-controller-drafts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A submitted prompt used to vanish between Send and the server's echo.
|
|
6
|
+
*
|
|
7
|
+
* The server does emit a `user_message` as soon as it accepts a turn, but that
|
|
8
|
+
* arrives after an HTTP round trip and an SSE hop, and when acceptance is
|
|
9
|
+
* unknown it may never arrive at all. Until then the transcript showed nothing,
|
|
10
|
+
* so the text the user just wrote was simply gone from the screen.
|
|
11
|
+
*
|
|
12
|
+
* This is derived at read time rather than pushed into the session reducer:
|
|
13
|
+
* that reducer applies strictly sequence-ordered server events, and inserting a
|
|
14
|
+
* client-invented item would desynchronize its cursor. `clientTurnKey` is the
|
|
15
|
+
* join key, so the real item replaces this one the moment it lands.
|
|
16
|
+
*/
|
|
17
|
+
export const PENDING_USER_ITEM_ID = 'pending:user';
|
|
18
|
+
|
|
19
|
+
export function pendingUserItem(draft: CodeDraft, items: CodeItem[], now = Date.now()): CodeItem | null {
|
|
20
|
+
const retry = draft.retry;
|
|
21
|
+
if (!retry) return null;
|
|
22
|
+
// The authoritative item is here: show that one instead.
|
|
23
|
+
if (items.some(item => item.kind === 'user_message' && item.clientTurnKey === retry.key)) return null;
|
|
24
|
+
const unknown = draft.operation.kind === 'unknown-send';
|
|
25
|
+
return {
|
|
26
|
+
itemId: PENDING_USER_ITEM_ID,
|
|
27
|
+
turnId: null,
|
|
28
|
+
kind: 'user_message',
|
|
29
|
+
// An unconfirmed send is not a failure and must not read as one; it is
|
|
30
|
+
// still pending until the user retries or the echo arrives.
|
|
31
|
+
status: 'pending',
|
|
32
|
+
text: retry.text,
|
|
33
|
+
clientTurnKey: retry.key,
|
|
34
|
+
// Sorting is by firstSequence, so a large value keeps this last.
|
|
35
|
+
firstSequence: Number.MAX_SAFE_INTEGER,
|
|
36
|
+
createdAt: now,
|
|
37
|
+
updatedAt: now,
|
|
38
|
+
...(unknown ? { phase: 'unknown' as const } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Items to render: server history plus the not-yet-echoed prompt. */
|
|
43
|
+
export function withPendingUserItem(draft: CodeDraft, items: CodeItem[], now = Date.now()): CodeItem[] {
|
|
44
|
+
const pending = pendingUserItem(draft, items, now);
|
|
45
|
+
return pending ? [...items, pending] : items;
|
|
46
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CodeSessionInfo } from '../../../../src/code-mode/wire';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How the session list is ordered and grouped, kept apart from rendering so the
|
|
5
|
+
* rules can be stated and tested on their own.
|
|
6
|
+
*
|
|
7
|
+
* The server returns sessions by `last_used_at DESC`, which means answering a
|
|
8
|
+
* prompt in the third session moves it to the top while the reader is looking
|
|
9
|
+
* at it. Order here is anchored to creation instead: a row holds its place
|
|
10
|
+
* until something actually changes about it, so the list moves when a session
|
|
11
|
+
* is created or archived and not merely because it is busy. Which session is
|
|
12
|
+
* running is carried by the row's own status, which is where a changing fact
|
|
13
|
+
* belongs.
|
|
14
|
+
*/
|
|
15
|
+
export type CodeSessionSection = 'active' | 'archived';
|
|
16
|
+
|
|
17
|
+
export function codeSessionSection(session: CodeSessionInfo): CodeSessionSection {
|
|
18
|
+
return session.archivedAt === null ? 'active' : 'archived';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Creation order, newest first. Ties break on id so the order is total. */
|
|
22
|
+
export function compareCodeSessions(left: CodeSessionInfo, right: CodeSessionInfo): number {
|
|
23
|
+
return right.createdAt - left.createdAt || left.sessionId.localeCompare(right.sessionId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CodeSessionGroup = {
|
|
27
|
+
section: CodeSessionSection;
|
|
28
|
+
sessions: CodeSessionInfo[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Active first, then archived. Archived sessions are history: they are ordered
|
|
33
|
+
* by when they were archived, because "when did I put this away" is the
|
|
34
|
+
* question that section answers, and they fall back to creation order when a
|
|
35
|
+
* record predates the field.
|
|
36
|
+
*/
|
|
37
|
+
export function groupCodeSessions(sessions: readonly CodeSessionInfo[]): CodeSessionGroup[] {
|
|
38
|
+
const active = sessions.filter(session => codeSessionSection(session) === 'active').sort(compareCodeSessions);
|
|
39
|
+
const archived = sessions.filter(session => codeSessionSection(session) === 'archived')
|
|
40
|
+
.sort((left, right) => (right.archivedAt ?? right.createdAt) - (left.archivedAt ?? left.createdAt)
|
|
41
|
+
|| left.sessionId.localeCompare(right.sessionId));
|
|
42
|
+
const groups: CodeSessionGroup[] = [];
|
|
43
|
+
if (active.length) groups.push({ section: 'active', sessions: active });
|
|
44
|
+
if (archived.length) groups.push({ section: 'archived', sessions: archived });
|
|
45
|
+
return groups;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a row says about itself, beyond its title.
|
|
50
|
+
*
|
|
51
|
+
* Idle is the common case and says nothing: a label on every row is a label on
|
|
52
|
+
* no row, and it costs the one session that is actually waiting its visibility.
|
|
53
|
+
* Unknown stays unknown -- an unhydrated approval count is not zero approvals.
|
|
54
|
+
*/
|
|
55
|
+
export type CodeSessionAttention =
|
|
56
|
+
| { kind: 'none' }
|
|
57
|
+
| { kind: 'unknown' }
|
|
58
|
+
| { kind: 'approvals'; count: number };
|
|
59
|
+
|
|
60
|
+
export function codeSessionAttention(count: number | undefined): CodeSessionAttention {
|
|
61
|
+
if (count === undefined) return { kind: 'unknown' };
|
|
62
|
+
return count > 0 ? { kind: 'approvals', count } : { kind: 'none' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function codeSessionAttentionLabel(attention: CodeSessionAttention): string {
|
|
66
|
+
if (attention.kind === 'unknown') return 'Approval status unknown';
|
|
67
|
+
if (attention.kind === 'none') return 'No pending approvals';
|
|
68
|
+
return `${attention.count} pending approval${attention.count === 1 ? '' : 's'}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { CodeItem } from '../../../../src/code-mode/wire';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One line that says what a tool call did, in the user's terms.
|
|
5
|
+
*
|
|
6
|
+
* A raw tool name plus a JSON blob forces the reader to decode the call before
|
|
7
|
+
* they can tell whether it matters. `Read src/app.ts` is legible at a glance,
|
|
8
|
+
* so the body can stay collapsed until someone actually wants it.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* [pattern, settled, active]. The settled word is what a finished call reads as
|
|
12
|
+
* and is deliberately unchanged: it is asserted across the summary tests and is
|
|
13
|
+
* the vocabulary the transcript has always used.
|
|
14
|
+
*
|
|
15
|
+
* The active word exists because a running call and a finished one otherwise
|
|
16
|
+
* look identical once the status badge is gone. Tense carries completion, the
|
|
17
|
+
* way it does in a native transcript, instead of a chip that says "Done" on
|
|
18
|
+
* every successful row.
|
|
19
|
+
*/
|
|
20
|
+
const VERBS: Array<[RegExp, string, string]> = [
|
|
21
|
+
[/^(read|read_file|get_file|open_file|view|cat)$/i, 'Read', 'Reading'],
|
|
22
|
+
[/^(write|write_file|create_file|edit|edit_file|apply_patch|str_replace\w*|update_file)$/i, 'Edit', 'Editing'],
|
|
23
|
+
[/^(bash|shell|exec|exec_command|run|run_command|terminal)$/i, 'Bash', 'Running'],
|
|
24
|
+
[/^(search|grep|rg|ripgrep|glob|find|codebase_search)$/i, 'Search', 'Searching'],
|
|
25
|
+
[/^(fetch|browser\w*|web_\w*|open_url|http)$/i, 'Open', 'Opening'],
|
|
26
|
+
[/^(list|ls|list_dir|list_files)$/i, 'List', 'Listing'],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** A call that has not settled yet reads in the present tense. */
|
|
30
|
+
function isActive(status: CodeItem['status']): boolean {
|
|
31
|
+
return status === 'running' || status === 'pending';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Longest-suffix workspace-relative path, so the row stays scannable. */
|
|
35
|
+
export function shortenPath(value: string, workingDir: string): string {
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
if (workingDir && trimmed.startsWith(workingDir)) {
|
|
38
|
+
const relative = trimmed.slice(workingDir.length).replace(/^[\\/]+/, '');
|
|
39
|
+
if (relative) return relative;
|
|
40
|
+
}
|
|
41
|
+
if (trimmed.length <= 64) return trimmed;
|
|
42
|
+
const parts = trimmed.split(/[\\/]/).filter(Boolean);
|
|
43
|
+
return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : `…${trimmed.slice(-60)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function firstLine(value: string): string {
|
|
47
|
+
const line = value.split(/\r?\n/).find(row => row.trim()) ?? '';
|
|
48
|
+
const collapsed = line.replace(/\s+/g, ' ').trim();
|
|
49
|
+
return collapsed.length > 96 ? `${collapsed.slice(0, 95)}…` : collapsed;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A tool argument may be a JSON object, a bare command, or a path. Pull the
|
|
54
|
+
* one field a reader would recognise, and fall back to the first line rather
|
|
55
|
+
* than printing an object.
|
|
56
|
+
*/
|
|
57
|
+
export function summariseToolInput(input: string | undefined, workingDir: string): string {
|
|
58
|
+
if (!input) return '';
|
|
59
|
+
const raw = input.trim();
|
|
60
|
+
if (!raw) return '';
|
|
61
|
+
if (raw.startsWith('{')) {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
64
|
+
for (const key of ['command', 'cmd', 'path', 'file_path', 'file', 'url', 'query', 'pattern']) {
|
|
65
|
+
const value = parsed[key];
|
|
66
|
+
if (typeof value === 'string' && value.trim()) {
|
|
67
|
+
return key === 'path' || key === 'file' || key === 'file_path'
|
|
68
|
+
? shortenPath(value, workingDir) : firstLine(value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return '';
|
|
72
|
+
} catch { /* not JSON after all; fall through to the raw text */ }
|
|
73
|
+
}
|
|
74
|
+
return raw.includes('/') && !raw.includes(' ') ? shortenPath(raw, workingDir) : firstLine(raw);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function toolSummary(item: CodeItem, workingDir: string): string {
|
|
78
|
+
const active = isActive(item.status);
|
|
79
|
+
if (item.kind === 'file_change') {
|
|
80
|
+
const target = summariseToolInput(item.tool?.input, workingDir) || item.tool?.detail || '';
|
|
81
|
+
const verb = active ? 'Editing' : 'Edit';
|
|
82
|
+
return target ? `${verb} ${target}` : 'File change';
|
|
83
|
+
}
|
|
84
|
+
const name = item.tool?.name?.trim() || 'Tool';
|
|
85
|
+
const match = VERBS.find(([pattern]) => pattern.test(name));
|
|
86
|
+
const verb = match ? (active ? match[2] : match[1]) : undefined;
|
|
87
|
+
const detail = summariseToolInput(item.tool?.input, workingDir);
|
|
88
|
+
if (verb) return detail ? `${verb} ${detail}` : verb;
|
|
89
|
+
// An unrecognised tool keeps its own name; renaming it would hide which
|
|
90
|
+
// tool actually ran, including MCP tools whose names are the useful part.
|
|
91
|
+
return detail ? `${name} ${detail}` : name;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Only states worth interrupting the reader for. `done` is the default. */
|
|
95
|
+
export function noteworthyStatus(item: CodeItem): string | null {
|
|
96
|
+
if (item.kind === 'turn_cancelled' || item.status === 'cancelled') return 'Stopped';
|
|
97
|
+
if (item.kind === 'turn_failed' || item.status === 'error') return 'Failed';
|
|
98
|
+
if (item.status === 'running') return 'Running';
|
|
99
|
+
if (item.status === 'pending') return 'Pending';
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Composer dictation over the existing `/api/voice` endpoint.
|
|
5
|
+
*
|
|
6
|
+
* The route already transcribes an uploaded blob; `x-stt-only` makes it return
|
|
7
|
+
* the text instead of submitting it as a chat message, which is what a composer
|
|
8
|
+
* needs. Nothing here is speculative: if the browser cannot record, the control
|
|
9
|
+
* reports why and stays disabled rather than pretending to listen.
|
|
10
|
+
*/
|
|
11
|
+
export type DictationStatus = 'unsupported' | 'idle' | 'recording' | 'transcribing';
|
|
12
|
+
|
|
13
|
+
export interface Dictation {
|
|
14
|
+
status: DictationStatus;
|
|
15
|
+
/** Why the control is disabled, or what pressing it will do. */
|
|
16
|
+
reason: string;
|
|
17
|
+
error: string | null;
|
|
18
|
+
toggle(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pickMime(): string {
|
|
22
|
+
if (typeof MediaRecorder === 'undefined') return '';
|
|
23
|
+
for (const candidate of ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus']) {
|
|
24
|
+
if (MediaRecorder.isTypeSupported(candidate)) return candidate;
|
|
25
|
+
}
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extensionFor(mime: string): string {
|
|
30
|
+
if (mime.includes('mp4')) return '.mp4';
|
|
31
|
+
if (mime.includes('ogg')) return '.ogg';
|
|
32
|
+
return '.webm';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function describe(error: unknown): string {
|
|
36
|
+
const name = (error as DOMException | undefined)?.name;
|
|
37
|
+
if (name === 'NotAllowedError') return 'Microphone access was denied.';
|
|
38
|
+
if (name === 'NotFoundError') return 'No microphone was found.';
|
|
39
|
+
if (name === 'NotReadableError' || name === 'AbortError') return 'The microphone is in use by another application.';
|
|
40
|
+
return error instanceof Error ? error.message : 'Dictation failed.';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useDictation(onText: (text: string) => void, disabled: boolean): Dictation {
|
|
44
|
+
const [status, setStatus] = useState<DictationStatus>('unsupported');
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
const recorder = useRef<MediaRecorder | null>(null);
|
|
47
|
+
const stream = useRef<MediaStream | null>(null);
|
|
48
|
+
const chunks = useRef<Blob[]>([]);
|
|
49
|
+
const live = useRef(true);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
// Capability is checked, never assumed: a plain-HTTP origin other than
|
|
53
|
+
// localhost has no mediaDevices at all.
|
|
54
|
+
const supported = typeof MediaRecorder !== 'undefined'
|
|
55
|
+
&& typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
|
56
|
+
setStatus(supported ? 'idle' : 'unsupported');
|
|
57
|
+
return () => {
|
|
58
|
+
live.current = false;
|
|
59
|
+
recorder.current?.state === 'recording' && recorder.current.stop();
|
|
60
|
+
stream.current?.getTracks().forEach(track => track.stop());
|
|
61
|
+
};
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const transcribe = useCallback(async (blob: Blob, mime: string) => {
|
|
65
|
+
setStatus('transcribing');
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch('/api/voice', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': mime || 'audio/webm', 'X-Voice-Ext': extensionFor(mime), 'X-Stt-Only': 'true' },
|
|
70
|
+
body: blob,
|
|
71
|
+
});
|
|
72
|
+
const payload = await response.json().catch(() => null) as { text?: string; error?: string } | null;
|
|
73
|
+
if (!response.ok) throw new Error(payload?.error || `Transcription failed (${response.status}).`);
|
|
74
|
+
const text = payload?.text?.trim();
|
|
75
|
+
if (!live.current) return;
|
|
76
|
+
if (text) onText(text);
|
|
77
|
+
else setError('Nothing was transcribed.');
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (live.current) setError(describe(err));
|
|
80
|
+
} finally {
|
|
81
|
+
if (live.current) setStatus('idle');
|
|
82
|
+
}
|
|
83
|
+
}, [onText]);
|
|
84
|
+
|
|
85
|
+
const toggle = useCallback(() => {
|
|
86
|
+
if (status === 'unsupported' || status === 'transcribing' || disabled) return;
|
|
87
|
+
if (status === 'recording') {
|
|
88
|
+
recorder.current?.stop();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
setError(null);
|
|
92
|
+
void (async () => {
|
|
93
|
+
let captured: MediaStream;
|
|
94
|
+
try { captured = await navigator.mediaDevices.getUserMedia({ audio: true }); }
|
|
95
|
+
catch (err) { setError(describe(err)); return; }
|
|
96
|
+
if (!live.current) { captured.getTracks().forEach(track => track.stop()); return; }
|
|
97
|
+
stream.current = captured;
|
|
98
|
+
const mime = pickMime();
|
|
99
|
+
let active: MediaRecorder;
|
|
100
|
+
try { active = new MediaRecorder(captured, mime ? { mimeType: mime } : {}); }
|
|
101
|
+
catch (err) {
|
|
102
|
+
captured.getTracks().forEach(track => track.stop());
|
|
103
|
+
setError(describe(err));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
recorder.current = active;
|
|
107
|
+
chunks.current = [];
|
|
108
|
+
active.ondataavailable = event => { if (event.data.size > 0) chunks.current.push(event.data); };
|
|
109
|
+
active.onerror = () => { setError('Recording failed.'); };
|
|
110
|
+
active.onstop = () => {
|
|
111
|
+
captured.getTracks().forEach(track => track.stop());
|
|
112
|
+
stream.current = null;
|
|
113
|
+
recorder.current = null;
|
|
114
|
+
const blob = new Blob(chunks.current, { type: mime || 'audio/webm' });
|
|
115
|
+
chunks.current = [];
|
|
116
|
+
if (!live.current) return;
|
|
117
|
+
if (blob.size === 0) { setStatus('idle'); setError('Nothing was recorded.'); return; }
|
|
118
|
+
void transcribe(blob, mime);
|
|
119
|
+
};
|
|
120
|
+
active.start();
|
|
121
|
+
setStatus('recording');
|
|
122
|
+
})();
|
|
123
|
+
}, [disabled, status, transcribe]);
|
|
124
|
+
|
|
125
|
+
const reason = status === 'unsupported'
|
|
126
|
+
? 'Dictation needs microphone access, which this browser context does not provide.'
|
|
127
|
+
: status === 'recording' ? 'Stop dictation'
|
|
128
|
+
: status === 'transcribing' ? 'Transcribing…' : 'Dictation';
|
|
129
|
+
return { status, reason, error, toggle };
|
|
130
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { ThemeSwitch } from './ThemeSwitch';
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
3
2
|
import { isElectron } from '../panels/desktop-bridge';
|
|
4
3
|
|
|
5
4
|
type CommandCenterProps = {
|
|
@@ -11,12 +10,9 @@ type CommandCenterProps = {
|
|
|
11
10
|
|
|
12
11
|
export function CommandCenter(props: CommandCenterProps) {
|
|
13
12
|
const electron = isElectron();
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
const actions =
|
|
17
|
-
? cloneElement(props.actions, {}, Children.toArray(props.actions.props.children).flatMap<ReactNode>(child =>
|
|
18
|
-
isValidElement(child) && child.type === ThemeSwitch ? [<span key="settings" id="command-settings-slot" />, child] : [child]))
|
|
19
|
-
: props.actions;
|
|
13
|
+
// The settings portal slot existed only for the workbench gear, which is gone: the rail
|
|
14
|
+
// owns settings now. Actions pass through untouched.
|
|
15
|
+
const actions = props.actions;
|
|
20
16
|
return (
|
|
21
17
|
<div className={`command-center command-bar${electron ? ' is-electron-titlebar' : ''}`}>
|
|
22
18
|
<div className="command-primary">
|
|
@@ -4,7 +4,6 @@ import type {
|
|
|
4
4
|
DashboardRegistryInstance,
|
|
5
5
|
DashboardScanResult,
|
|
6
6
|
} from '../types';
|
|
7
|
-
import { SettingsShell } from '../settings/SettingsShell';
|
|
8
7
|
import { ProcessControlPanel } from './ProcessControlPanel';
|
|
9
8
|
import { InstanceLogsPanel } from './InstanceLogsPanel';
|
|
10
9
|
|
|
@@ -13,8 +12,6 @@ type InstanceDetailPanelProps = {
|
|
|
13
12
|
data: DashboardScanResult | null;
|
|
14
13
|
activeTab: DashboardDetailTab;
|
|
15
14
|
onRegistryPatch: (port: number, patch: Partial<DashboardRegistryInstance>) => void;
|
|
16
|
-
onSettingsDirtyChange?: (dirty: boolean) => void;
|
|
17
|
-
onSettingsSaved?: () => void;
|
|
18
15
|
};
|
|
19
16
|
|
|
20
17
|
export function InstanceDetailPanel(props: InstanceDetailPanelProps) {
|
|
@@ -47,21 +44,6 @@ export function InstanceDetailPanel(props: InstanceDetailPanelProps) {
|
|
|
47
44
|
</div>
|
|
48
45
|
)}
|
|
49
46
|
|
|
50
|
-
{props.activeTab === 'settings' && instance && (
|
|
51
|
-
<SettingsShell
|
|
52
|
-
key={instance.port}
|
|
53
|
-
port={instance.port}
|
|
54
|
-
instanceUrl={instance.url}
|
|
55
|
-
{...(props.onSettingsDirtyChange !== undefined ? { onDirtyChange: props.onSettingsDirtyChange } : {})}
|
|
56
|
-
{...(props.onSettingsSaved !== undefined ? { onSaved: props.onSettingsSaved } : {})}
|
|
57
|
-
/>
|
|
58
|
-
)}
|
|
59
|
-
|
|
60
|
-
{props.activeTab === 'settings' && !instance && (
|
|
61
|
-
<div className="detail-empty">
|
|
62
|
-
Select an instance to configure it.
|
|
63
|
-
</div>
|
|
64
|
-
)}
|
|
65
47
|
</section>
|
|
66
48
|
);
|
|
67
49
|
}
|