@bivy/bivy 0.17.3-staging.9 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,6 +116,10 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
116
116
  }
117
117
  .menu-item:hover, .menu-item[aria-checked="true"], .menu-item.active { background: var(--surface-2); }
118
118
  .menu-item.danger { color: var(--danger); }
119
+ .menu-item-label { flex: 1; min-width: 0; }
120
+ .menu-item.separated { margin-top: var(--space-1); border-top: 1px solid var(--line); border-radius: 0 0 var(--radius-sm) var(--radius-sm); }
121
+ /* A section title inside a menu (e.g. "Preview cards" above its choices). */
122
+ .menu-heading { padding: var(--space-2) var(--space-3) var(--space-1); color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: 0.04em; text-transform: uppercase; }
119
123
 
120
124
  /* ---- Feedback ---- */
121
125
  .toast {
@@ -588,6 +592,12 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
588
592
  discoverability pattern as .row-menu-btn — and a touch-friendly baseline
589
593
  opacity on devices with no hover. */
590
594
  .assistant-row { align-self: stretch; width: 100%; max-width: 680px; }
595
+ /* The message "Show in chat" landed on (Artifacts / Apps pages). */
596
+ .is-jump-target { border-radius: var(--radius-md); animation: jump-target 2.4s var(--ease-standard); }
597
+ @keyframes jump-target {
598
+ 0%, 50% { background: color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: 0 0 0 var(--space-2) color-mix(in srgb, var(--accent) 10%, transparent); }
599
+ 100% { background: transparent; box-shadow: 0 0 0 var(--space-2) transparent; }
600
+ }
591
601
  .msg-actions { display: flex; align-items: center; gap: 2px; margin-top: var(--space-1); margin-left: -6px; }
592
602
  .msg-copy-btn, .msg-speak-btn {
593
603
  border: 0; background: transparent; color: var(--muted);
@@ -1271,6 +1281,7 @@ button.sheet-action:last-child { border-bottom: 0; }
1271
1281
  .fork-select-field select { width: 100%; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: var(--radius-md); background: var(--surface); color: var(--ink); }
1272
1282
  .fork-toggle { display: flex; align-items: center; gap: 8px; padding: 10px 12px; font-size: 13px; color: var(--ink); }
1273
1283
  .fork-toggle input { width: 16px; height: 16px; }
1284
+ .fork-note { margin: 0; padding: var(--space-2) var(--space-3); color: var(--muted); font-size: var(--text-sm); line-height: var(--lh-sm); }
1274
1285
  .fork-error { margin: 4px 12px; padding: 8px 10px; border-radius: var(--radius-md); background: var(--surface-2); color: var(--danger); font-size: 12px; }
1275
1286
  .fork-warn { margin: 4px 12px; padding: 8px 10px; border-radius: var(--radius-md); background: var(--surface-2); color: var(--muted); font-size: 12px; }
1276
1287
  .fork-submit { width: calc(100% - 24px); margin: var(--space-2) var(--space-3); }
@@ -1395,7 +1406,12 @@ button.sheet-action:last-child { border-bottom: 0; }
1395
1406
  font-weight: var(--weight-semibold); cursor: pointer; white-space: nowrap;
1396
1407
  }
1397
1408
  .seg-btn:hover { color: var(--ink); }
1398
- .seg-btn[aria-selected="true"] { background: var(--surface); color: var(--accent); box-shadow: var(--shadow-sm); }
1409
+ .seg-btn[aria-selected="true"], .seg-btn[aria-checked="true"] { background: var(--surface); color: var(--accent); box-shadow: var(--shadow-sm); }
1410
+ /* Chat | Terminal in the session topbar (SessionViewToggle). */
1411
+ .topbar-subline { display: flex; align-items: center; gap: var(--space-3); min-width: 0; margin-top: 2px; }
1412
+ .topbar-subline .node-switcher { flex: 0 1 auto; }
1413
+ .session-view-toggle { flex: none; padding: 2px; }
1414
+ .session-view-toggle .seg-btn { display: inline-flex; align-items: center; gap: var(--space-1); padding: 2px var(--space-2); line-height: 1.25; }
1399
1415
  .automations-tabs {
1400
1416
  flex: none; align-self: center; max-width: calc(100% - var(--space-6));
1401
1417
  margin: var(--space-2) auto 0; overflow-x: auto;
@@ -1411,6 +1427,51 @@ button.sheet-action:last-child { border-bottom: 0; }
1411
1427
  .automations-view-head-actions > .btn.primary { padding: 9px 18px; }
1412
1428
  .automations-view-body { padding: 8px 24px max(48px, env(safe-area-inset-bottom)); }
1413
1429
  }
1430
+ /* Artifacts / Apps pages (LibraryView.tsx) share the Automations page shell:
1431
+ a quiet image grid for files, a plain list for apps. */
1432
+ .library-view:focus { outline: none; }
1433
+ .library-view .automations-view-head { width: 100%; max-width: 1080px; margin: 0 auto; box-sizing: border-box; }
1434
+ .library-body { max-width: 1080px; }
1435
+ .library-toolbar { display: flex; margin: var(--space-2) 0 var(--space-4); }
1436
+ .library-loading { display: flex; justify-content: center; padding: var(--space-8) 0; }
1437
+ .library-body > .banner { margin: var(--space-2) 0; }
1438
+ .library-note { margin: var(--space-2) 0; font-size: var(--text-sm); color: var(--muted); }
1439
+ .library-grid {
1440
+ list-style: none; margin: var(--space-2) 0 0; padding: 0;
1441
+ display: grid; gap: var(--space-5) var(--space-3);
1442
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
1443
+ }
1444
+ .library-tile { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
1445
+ .library-tile-preview {
1446
+ width: 100%; aspect-ratio: 4 / 3; padding: 0; overflow: hidden;
1447
+ display: grid; place-items: center;
1448
+ border: 1px solid var(--line); border-radius: var(--radius-lg);
1449
+ background: var(--surface-2); color: var(--muted); font: inherit; cursor: pointer;
1450
+ transition: border-color var(--dur-fast) var(--ease-standard);
1451
+ }
1452
+ .library-tile-preview:hover:not(:disabled) { border-color: var(--line-strong); }
1453
+ .library-tile-preview:disabled { cursor: progress; }
1454
+ .library-tile-preview img { display: block; width: 100%; height: 100%; object-fit: cover; }
1455
+ .library-tile-file { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); }
1456
+ .library-tile-ext { font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: 0.04em; }
1457
+ .library-tile-caption { display: flex; flex-direction: column; gap: 2px; min-width: 0; padding: 0 2px; }
1458
+ .library-tile-name {
1459
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1460
+ font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--ink);
1461
+ }
1462
+ .library-tile-note { font-size: var(--text-xs); color: var(--danger); }
1463
+ .library-source {
1464
+ align-self: flex-start; display: inline-flex; align-items: center; gap: var(--space-1);
1465
+ max-width: 100%; min-width: 0; min-height: 28px; padding: 0; border: 0; background: transparent;
1466
+ color: var(--muted); font: inherit; font-size: var(--text-xs); text-align: left; cursor: pointer;
1467
+ }
1468
+ .library-source:hover { color: var(--ink); }
1469
+ .library-source svg, .library-source-when { flex: none; }
1470
+ .library-source-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1471
+ @media (min-width: 640px) {
1472
+ .library-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--space-6) var(--space-4); }
1473
+ }
1474
+
1414
1475
  .autom-section { margin-top: 26px; }
1415
1476
  .autom-section-head {
1416
1477
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
@@ -2445,6 +2506,12 @@ textarea.picker-search { resize: vertical; font-family: ui-monospace, monospace;
2445
2506
  height: var(--app-h, 100dvh); z-index: var(--z-sheet); display: flex; flex-direction: column;
2446
2507
  background: var(--bg); padding-top: env(safe-area-inset-top);
2447
2508
  }
2509
+ /* The same terminal inside the session pane (Chat | Terminal toggle): in the
2510
+ main column's flow under the topbar, not a fixed full-screen layer. */
2511
+ .term-overlay.term-embedded {
2512
+ position: relative; top: auto; left: auto; width: auto; height: auto;
2513
+ flex: 1 1 auto; min-height: 0; z-index: auto; padding-top: 0;
2514
+ }
2448
2515
  .term-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); }
2449
2516
  .term-title { font-weight: 700; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2450
2517
  .term-status { color: var(--muted); font-weight: 400; margin-left: 8px; font-size: 12px; }
@@ -2492,6 +2559,10 @@ textarea.picker-search { resize: vertical; font-family: ui-monospace, monospace;
2492
2559
  }
2493
2560
  .term-out .xterm { height: 100%; }
2494
2561
  .term-out .xterm-viewport { overflow-y: auto; overscroll-behavior: contain; }
2562
+ /* xterm.css paints the viewport #000; the GPU renderers cover it with the theme
2563
+ background, the DOM fallback does not — a light-theme sliver below the last
2564
+ row. Match readTheme()'s background. */
2565
+ .term-out .xterm .xterm-viewport { background-color: var(--bg); }
2495
2566
  /* Smooth momentum scrolling for the terminal viewport on touch devices. */
2496
2567
  .term-out .xterm-viewport { -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
2497
2568
 
@@ -2834,6 +2905,8 @@ select.picker-search { appearance: auto; }
2834
2905
  .setup-card p { color: var(--ink); margin: 8px 0; }
2835
2906
  .setup-card .muted { color: var(--muted); font-size: 13px; }
2836
2907
  .setup-foot { margin-top: 18px; }
2908
+ .setup-foot a { color: var(--accent); }
2909
+ .setup-foot a:hover { color: var(--accent-hover); }
2837
2910
  .btn.block { display: block; width: 100%; text-align: center; text-decoration: none; margin-top: 12px; padding: 11px; }
2838
2911
  .setup-or { color: var(--muted); font-size: 13px; margin: 16px 0 8px; }
2839
2912
  .setup-email { display: flex; flex-direction: column; gap: 8px; }
@@ -2842,6 +2915,9 @@ select.picker-search { appearance: auto; }
2842
2915
  .setup-email .btn { width: 100%; white-space: nowrap; min-height: var(--space-7); }
2843
2916
  .setup-note { margin-top: 10px; word-break: break-word; }
2844
2917
  .setup-note .btn { min-height: var(--space-7); }
2918
+ /* Inline text actions flow with the sentence: negative block margins keep the
2919
+ 44px touch target from stretching the paragraph's line box. */
2920
+ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2); padding-block: 0; font: inherit; vertical-align: middle; }
2845
2921
  .setup-error { margin: 8px 0 4px; padding: 10px 12px; border: 1px solid var(--danger); border-left-width: 3px; border-radius: var(--radius-md); background: var(--danger-soft, transparent); color: var(--ink); font-size: 13px; line-height: 1.4; text-align: left; }
2846
2922
  /* Setup / connect surfaces prefer a little vertical rhythm on inline banners. */
2847
2923
  .setup-card .banner.inline,
@@ -2852,6 +2928,9 @@ select.picker-search { appearance: auto; }
2852
2928
  .scrim { position: fixed; inset: 0; background: var(--overlay); z-index: var(--z-dropdown); }
2853
2929
  @media (max-width: 900px) {
2854
2930
  .app { grid-template-columns: 1fr; }
2931
+ /* Share the subtitle line with the machine name: icons only (the accessible
2932
+ name stays via aria-label). */
2933
+ .session-view-label { display: none; }
2855
2934
  .only-mobile { display: inline-flex; }
2856
2935
  .sidebar {
2857
2936
  position: fixed; top: var(--app-top, 0px); left: 0; height: var(--app-h, 100dvh);
@@ -2953,18 +3032,86 @@ select.picker-search { appearance: auto; }
2953
3032
 
2954
3033
  /* Artifacts sheet (components/ArtifactsSheet.tsx) — agent-sent attachments,
2955
3034
  user uploads, and resolved inline images grouped by kind. */
2956
- .app-views-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
2957
- .app-views-heading .artifact-name, .app-message .artifact-name { white-space: normal; overflow-wrap: anywhere; }
2958
- .app-view-row { flex-wrap: wrap; }
2959
- .app-views-heading > .btn, .app-view-actions .btn { min-block-size: var(--space-7); flex-shrink: 0; }
2960
- .app-view-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); margin-inline-start: auto; }
2961
- .app-view-notice { display: flex; flex-direction: column; gap: var(--space-2); flex-basis: 100%; min-width: 0; }
2962
- .app-view-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1); font-size: var(--text-sm); overflow-wrap: anywhere; }
3035
+ /* An app, a view of it, or a server that could become one (AppRow.tsx): the
3036
+ same tile, type and trailing action in the chat card, the Apps page and the
3037
+ Apps sheet. Views nest inside their app's card as small rows. */
3038
+ .apps-card { border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
3039
+ .apps-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
3040
+ .app-row { display: flex; align-items: center; gap: var(--space-3); min-width: 0; padding: var(--space-3); }
3041
+ .app-tile { flex: none; display: grid; place-items: center; inline-size: 40px; block-size: 40px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-2); color: var(--ink); font-weight: var(--weight-semibold); }
3042
+ .app-row.small .app-tile { inline-size: 36px; block-size: 36px; border-radius: var(--radius-sm); color: var(--muted); }
3043
+ .app-tile-port { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--ok); }
3044
+ .app-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
3045
+ .app-row-name { font-size: var(--text-base); font-weight: var(--weight-semibold); }
3046
+ .app-row.small .app-row-name { font-size: var(--text-sm); }
3047
+ .app-row-meta { color: var(--muted); font-size: var(--text-xs); line-height: var(--lh-xs); }
3048
+ .app-row-action { flex: none; display: flex; align-items: center; gap: var(--space-1); }
3049
+ .app-message { margin-block: var(--space-2); }
3050
+ /* Review card (ReviewCard.tsx): the app at a moment worth judging. The
3051
+ screenshot is shown whole, phone-shaped, on a quiet stage; Before/Now is the
3052
+ canonical .segmented switch; one primary action. */
3053
+ .review-card { margin-block: var(--space-2); max-inline-size: 440px; }
3054
+ .review-stage { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); margin: 0 var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: var(--surface-2); }
3055
+ .review-shot { display: grid; place-items: center; padding: 0; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); overflow: hidden; cursor: zoom-in; box-shadow: var(--shadow-sm); }
3056
+ .review-shot img { display: block; inline-size: auto; block-size: auto; max-inline-size: 100%; max-block-size: min(300px, 40vh); }
3057
+ .review-loading { display: grid; place-items: center; inline-size: 139px; block-size: min(300px, 40vh); max-inline-size: 100%; }
3058
+ .review-gone { margin: 0; padding: var(--space-6) var(--space-3); color: var(--muted); font-size: var(--text-sm); text-align: center; }
3059
+ .review-side .seg-btn { min-block-size: 44px; min-inline-size: 72px; }
3060
+ .review-banner { margin: 0 var(--space-3); }
3061
+ .review-note { margin: var(--space-3) var(--space-3) 0; padding-inline-start: var(--space-3); border-inline-start: 2px solid var(--line-strong); color: var(--ink); font-size: var(--text-sm); white-space: pre-wrap; }
3062
+ .review-status { margin: var(--space-2) var(--space-3) 0; color: var(--muted); font-size: var(--text-sm); }
3063
+ .review-actions { display: flex; padding: var(--space-3); }
3064
+ .review-actions .btn { flex: 1; min-block-size: 44px; }
3065
+
3066
+ /* Apps sheet (AppsSheet.tsx): one card per app, its views nested, one primary
3067
+ action per view; secondary tools as quiet icon buttons; rare or destructive
3068
+ actions behind ⋯ (MoreMenu, the canonical .menu). Status uses .banner. */
3069
+ .apps-sheet { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-2) var(--space-2) var(--space-4); }
3070
+ .apps-state { display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: var(--space-6) 0; color: var(--muted); font-size: var(--text-sm); }
3071
+ .apps-group { display: flex; flex-direction: column; gap: var(--space-2); }
3072
+ .apps-group-title { margin: 0; padding: 0 var(--space-1); color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: 0.04em; text-transform: uppercase; }
3073
+ .apps-view { display: flex; flex-direction: column; border-top: 1px solid var(--line); }
3074
+ .apps-cmd { display: block; margin: 0 var(--space-3) var(--space-3); padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); background: var(--surface-2); color: var(--ink); font-family: var(--font-mono); font-size: var(--text-xs); white-space: pre-wrap; overflow-wrap: anywhere; }
3075
+ .apps-view-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 0; padding: 0 var(--space-1) var(--space-1); }
3076
+ .apps-view-tools .btn { gap: 6px; padding-inline: var(--space-2); color: var(--muted); }
3077
+ .apps-view-tools .btn:hover:not(:disabled) { color: var(--ink); }
3078
+ .apps-more { position: relative; }
3079
+ .apps-view-tools .apps-more { margin-inline-start: auto; }
3080
+ .apps-more-menu { position: absolute; right: 0; top: calc(100% + var(--space-1)); z-index: var(--z-dropdown); }
3081
+ .apps-notice { display: flex; align-items: flex-start; gap: var(--space-2); margin: 0 var(--space-3) var(--space-3); padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); background: color-mix(in srgb, var(--ok) 12%, transparent); font-size: var(--text-sm); }
3082
+ .apps-notice > svg { flex: none; margin-top: 1px; color: var(--ok); }
3083
+ .apps-notes { display: flex; flex-direction: column; gap: var(--space-2); margin: 0 var(--space-3) var(--space-3); padding-top: var(--space-3); border-top: 1px dashed var(--line); }
3084
+ .apps-notes-head { display: flex; align-items: center; gap: var(--space-2); color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: 0.04em; text-transform: uppercase; }
3085
+ .apps-count { min-inline-size: 18px; padding: 0 6px; border-radius: var(--radius-full); background: var(--surface-3); color: var(--ink); text-align: center; letter-spacing: 0; }
3086
+ .apps-notes ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
3087
+ .apps-note { display: flex; flex-direction: column; gap: 2px; padding-inline-start: var(--space-3); border-inline-start: 2px solid var(--line-strong); }
3088
+ .apps-note-text { color: var(--ink); font-size: var(--text-sm); }
3089
+ .apps-notes-actions { display: flex; gap: var(--space-2); }
3090
+ .apps-empty { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); padding: var(--space-6) var(--space-4); color: var(--muted); font-size: var(--text-sm); text-align: center; }
3091
+ .apps-empty strong { color: var(--ink); font-size: var(--text-base); }
3092
+ .apps-empty p { margin: 0; max-inline-size: 34ch; }
3093
+ .apps-foot { margin: 0; padding: 0 var(--space-1); color: var(--muted); font-size: var(--text-xs); line-height: var(--lh-xs); }
2963
3094
  /* Peek: an app preview framed in a drawer over the chat (PreviewPeek.tsx). */
2964
3095
  .preview-peek { display: block; inline-size: 100%; block-size: 100%; border: 0; border-radius: var(--radius-md); background: var(--bg); }
3096
+ /* Share-sheet landing (ShareDestinationSheet.tsx): what was shared, then one
3097
+ row per destination — AppRow, with the session's app preview when it has one. */
3098
+ .share-sheet { display: flex; flex-direction: column; gap: var(--space-3); }
3099
+ .share-shared { display: flex; flex-direction: column; gap: var(--space-2); margin: 0; }
3100
+ .share-thumbs { display: flex; gap: var(--space-2); overflow-x: auto; }
3101
+ .share-thumb { flex: none; block-size: 96px; inline-size: auto; max-inline-size: 160px; object-fit: cover; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-2); }
3102
+ .share-dests { display: flex; flex-direction: column; gap: var(--space-1); }
3103
+ .share-dest { display: block; inline-size: 100%; padding: 0; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); color: inherit; font: inherit; text-align: start; cursor: pointer; }
3104
+ .share-dest:hover { background: var(--surface-2); }
3105
+ .share-dest .app-row { min-block-size: 44px; }
3106
+ .preview-peek-stage { position: relative; block-size: 100%; }
3107
+ /* Point and speak: the recording bar floats over the top of the preview while
3108
+ the finger stays on the shell's mic, so nothing under it moves. */
3109
+ /* Draw: making the marked-up picture, or the choice when screenshots are off. */
3110
+ .preview-marking { position: absolute; inset-inline: var(--space-2); bottom: var(--space-2); z-index: var(--z-sticky); box-shadow: var(--shadow-lg); border-radius: var(--radius-md); background: var(--surface); }
3111
+ .preview-marking .banner { margin: 0; }
3112
+ .preview-marking .banner-actions { flex-wrap: wrap; }
3113
+ .preview-voice { position: absolute; inset-inline: var(--space-2); top: var(--space-2); z-index: var(--z-sticky); padding: var(--space-1) var(--space-2); border: 1px solid var(--line-strong); border-radius: var(--radius-full); background: var(--surface); box-shadow: var(--shadow-lg); }
2965
3114
  .app-terminal-status { padding: var(--space-2) var(--space-4); margin: 0; color: var(--muted); font-size: var(--text-sm); }
2966
- .app-view-command { overflow-wrap: anywhere; white-space: pre-wrap; font-size: var(--text-xs); color: var(--muted); }
2967
- .app-view-row .artifact-main { flex-basis: 60%; }
2968
3115
  .artifacts-group { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
2969
3116
  .artifacts-group:last-child { margin-bottom: 0; }
2970
3117
  .artifacts-group-title { font-size: 12px; font-weight: 600; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.04em; }
@@ -3201,3 +3348,8 @@ select.picker-search { appearance: auto; }
3201
3348
  .automation-history-view .autom-section-head { align-items: flex-start; gap: var(--space-3); }
3202
3349
  .automation-history-view .autom-section-head > strong { min-width: 0; line-height: var(--lh-lg); }
3203
3350
  }
3351
+
3352
+ /* Handoff notes above the composer (HandoffBanner, StandbyNotice): shaped like
3353
+ the composer itself, with one line of another device's draft. */
3354
+ .handoff-banner { border-radius: var(--radius-md); }
3355
+ .handoff-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -62,6 +62,12 @@
62
62
  --s-github: #24292f; --s-slack: #7c2d92; --s-linear: #5e6ad2; --s-schedule: #0e7490;
63
63
  --s-webhook: #6d28d9; --s-manual: #b45309; --s-cli: #3f6212; --s-app: #64748b;
64
64
 
65
+ /* ---- Annotation: marks drawn over an app preview. The same in both themes
66
+ (the app underneath has its own), vivid and rare in real UIs, with a white
67
+ halo so it reads on light and dark pages. The node composites it too. ---- */
68
+ --annotate: #ff2d78;
69
+ --annotate-halo: #ffffff;
70
+
65
71
  /* ---- Terminal text selection ---- */
66
72
  --term-selection: rgba(63, 99, 119, 0.30);
67
73
  --term-selection-inactive: rgba(63, 99, 119, 0.20);
@@ -1,5 +1,22 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
+ export const REVIEW_CARD_MODES = ["ready", "every", "off"];
4
+ export const APP_REVIEW_BLOCK = "bivy_app_review";
5
+ const isShot = (value) => {
6
+ const shot = value;
7
+ return !!shot && typeof shot.hash === "string" && /^[0-9a-f]{64}$/.test(shot.hash) && [shot.size, shot.width, shot.height].every((n) => typeof n === "number" && n >= 0);
8
+ };
9
+ export function isAppReview(value) {
10
+ if (!value || typeof value !== "object")
11
+ return false;
12
+ const review = value;
13
+ return typeof review.id === "string" && typeof review.sessionId === "string" && typeof review.appId === "string" && /^[a-f0-9]{32}$/.test(review.appId)
14
+ && typeof review.viewId === "string" && typeof review.name === "string" && typeof review.view === "string" && typeof review.path === "string"
15
+ && (review.trigger === "present" || review.trigger === "run" || review.trigger === "asked" || review.trigger === "notes") && typeof review.at === "number"
16
+ && (review.shot === undefined || isShot(review.shot)) && (review.before === undefined || isShot(review.before))
17
+ && (review.note === undefined || typeof review.note === "string")
18
+ && (review.notes === undefined || (Number.isInteger(review.notes) && review.notes >= 0));
19
+ }
3
20
  export const APP_PUBLICATION_BLOCK = "bivy_app";
4
21
  export function isAppReference(value) {
5
22
  if (!value || typeof value !== "object")
package/dist/attach.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
+ import { hostname } from "node:os";
3
4
  import { StringDecoder } from "node:string_decoder";
4
5
  import { WebSocket } from "ws";
5
6
  /**
@@ -50,6 +51,9 @@ const TTY_MODE_RESET = "\x1b[?1049l" + // leave the alternate screen
50
51
  "\x1b[?1l\x1b>" + // cursor keys + keypad back to normal mode
51
52
  "\x1b[?25h" + // cursor visible
52
53
  "\x1b[0m"; // reset colors/attributes
54
+ /** How this terminal names itself to the node, so other devices can say where a
55
+ * session was last driven ("Terminal on my-laptop"). */
56
+ const DEVICE = { id: `terminal:${hostname()}`, label: `Terminal on ${hostname()}` };
53
57
  function parseArgs(argv) {
54
58
  let url = process.env.BIVY_URL || `http://localhost:${process.env.PORT || "4317"}`;
55
59
  let token = process.env.BIVY_DEVICE_TOKEN || undefined;
@@ -102,6 +106,9 @@ function bridge(args, open) {
102
106
  let bound = false;
103
107
  let detachArmed = 0;
104
108
  let settled = false;
109
+ // Set while the session this terminal showed is continuing as a chat in the
110
+ // app: the next key decides whether to take it back here or leave.
111
+ let handedToChat;
105
112
  const inputDecoder = new StringDecoder("utf8");
106
113
  const stdin = process.stdin;
107
114
  const wasRaw = stdin.isTTY ? stdin.isRaw : false;
@@ -144,6 +151,21 @@ function bridge(args, open) {
144
151
  socket.send(JSON.stringify(payload));
145
152
  };
146
153
  const onInput = (data) => {
154
+ if (handedToChat) {
155
+ const sessionId = handedToChat;
156
+ handedToChat = undefined;
157
+ if (data[0] !== 0x0d && data[0] !== 0x0a) {
158
+ finish(0);
159
+ return;
160
+ }
161
+ // Reopen the chat's own TUI, resuming the same conversation; the node
162
+ // locks the chat again and every device sees the handoff.
163
+ process.stdout.write(c.dim("Taking it back…\r\n"));
164
+ modesReset = false;
165
+ const { cols, rows } = termSize();
166
+ send({ kind: "terminal.open.tui", sessionId, cols, rows });
167
+ return;
168
+ }
147
169
  // Ctrl-\ (0x1c) twice in a row detaches, leaving the daemon session alive.
148
170
  if (data.length === 1 && data[0] === 0x1c) {
149
171
  const now = Date.now();
@@ -212,6 +234,17 @@ function bridge(args, open) {
212
234
  if (typeof msg.data === "string")
213
235
  process.stdout.write(msg.data);
214
236
  }
237
+ else if (type === "terminal.closed" && msg.reason === "chat" && msg.termId === termId) {
238
+ resetModes();
239
+ termId = undefined;
240
+ process.stdout.write(c.dim("\r\n[continued as chat in the Bivy app]\r\n"));
241
+ if (typeof msg.sessionId !== "string" || !msg.sessionId) {
242
+ finish(0);
243
+ return;
244
+ }
245
+ handedToChat = msg.sessionId;
246
+ process.stdout.write(`Press ${c.bold("Enter")} to take it back here, or any other key to leave.\r\n`);
247
+ }
215
248
  else if ((type === "terminal.exit" || type === "terminal.closed") && msg.termId === termId) {
216
249
  const code = typeof msg.code === "number" ? msg.code : 0;
217
250
  resetModes();
@@ -220,7 +253,7 @@ function bridge(args, open) {
220
253
  }
221
254
  else if (type === "terminal.error") {
222
255
  process.stderr.write(c.red(`\r\n${String(msg.error || "Terminal error")}\r\n`));
223
- if (!bound)
256
+ if (!bound || !termId)
224
257
  finish(1);
225
258
  }
226
259
  });
@@ -247,7 +280,7 @@ function openDetached(args, spec, cols, rows) {
247
280
  catch { }
248
281
  resolve(code);
249
282
  };
250
- socket.on("open", () => socket.send(JSON.stringify({ kind: "terminal.open.run", agent: spec.agent, label: spec.label, name: spec.name, model: spec.model, command: spec.command, args: spec.args ?? [], workspace: spec.workspace, sessionId: spec.sessionId, cols, rows })));
283
+ socket.on("open", () => socket.send(JSON.stringify({ kind: "terminal.open.run", agent: spec.agent, label: spec.label, name: spec.name, model: spec.model, command: spec.command, args: spec.args ?? [], workspace: spec.workspace, sessionId: spec.sessionId, cols, rows, device: DEVICE })));
251
284
  socket.on("error", (err) => {
252
285
  process.stderr.write(c.red(`Connection error: ${err instanceof Error ? err.message : String(err)}\n`));
253
286
  finish(1);
@@ -283,7 +316,7 @@ async function main() {
283
316
  process.exit(await openDetached(args, spec, cols, rows));
284
317
  }
285
318
  process.stdout.write(c.dim(`Starting ${c.cyan(spec.name || spec.label || spec.agent || spec.command)} — Ctrl-\\ Ctrl-\\ to detach\r\n`));
286
- const code = await bridge(args, (ws) => ws.send(JSON.stringify({ kind: "terminal.open.run", agent: spec.agent, label: spec.label, name: spec.name, model: spec.model, command: spec.command, args: spec.args ?? [], workspace: spec.workspace, sessionId: spec.sessionId, cols, rows })));
319
+ const code = await bridge(args, (ws) => ws.send(JSON.stringify({ kind: "terminal.open.run", agent: spec.agent, label: spec.label, name: spec.name, model: spec.model, command: spec.command, args: spec.args ?? [], workspace: spec.workspace, sessionId: spec.sessionId, cols, rows, device: DEVICE })));
287
320
  process.exit(code);
288
321
  }
289
322
  // attach
@@ -297,7 +330,7 @@ async function main() {
297
330
  async function attachById(args, termId) {
298
331
  const { cols, rows } = termSize();
299
332
  process.stdout.write(c.dim(`Attaching to ${c.cyan(termId)} — Ctrl-\\ Ctrl-\\ to detach\r\n`));
300
- const code = await bridge({ ...args, termId }, (ws) => ws.send(JSON.stringify({ kind: "terminal.attach", termId, cols, rows })));
333
+ const code = await bridge({ ...args, termId }, (ws) => ws.send(JSON.stringify({ kind: "terminal.attach", termId, cols, rows, device: DEVICE })));
301
334
  process.exit(code);
302
335
  }
303
336
  main().catch((error) => {
@@ -6,7 +6,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
6
6
  return workspace;
7
7
  };
8
8
  const operations = {
9
- "apps.list": (msg) => service.list(String(msg.sessionId)),
9
+ "apps.list": (msg) => service.list(typeof msg.sessionId === "string" ? msg.sessionId : undefined),
10
10
  "apps.publish": (msg) => {
11
11
  const sessionId = String(msg.sessionId);
12
12
  const app = service.publish(sessionId, workspaceOf(sessionId), msg.manifest);
@@ -20,18 +20,30 @@ export function createAppCommands(service, workspaceFor, published = () => { })
20
20
  published(app);
21
21
  return { app };
22
22
  },
23
- "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true, typeof msg.scale === "number" ? msg.scale : undefined),
23
+ "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true, typeof msg.scale === "number" ? msg.scale : undefined, typeof msg.path === "string" ? msg.path : undefined),
24
24
  "apps.shot": (msg) => service.shot(String(msg.sessionId), typeof msg.appId === "string" ? msg.appId : undefined, { widths: msg.widths, themes: msg.themes, path: typeof msg.path === "string" ? msg.path : undefined }),
25
+ "apps.input": (msg) => service.act(String(msg.sessionId), typeof msg.target === "string" ? msg.target : undefined, msg.action),
26
+ "apps.menu": (msg) => service.menu(String(msg.sessionId), typeof msg.target === "string" ? msg.target : undefined, msg.path),
27
+ "apps.present": (msg) => service.present(String(msg.sessionId), { target: typeof msg.target === "string" ? msg.target : undefined, path: typeof msg.path === "string" ? msg.path : undefined, note: typeof msg.note === "string" ? msg.note : undefined }),
28
+ "apps.showMe": (msg) => service.present(String(msg.sessionId), { target: typeof msg.appId === "string" ? msg.appId : undefined, trigger: "asked" }),
29
+ "apps.mute": (msg) => service.mute(String(msg.sessionId)),
30
+ "apps.reviewMode": (msg) => service.setReviewMode(String(msg.sessionId), String(msg.appId), msg.mode),
31
+ "apps.annotate": (msg) => service.annotate(String(msg.sessionId), msg),
25
32
  "apps.clearNotes": (msg) => service.clearNotes(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
33
+ "apps.agentNotes": (msg) => service.setAgentNotes(String(msg.sessionId), String(msg.appId), msg.enabled === true),
34
+ "apps.notes": (msg) => service.notes(String(msg.sessionId), { app: typeof msg.appId === "string" ? msg.appId : undefined, view: typeof msg.view === "string" ? msg.view : undefined, since: typeof msg.since === "number" ? msg.since : undefined }),
26
35
  "apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
27
- "apps.share": (msg) => service.share(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
36
+ // The app UI passes exact IDs; `bivy app share` passes an app and/or view by ID or name.
37
+ "apps.share": (msg) => typeof msg.viewId === "string" && typeof msg.appId === "string"
38
+ ? service.share(String(msg.sessionId), msg.appId, msg.viewId)
39
+ : service.shareView(String(msg.sessionId), { app: typeof msg.appId === "string" ? msg.appId : undefined, view: typeof msg.view === "string" ? msg.view : undefined }),
28
40
  "apps.revoke": (msg) => service.revoke(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
29
41
  "apps.remove": (msg) => { service.remove(String(msg.sessionId), String(msg.appId)); return { ok: true }; },
30
42
  };
31
43
  return Object.fromEntries(Object.entries(operations).map(([kind, execute]) => [kind, async (msg, ctx) => {
32
44
  try {
33
45
  ctx.reply({ type: `${kind}.ok`, requestId: msg.requestId, ...await execute(msg) });
34
- if (kind === "apps.publish" || kind === "apps.adopt" || kind === "apps.remove")
46
+ if (kind === "apps.publish" || kind === "apps.adopt" || kind === "apps.remove" || kind === "apps.reviewMode" || kind === "apps.agentNotes")
35
47
  ctx.broadcast({ type: "apps.changed", sessionId: msg.sessionId });
36
48
  }
37
49
  catch (error) {
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // The machine-wide Artifacts index behind the sidebar's Artifacts page: every
5
+ // file an agent sent into a chat (`bivy attach` / `attach_to_chat`), across all
6
+ // of this machine's sessions. Built from the same event-log records attachment
7
+ // GC already sweeps, so there is no second index to keep in sync. Only what can
8
+ // still be opened is listed: the session exists and the blob is still stored.
9
+ /** Newest first, bounded like the per-session projection. */
10
+ export const MAX_MACHINE_ARTIFACTS = 500;
11
+ /** One entry per content hash — the newest send wins, so the same report sent
12
+ * to two sessions links to where it was sent last. */
13
+ export function listMachineArtifacts(deps) {
14
+ const byHash = new Map();
15
+ for (const sessionId of deps.sessionIds()) {
16
+ for (const record of deps.scan(sessionId)) {
17
+ if (record.bivyKind !== "outbound-attachment")
18
+ continue;
19
+ const { ref } = record;
20
+ const existing = byHash.get(ref.hash);
21
+ if (existing && existing.createdAt >= record.createdAt)
22
+ continue;
23
+ byHash.set(ref.hash, {
24
+ sessionId, hash: ref.hash, name: ref.name, mimeType: ref.mimeType, kind: ref.kind, size: ref.size,
25
+ ...(record.caption ? { caption: record.caption } : {}),
26
+ createdAt: record.createdAt,
27
+ artifact: Boolean(record.artifact || existing?.artifact),
28
+ });
29
+ }
30
+ }
31
+ return [...byHash.values()]
32
+ .filter((item) => deps.stored(item.hash))
33
+ .sort((a, b) => b.createdAt - a.createdAt)
34
+ .slice(0, MAX_MACHINE_ARTIFACTS);
35
+ }
36
+ export function createArtifactCommands(deps) {
37
+ return {
38
+ "artifacts.list"(msg, ctx) {
39
+ ctx.reply({ type: "artifacts.list.ok", requestId: msg.requestId, artifacts: listMachineArtifacts(deps) });
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,19 @@
1
+ import { deviceFrom } from "../session/presence.js";
2
+ /** Device handoff presence, shared by relay and HTTP: read a session's driver
3
+ * and carried-over draft, and publish this device's unsent draft. */
4
+ export function createPresenceCommands(book, publish) {
5
+ return {
6
+ "session.presence.get"(input, ctx) {
7
+ const requestId = typeof input.requestId === "string" ? input.requestId : undefined;
8
+ ctx.reply({ type: "session.presence.get.ok", requestId, presence: book.get(String(input.sessionId)) });
9
+ },
10
+ "session.presence.draft"(input, ctx) {
11
+ const requestId = typeof input.requestId === "string" ? input.requestId : undefined;
12
+ const device = deviceFrom(input.device);
13
+ if (!device)
14
+ return ctx.reply({ type: "session.presence.draft.error", httpStatus: 400, requestId, error: "Missing device" });
15
+ publish(book.draft(String(input.sessionId), device, String(input.text ?? "")));
16
+ ctx.reply({ type: "session.presence.draft.ok", requestId });
17
+ },
18
+ };
19
+ }
@@ -3,10 +3,12 @@
3
3
  /** Direct-mode adapters for operations whose canonical implementation is the
4
4
  * client command registry. Relay and HTTP now differ only in framing. */
5
5
  export const CLIENT_COMMAND_ROUTES = [
6
- ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
6
+ ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "menu", "present", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "agentNotes", "notes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
7
+ { method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
7
8
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
8
9
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
9
10
  { method: "post", path: "/api/session/pause", kind: "session.pause" },
11
+ ...["get", "draft"].map((action) => ({ method: "post", path: `/api/session/presence/${action}`, kind: `session.presence.${action}` })),
10
12
  { method: "post", path: "/api/session/resume", kind: "session.resume" },
11
13
  { method: "post", path: "/api/session/question/answer", kind: "session.question.answer" },
12
14
  ];
@@ -8,15 +8,29 @@ const optionalSession = { sessionId: Type.Optional(Type.String()) };
8
8
  * this table remains migration-compatible; adding validation is one data row. */
9
9
  export const CLIENT_COMMAND_SCHEMAS = {
10
10
  ping: Type.Object(request),
11
- "apps.list": Type.Object({ ...request, ...session }),
11
+ "apps.list": Type.Object({ ...request, ...optionalSession }),
12
+ "artifacts.list": Type.Object(request),
12
13
  "apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
13
14
  "apps.offers": Type.Object({ ...request, ...session }),
14
15
  "apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
15
- "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })) }),
16
+ "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
16
17
  "apps.shot": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()), widths: Type.Optional(Type.Array(Type.Integer(), { maxItems: 4 })), themes: Type.Optional(Type.Array(Type.Union([Type.Literal("light"), Type.Literal("dark")]), { maxItems: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
18
+ "apps.input": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), action: Type.Object({ kind: Type.String({ maxLength: 20 }) }) }),
19
+ "apps.menu": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), path: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { minItems: 1, maxItems: 8 })) }),
20
+ "apps.present": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), path: Type.Optional(Type.String({ maxLength: 2048 })), note: Type.Optional(Type.String({ maxLength: 2000 })) }),
21
+ "apps.showMe": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()) }),
22
+ "apps.mute": Type.Object({ ...request, ...session }),
23
+ "apps.reviewMode": Type.Object({ ...request, ...session, appId: Type.String(), mode: Type.Union([Type.Literal("ready"), Type.Literal("every"), Type.Literal("off")]) }),
24
+ "apps.annotate": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), path: Type.Optional(Type.String({ maxLength: 2048 })),
25
+ viewport: Type.Object({ width: Type.Number(), height: Type.Number() }), scroll: Type.Optional(Type.Object({ x: Type.Number(), y: Type.Number() })),
26
+ dpr: Type.Optional(Type.Number()), theme: Type.Optional(Type.Union([Type.Literal("light"), Type.Literal("dark")])), compare: Type.Optional(Type.Integer({ minimum: 0 })),
27
+ strokes: Type.Array(Type.Object({ tool: Type.Union([Type.Literal("pen"), Type.Literal("box")]), points: Type.Array(Type.Array(Type.Number(), { minItems: 2, maxItems: 2 }), { maxItems: 10000 }) }), { minItems: 1, maxItems: 50 }),
28
+ signals: Type.Optional(Type.Record(Type.String(), Type.Boolean())) }),
17
29
  "apps.clearNotes": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
30
+ "apps.agentNotes": Type.Object({ ...request, ...session, appId: Type.String(), enabled: Type.Boolean() }),
31
+ "apps.notes": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String({ maxLength: 200 })), view: Type.Optional(Type.String({ maxLength: 200 })), since: Type.Optional(Type.Number()) }),
18
32
  "apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
19
- "apps.share": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
33
+ "apps.share": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String({ maxLength: 200 })), viewId: Type.Optional(Type.String()), view: Type.Optional(Type.String({ maxLength: 200 })) }),
20
34
  "apps.revoke": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
21
35
  "apps.remove": Type.Object({ ...request, ...session, appId: Type.String() }),
22
36
  "credentials.native.preview": Type.Object({ ...request, label: Type.Optional(Type.String({ maxLength: 100 })) }),
@@ -28,6 +42,8 @@ export const CLIENT_COMMAND_SCHEMAS = {
28
42
  }),
29
43
  "attachment.fetch": Type.Object({ ...request, hash: Type.String() }),
30
44
  "session.pause": Type.Object(session),
45
+ "session.presence.get": Type.Object({ ...request, ...session }),
46
+ "session.presence.draft": Type.Object({ ...request, ...session, device: Type.Object({ id: Type.String({ maxLength: 100 }), label: Type.Optional(Type.String({ maxLength: 200 })) }), text: Type.String({ maxLength: 20_000 }) }),
31
47
  "session.resume": Type.Object(session),
32
48
  "session.question.answer": Type.Object({ ...optionalSession, requestId: Type.String(), answers: Type.Optional(Type.Record(Type.String(), Type.String())) }),
33
49
  "session.replay": Type.Object({ ...session, afterSeq: Type.Optional(Type.Number()) }),