@brainpilot/web 0.0.5 → 0.0.7

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 (58) hide show
  1. package/dist/assets/index-DWOsU22G.css +1 -0
  2. package/dist/assets/index-j3rGyO6m.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +6 -3
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollBehavior.test.ts +48 -0
  8. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  9. package/src/__tests__/demoConversation.test.ts +96 -0
  10. package/src/__tests__/demoReset.test.ts +24 -0
  11. package/src/__tests__/internalToolStrip.test.ts +108 -0
  12. package/src/__tests__/runningToast.test.ts +29 -0
  13. package/src/__tests__/tokenUsage.test.ts +48 -0
  14. package/src/__tests__/toolDisplay.test.ts +55 -0
  15. package/src/__tests__/traceReducer.test.ts +62 -0
  16. package/src/components/chat/MessageStream.tsx +104 -56
  17. package/src/components/chat/PromptComposer.tsx +120 -29
  18. package/src/components/chat/chatScrollMemory.ts +49 -0
  19. package/src/components/demo/DemoView.tsx +98 -29
  20. package/src/components/demo/TraceNodeModal.tsx +6 -2
  21. package/src/components/demo/demoBundle.ts +7 -2
  22. package/src/components/demo/demoReset.ts +16 -0
  23. package/src/components/session/AgentNetwork.tsx +68 -75
  24. package/src/components/session/AgentTraceViews.tsx +35 -70
  25. package/src/components/session/AnalyticsTab.tsx +58 -224
  26. package/src/components/session/TraceGraphView.tsx +36 -30
  27. package/src/components/session/TraceNodeDetail.tsx +61 -24
  28. package/src/components/session/agentNetworkShared.ts +10 -0
  29. package/src/components/session/traceLayout.ts +32 -0
  30. package/src/components/settings/SettingsDialog.tsx +19 -1
  31. package/src/components/shell/DesktopShell.tsx +72 -17
  32. package/src/components/sidebar/SessionList.tsx +127 -0
  33. package/src/components/sidebar/Sidebar.tsx +94 -98
  34. package/src/contexts/SSEContext.tsx +90 -1
  35. package/src/contexts/SessionContext.tsx +397 -43
  36. package/src/contexts/agentsReducer.ts +49 -0
  37. package/src/contexts/messageGroups.ts +56 -0
  38. package/src/contexts/messageReducer.ts +4 -0
  39. package/src/contexts/runningToast.ts +33 -0
  40. package/src/contexts/traceReducer.ts +62 -0
  41. package/src/contexts/turnTimer.test.ts +97 -0
  42. package/src/contexts/turnTimer.ts +108 -0
  43. package/src/contexts/useTurnTimer.ts +104 -0
  44. package/src/contracts/backend.ts +53 -2
  45. package/src/i18n/messages/analytics.ts +16 -6
  46. package/src/i18n/messages/chat.ts +26 -4
  47. package/src/i18n/messages/contexts.ts +2 -0
  48. package/src/i18n/messages/network.ts +13 -9
  49. package/src/i18n/messages/profile.ts +4 -0
  50. package/src/i18n/messages/settings.ts +4 -0
  51. package/src/i18n/messages/shell.ts +2 -0
  52. package/src/i18n/messages/trace.ts +69 -17
  53. package/src/mocks/backend.ts +7 -0
  54. package/src/styles/global.css +289 -70
  55. package/src/utils/api.ts +105 -8
  56. package/src/utils/toolDisplay.ts +74 -0
  57. package/dist/assets/index-C-8G4D4j.js +0 -448
  58. package/dist/assets/index-C501m5OS.css +0 -1
@@ -669,6 +669,33 @@ button {
669
669
  outline: none;
670
670
  }
671
671
 
672
+ /* #104: icon-only nav — square buttons, no horizontal text weight. The label
673
+ lives in a .sr-only span so the accessible name is preserved. */
674
+ .workspace-view-tabs--icon-only button {
675
+ width: 28px;
676
+ justify-content: center;
677
+ gap: 0;
678
+ padding: 0;
679
+ }
680
+
681
+ /* #134: a tab that can carry a quiet unread dot (Trace). Positioned so the dot
682
+ sits at the top-right corner of the icon without affecting layout. */
683
+ .workspace-view-tab--badged {
684
+ position: relative;
685
+ }
686
+
687
+ .workspace-view-tab__badge {
688
+ position: absolute;
689
+ top: 3px;
690
+ right: 3px;
691
+ width: 6px;
692
+ height: 6px;
693
+ border-radius: 999px;
694
+ background: var(--color-danger, #e5484d);
695
+ box-shadow: 0 0 0 2px var(--color-surface-soft);
696
+ pointer-events: none;
697
+ }
698
+
672
699
  .session-title {
673
700
  display: flex;
674
701
  min-width: 0;
@@ -691,9 +718,10 @@ button {
691
718
  white-space: nowrap;
692
719
  }
693
720
 
721
+ /* #105: id demoted to muted secondary metadata beside the real title. */
694
722
  .session-title__id {
695
- color: var(--color-text-muted);
696
- font-size: 12px;
723
+ color: var(--color-text-subtle);
724
+ font-size: 11px;
697
725
  font-family: var(--font-mono);
698
726
  }
699
727
 
@@ -858,44 +886,24 @@ button {
858
886
  border-radius: 0;
859
887
  }
860
888
 
861
- .trace-live-toggle {
889
+ /* #79: trace is live-by-SSE — a static "Live" indicator, not a poll toggle. */
890
+ .trace-live-indicator {
862
891
  display: inline-flex;
863
892
  height: 28px;
864
893
  align-items: center;
865
894
  gap: 6px;
866
- border: 0;
867
- background: transparent;
868
- color: var(--color-text-muted);
895
+ color: var(--color-text);
869
896
  padding: 0 9px;
870
897
  font-size: 12px;
871
898
  }
872
899
 
873
- .trace-live-toggle span {
900
+ .trace-live-indicator span {
874
901
  width: 7px;
875
902
  height: 7px;
876
903
  border-radius: 999px;
877
- background: var(--color-text-subtle);
878
- }
879
-
880
- .trace-live-toggle.is-active {
881
- color: var(--color-text);
882
- }
883
-
884
- .trace-live-toggle.is-active span {
885
904
  background: var(--color-success);
886
905
  }
887
906
 
888
- .trace-live-toggle:hover:not(:disabled),
889
- .trace-live-toggle:focus-visible {
890
- background: var(--color-hover);
891
- color: var(--color-text);
892
- outline: none;
893
- }
894
-
895
- .trace-live-toggle:disabled {
896
- opacity: 0.5;
897
- }
898
-
899
907
  .agent-grid {
900
908
  display: grid;
901
909
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
@@ -1168,8 +1176,8 @@ button {
1168
1176
 
1169
1177
  .trace-edge path {
1170
1178
  fill: none;
1171
- stroke: var(--color-border-strong);
1172
- stroke-width: 1.35;
1179
+ stroke: color-mix(in srgb, var(--color-accent) 55%, var(--color-border-strong));
1180
+ stroke-width: 2;
1173
1181
  marker-end: url("#trace-arrow");
1174
1182
  }
1175
1183
 
@@ -1182,6 +1190,10 @@ button {
1182
1190
  stroke: var(--color-success);
1183
1191
  }
1184
1192
 
1193
+ .trace-edge--follows path {
1194
+ stroke: color-mix(in srgb, var(--color-accent) 50%, var(--color-border-strong));
1195
+ }
1196
+
1185
1197
  .trace-edge-label rect {
1186
1198
  fill: var(--color-surface-raised);
1187
1199
  stroke: var(--color-border-strong);
@@ -1199,7 +1211,7 @@ button {
1199
1211
  }
1200
1212
 
1201
1213
  #trace-arrow path {
1202
- fill: var(--color-border-strong);
1214
+ fill: color-mix(in srgb, var(--color-accent) 55%, var(--color-border-strong));
1203
1215
  }
1204
1216
 
1205
1217
  .trace-map-node {
@@ -1512,6 +1524,13 @@ button {
1512
1524
  font-size: 11px;
1513
1525
  }
1514
1526
 
1527
+ /* #79: auto-captured (hook-driven) trace nodes get a distinct badge. */
1528
+ .trace-detail__badges span.trace-detail__badge--auto {
1529
+ border-color: color-mix(in srgb, var(--color-success) 45%, var(--color-border));
1530
+ background: color-mix(in srgb, var(--color-success) 14%, transparent);
1531
+ color: var(--color-success);
1532
+ }
1533
+
1515
1534
  .trace-detail__section {
1516
1535
  display: grid;
1517
1536
  gap: 8px;
@@ -1622,6 +1641,37 @@ button {
1622
1641
  white-space: nowrap;
1623
1642
  }
1624
1643
 
1644
+ .trace-detail__children {
1645
+ display: flex;
1646
+ flex-wrap: wrap;
1647
+ gap: 5px;
1648
+ overflow: visible !important;
1649
+ white-space: normal !important;
1650
+ }
1651
+
1652
+ .trace-detail__children button {
1653
+ display: inline-flex;
1654
+ min-width: 0;
1655
+ max-width: 100%;
1656
+ height: 24px;
1657
+ align-items: center;
1658
+ border: 1px solid var(--color-border);
1659
+ border-radius: 999px;
1660
+ background: var(--color-surface-soft);
1661
+ color: var(--color-text-muted);
1662
+ padding: 0 8px;
1663
+ font: inherit;
1664
+ font-size: 11px;
1665
+ }
1666
+
1667
+ .trace-detail__children button:hover,
1668
+ .trace-detail__children button:focus-visible {
1669
+ border-color: var(--color-border-strong);
1670
+ background: var(--color-hover);
1671
+ color: var(--color-text);
1672
+ outline: none;
1673
+ }
1674
+
1625
1675
  .prompt-home__inner {
1626
1676
  display: grid;
1627
1677
  width: min(var(--composer-width), 100%);
@@ -1700,6 +1750,48 @@ button {
1700
1750
  gap: 4px;
1701
1751
  }
1702
1752
 
1753
+ /* #47: uploaded-file chips shown between the input and the toolbar. */
1754
+ .composer__attachments {
1755
+ display: flex;
1756
+ flex-wrap: wrap;
1757
+ gap: 6px;
1758
+ padding: 0 8px 4px;
1759
+ }
1760
+ .composer__chip {
1761
+ display: inline-flex;
1762
+ align-items: center;
1763
+ gap: 4px;
1764
+ max-width: 220px;
1765
+ padding: 2px 6px 2px 8px;
1766
+ border-radius: 999px;
1767
+ font-size: 12px;
1768
+ line-height: 1.4;
1769
+ background: var(--surface-2, rgba(127, 127, 127, 0.12));
1770
+ color: var(--text-2, inherit);
1771
+ }
1772
+ .composer__chip--pending {
1773
+ opacity: 0.7;
1774
+ }
1775
+ .composer__chip-name {
1776
+ overflow: hidden;
1777
+ text-overflow: ellipsis;
1778
+ white-space: nowrap;
1779
+ }
1780
+ .composer__chip-remove {
1781
+ display: inline-flex;
1782
+ align-items: center;
1783
+ justify-content: center;
1784
+ padding: 0;
1785
+ border: 0;
1786
+ background: transparent;
1787
+ color: inherit;
1788
+ cursor: pointer;
1789
+ opacity: 0.6;
1790
+ }
1791
+ .composer__chip-remove:hover {
1792
+ opacity: 1;
1793
+ }
1794
+
1703
1795
  .toolbar-select {
1704
1796
  padding: 0 8px;
1705
1797
  font-size: 13px;
@@ -1775,20 +1867,11 @@ button {
1775
1867
  }
1776
1868
 
1777
1869
  @media (max-width: 860px) {
1778
- .desktop-shell {
1779
- grid-template-columns: 64px minmax(0, 1fr);
1780
- }
1781
-
1782
- .sidebar {
1783
- gap: 16px;
1784
- }
1785
-
1786
- .section-heading,
1787
- .conversation-stack,
1788
- .nav-item span {
1789
- display: none;
1790
- }
1791
-
1870
+ /* #131 — the rail layout at narrow widths is now driven by React
1871
+ (DesktopShell auto-collapses below this breakpoint and applies
1872
+ .desktop-shell--sidebar-collapsed), so the sidebar styling lives in those
1873
+ class rules and the session list is reachable via the rail popover instead
1874
+ of being hidden outright. Only non-sidebar responsive tweaks remain here. */
1792
1875
  .prompt-home {
1793
1876
  padding-inline: 18px;
1794
1877
  }
@@ -2017,7 +2100,12 @@ button {
2017
2100
  overscroll-behavior: contain;
2018
2101
  padding: 4px 14px 20px 4px;
2019
2102
  scrollbar-gutter: stable;
2020
- scroll-behavior: smooth;
2103
+ /* #133: NO `scroll-behavior: smooth` here. The chat stream restores its
2104
+ position imperatively (`scrollTop = …`) on tab-switch remount and on
2105
+ pinned-bottom live append; a global smooth behavior turns those instant
2106
+ jumps into a visible top-to-bottom replay through the history. Smooth
2107
+ scrolling, if ever wanted, must be opt-in around an explicit
2108
+ user-triggered jump, never the default for the container. */
2021
2109
  scrollbar-color: var(--color-border) transparent;
2022
2110
  scrollbar-width: thin;
2023
2111
  }
@@ -2173,34 +2261,31 @@ button {
2173
2261
  }
2174
2262
 
2175
2263
  .message-row--live .message-row__body::after {
2176
- content: "";
2177
- position: absolute;
2178
- inset: 0;
2179
- pointer-events: none;
2180
- border-radius: var(--radius-md);
2181
- background: linear-gradient(
2182
- 100deg,
2183
- transparent 30%,
2184
- color-mix(in srgb, var(--color-info) 22%, transparent) 50%,
2185
- transparent 70%
2186
- );
2187
- background-size: 220% 100%;
2188
- animation: textSweep 1.4s linear infinite;
2264
+ content: none;
2189
2265
  }
2190
2266
 
2191
- @keyframes textSweep {
2192
- from {
2193
- background-position: 160% 0;
2194
- }
2195
- to {
2196
- background-position: -60% 0;
2197
- }
2267
+ .message-row__content {
2268
+ display: grid;
2269
+ min-width: 0;
2270
+ gap: 2px;
2198
2271
  }
2199
2272
 
2200
- @media (prefers-reduced-motion: reduce) {
2201
- .message-row--live .message-row__body::after {
2202
- animation: none;
2203
- background: none;
2273
+ .message-row__content--pending .message-card__content {
2274
+ color: var(--color-text-muted);
2275
+ }
2276
+
2277
+ .message-row__streaming-cursor {
2278
+ display: inline-block;
2279
+ width: 7px;
2280
+ height: 15px;
2281
+ border-radius: 2px;
2282
+ background: var(--color-info);
2283
+ opacity: 0.82;
2284
+ }
2285
+
2286
+ @media (prefers-reduced-motion: no-preference) {
2287
+ .message-row__streaming-cursor {
2288
+ animation: pulse 1.1s infinite;
2204
2289
  }
2205
2290
  }
2206
2291
 
@@ -2725,7 +2810,13 @@ button {
2725
2810
  background: var(--color-surface-soft);
2726
2811
  border: 1px solid var(--color-border);
2727
2812
  border-radius: 4px;
2728
- padding: 1px 4px;
2813
+ /* em-based padding scales with the (0.92em) code font; box-decoration-break
2814
+ keeps the border/radius/background intact on every fragment when an inline
2815
+ code span wraps across lines, instead of slicing into vertically-overflowing
2816
+ boxes that overlap adjacent lines (#130). */
2817
+ padding: 0.1em 0.35em;
2818
+ -webkit-box-decoration-break: clone;
2819
+ box-decoration-break: clone;
2729
2820
  }
2730
2821
 
2731
2822
  .message-card__content pre {
@@ -2865,6 +2956,22 @@ button {
2865
2956
  font-size: 12px;
2866
2957
  }
2867
2958
 
2959
+ /* #84: label each tool payload as Args / Result so the two stacked <pre>
2960
+ blocks are distinguishable. Label is intentionally low-weight — it's a
2961
+ separator signal, not a heading. */
2962
+ .activity-step__io {
2963
+ display: grid;
2964
+ gap: 4px;
2965
+ }
2966
+
2967
+ .activity-step__io-label {
2968
+ color: var(--color-text-muted);
2969
+ font-size: 11px;
2970
+ padding-left: 2px;
2971
+ text-transform: uppercase;
2972
+ letter-spacing: 0.04em;
2973
+ }
2974
+
2868
2975
  .terminal-input {
2869
2976
  display: grid;
2870
2977
  grid-template-columns: auto minmax(0, 1fr);
@@ -2920,6 +3027,62 @@ button {
2920
3027
  opacity: 0;
2921
3028
  }
2922
3029
 
3030
+ /* #131 — collapsed icon-rail session popover. The rail hides the inline session
3031
+ section (rule above) and squeezes nav labels to opacity:0; the popover is a
3032
+ floating panel anchored to the Sessions icon that restores the full list. */
3033
+ .sidebar__sessions-popover-anchor {
3034
+ position: relative;
3035
+ }
3036
+
3037
+ .sidebar__sessions-popover {
3038
+ position: absolute;
3039
+ top: 0;
3040
+ left: calc(100% + 10px);
3041
+ z-index: 40;
3042
+ display: flex;
3043
+ width: 300px;
3044
+ max-height: min(70vh, 560px);
3045
+ flex-direction: column;
3046
+ gap: 8px;
3047
+ padding: 12px;
3048
+ border: 1px solid var(--color-border);
3049
+ border-radius: var(--radius-md);
3050
+ background: var(--color-surface-raised, var(--color-surface));
3051
+ box-shadow: var(--shadow-md, 0 12px 32px rgba(0, 0, 0, 0.18));
3052
+ }
3053
+
3054
+ .sidebar__sessions-popover .conversation-stack {
3055
+ display: grid;
3056
+ min-height: 0;
3057
+ overflow-y: auto;
3058
+ }
3059
+
3060
+ .sidebar__sessions-popover-head {
3061
+ display: flex;
3062
+ align-items: center;
3063
+ justify-content: space-between;
3064
+ gap: 8px;
3065
+ }
3066
+
3067
+ .sidebar__sessions-popover-head h2 {
3068
+ margin: 0;
3069
+ color: var(--color-text-subtle);
3070
+ font-size: 13px;
3071
+ font-weight: 500;
3072
+ }
3073
+
3074
+ /* The popover lives inside the collapsed rail, so undo the rail's label
3075
+ squeeze/hide for everything inside it — its content is fully expanded. */
3076
+ .desktop-shell--sidebar-collapsed .sidebar__sessions-popover .nav-item span,
3077
+ .desktop-shell--sidebar-collapsed .sidebar__sessions-popover .conversation-row span,
3078
+ .desktop-shell--sidebar-collapsed .sidebar__sessions-popover .conversation-row small {
3079
+ opacity: 1;
3080
+ }
3081
+
3082
+ .desktop-shell--sidebar-collapsed .sidebar__sessions-popover .conversation-stack {
3083
+ display: grid;
3084
+ }
3085
+
2923
3086
  .sandbox-status {
2924
3087
  position: relative;
2925
3088
  display: inline-flex;
@@ -5097,6 +5260,52 @@ button {
5097
5260
  font-size: 11px;
5098
5261
  }
5099
5262
 
5263
+ .agent-network__available {
5264
+ border: 1px solid var(--color-border);
5265
+ border-radius: var(--radius-sm);
5266
+ background: var(--color-surface);
5267
+ color: var(--color-text-muted);
5268
+ padding: 7px 10px;
5269
+ font-size: 11px;
5270
+ }
5271
+
5272
+ .agent-network__available summary {
5273
+ cursor: pointer;
5274
+ list-style: none;
5275
+ }
5276
+
5277
+ .agent-network__available summary::-webkit-details-marker {
5278
+ display: none;
5279
+ }
5280
+
5281
+ .agent-network__available summary::before {
5282
+ content: ">";
5283
+ display: inline-block;
5284
+ margin-right: 6px;
5285
+ color: var(--color-text-subtle);
5286
+ transition: transform var(--ease-standard);
5287
+ }
5288
+
5289
+ .agent-network__available[open] summary::before {
5290
+ transform: rotate(90deg);
5291
+ }
5292
+
5293
+ .agent-network__available ul {
5294
+ display: flex;
5295
+ flex-wrap: wrap;
5296
+ gap: 5px;
5297
+ margin: 8px 0 0;
5298
+ padding: 0;
5299
+ list-style: none;
5300
+ }
5301
+
5302
+ .agent-network__available li {
5303
+ border: 1px dashed var(--color-border-strong);
5304
+ border-radius: 999px;
5305
+ color: var(--color-text-subtle);
5306
+ padding: 2px 7px;
5307
+ }
5308
+
5100
5309
  .agent-network__legend-dot {
5101
5310
  display: inline-block;
5102
5311
  width: 8px;
@@ -6395,6 +6604,17 @@ button {
6395
6604
  .agent-analytics__chart-title svg {
6396
6605
  color: var(--color-text-subtle);
6397
6606
  }
6607
+ .agent-analytics__token-total {
6608
+ font-size: 22px;
6609
+ font-weight: 700;
6610
+ color: var(--color-text);
6611
+ margin: 2px 0 8px;
6612
+ }
6613
+ .agent-analytics__token-total-label {
6614
+ font-size: 12px;
6615
+ font-weight: 500;
6616
+ color: var(--color-text-muted);
6617
+ }
6398
6618
  .agent-analytics__svg {
6399
6619
  width: 100%;
6400
6620
  height: auto;
@@ -7426,4 +7646,3 @@ button {
7426
7646
  .demo-right { border-bottom: 1px solid var(--color-border); }
7427
7647
  .demo-landing__cards { grid-template-columns: 1fr; }
7428
7648
  }
7429
-
package/src/utils/api.ts CHANGED
@@ -39,10 +39,29 @@ const API_BASE = "/api";
39
39
  // Trust-front: the hosted gateway authenticates via an httpOnly cookie that the
40
40
  // browser carries automatically. The frontend never reads, stores, or attaches a
41
41
  // token — it just makes credentialed requests.
42
- function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
43
- return fetch(input, { credentials: "include", ...init });
42
+ //
43
+ // #106: callers that drive composer state (postMessage / create) pass a
44
+ // `timeoutMs`. A hung request used to leave `isSending` true forever (the
45
+ // `finally` that resets it never ran), permanently disabling the composer and
46
+ // silently dropping the user's input. With a timeout the request rejects, the
47
+ // caller's catch surfaces a recoverable error, and `isSending` is released.
48
+ function apiFetch(
49
+ input: RequestInfo | URL,
50
+ init: RequestInit & { timeoutMs?: number } = {},
51
+ ): Promise<Response> {
52
+ const { timeoutMs, signal, ...rest } = init;
53
+ if (timeoutMs == null) {
54
+ return fetch(input, { credentials: "include", signal, ...rest });
55
+ }
56
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
57
+ // Honour an upstream signal too, if one was supplied.
58
+ const merged = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
59
+ return fetch(input, { credentials: "include", signal: merged, ...rest });
44
60
  }
45
61
 
62
+ /** #106: default ceiling for composer-driving requests (create / postMessage). */
63
+ const SEND_TIMEOUT_MS = 30_000;
64
+
46
65
  function authHeaders(json = true): Record<string, string> {
47
66
  return json ? { "Content-Type": "application/json" } : {};
48
67
  }
@@ -69,6 +88,21 @@ async function handleJson<T>(res: Response): Promise<T> {
69
88
  return (await res.json()) as T;
70
89
  }
71
90
 
91
+ /** #47: encode a Blob/File as base64 (without the data: prefix) for upload. */
92
+ function blobToBase64(blob: Blob): Promise<string> {
93
+ return new Promise((resolve, reject) => {
94
+ const reader = new FileReader();
95
+ reader.onerror = () => reject(reader.error ?? new Error("file read failed"));
96
+ reader.onload = () => {
97
+ const result = reader.result as string;
98
+ // strip the "data:<mime>;base64," prefix
99
+ const comma = result.indexOf(",");
100
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
101
+ };
102
+ reader.readAsDataURL(blob);
103
+ });
104
+ }
105
+
72
106
  export function getSSEUrl(sessionId: string): string {
73
107
  // Same origin; relative path lets EventSource follow the current host/port and
74
108
  // carry the auth cookie automatically — no token in the query string.
@@ -246,6 +280,20 @@ export const api = {
246
280
  throw new Error(await parseError(res));
247
281
  }
248
282
  },
283
+
284
+ // #47: upload a file into the workspace (base64 over the JSON byte chain).
285
+ async uploadFile(sandboxId: string, path: string, file: Blob): Promise<{ path: string; size: number }> {
286
+ const contentBase64 = await blobToBase64(file);
287
+ const res = await apiFetch(`${API_BASE}/sandbox/${sandboxId}/files`, {
288
+ method: "POST",
289
+ headers: { ...authHeaders(), "content-type": "application/json" },
290
+ body: JSON.stringify({ path, contentBase64 }),
291
+ });
292
+ if (!res.ok) {
293
+ throw new Error(await parseError(res));
294
+ }
295
+ return handleJson(res);
296
+ },
249
297
  },
250
298
 
251
299
  sessions: {
@@ -287,6 +335,7 @@ export const api = {
287
335
  await apiFetch(`${API_BASE}/sessions`, {
288
336
  method: "POST",
289
337
  headers: authHeaders(),
338
+ timeoutMs: SEND_TIMEOUT_MS,
290
339
  body: JSON.stringify({
291
340
  title,
292
341
  ...(opts.providerId ? { providerId: opts.providerId } : {}),
@@ -294,7 +343,16 @@ export const api = {
294
343
  }),
295
344
  }),
296
345
  );
297
- return normalizeSession(raw as Parameters<typeof normalizeSession>[0]);
346
+ // The runtime's POST /sessions returns the envelope `{ id, session }`
347
+ // (server.ts), unlike GET /sessions[/:id] which return the bare session.
348
+ // Unwrap `session` if present so normalizeSession reads the real `title`
349
+ // instead of falling back to `Session <id8>` (#96). Tolerate a bare
350
+ // object too (mock / future shape change).
351
+ const envelope = raw as { session?: unknown } | null;
352
+ const sessionRaw = envelope && typeof envelope === "object" && "session" in envelope
353
+ ? envelope.session
354
+ : raw;
355
+ return normalizeSession(sessionRaw as Parameters<typeof normalizeSession>[0]);
298
356
  },
299
357
 
300
358
  async update(sessionId: string, title: string): Promise<Session> {
@@ -323,15 +381,19 @@ export const api = {
323
381
  );
324
382
  },
325
383
 
326
- async interrupt(sessionId: string): Promise<{ status: string }> {
384
+ async interrupt(sessionId: string): Promise<{ interrupted: boolean }> {
327
385
  if (runtimeConfig.useMockBackend) {
328
- return { status: "ok" };
386
+ return { interrupted: true };
329
387
  }
330
- return handleJson<{ status: string }>(
331
- await apiFetch(`${API_BASE}/sessions/${sessionId}/messages`, {
388
+ // #90: Stop = whole-session interrupt. Hit the dedicated interrupt route
389
+ // (RUNTIME_ROUTES.interrupt), NOT /messages — the messages endpoint's body
390
+ // schema rejects {type:"interrupt"} so the agent was never actually
391
+ // stopped. Empty body = interrupt every agent in the session.
392
+ return handleJson<{ interrupted: boolean }>(
393
+ await apiFetch(`${API_BASE}/sessions/${sessionId}/interrupt`, {
332
394
  method: "POST",
333
395
  headers: { ...authHeaders(), "Content-Type": "application/json" },
334
- body: JSON.stringify({ type: "interrupt", session_id: sessionId }),
396
+ body: JSON.stringify({}),
335
397
  }),
336
398
  );
337
399
  },
@@ -348,6 +410,7 @@ export const api = {
348
410
  await apiFetch(`${API_BASE}/sessions/${sessionId}/messages`, {
349
411
  method: "POST",
350
412
  headers: { ...authHeaders(), "Content-Type": "application/json" },
413
+ timeoutMs: SEND_TIMEOUT_MS,
351
414
  body: JSON.stringify({
352
415
  type: payload.type ?? "user_message",
353
416
  content: payload.content,
@@ -419,6 +482,40 @@ export const api = {
419
482
  return Array.isArray(raw.events) ? (raw.events as RawAgUiEvent[]) : [];
420
483
  },
421
484
 
485
+ /**
486
+ * Persisted AG-UI event history from `events.jsonl` — used to rehydrate
487
+ * the chat list (and trace/agents seed) when a session is activated after
488
+ * a runtime restart. SSE only replays the in-memory ring buffer; this
489
+ * endpoint walks the on-disk log and returns the tail when long. Pass
490
+ * `limit: 0` to request the full log for lossless rehydrate.
491
+ *
492
+ * Tolerates any non-200 / non-JSON response by returning an empty
493
+ * envelope, so callers can fall through to whatever live data the SSE
494
+ * stream eventually delivers.
495
+ */
496
+ async getHistory(
497
+ sessionId: string,
498
+ opts: { limit?: number } = {},
499
+ ): Promise<{ events: RawAgUiEvent[]; total: number; truncated: boolean }> {
500
+ if (runtimeConfig.useMockBackend) {
501
+ return { events: [], total: 0, truncated: false };
502
+ }
503
+ const qs = opts.limit !== undefined ? `?limit=${encodeURIComponent(opts.limit)}` : "";
504
+ const res = await apiFetch(
505
+ `${API_BASE}/sessions/${sessionId}/history${qs}`,
506
+ { headers: authHeaders() },
507
+ );
508
+ if (!res.ok) return { events: [], total: 0, truncated: false };
509
+ const raw = (await res.json().catch(() => null)) as
510
+ | { events?: unknown[]; total?: number; truncated?: boolean }
511
+ | null;
512
+ return {
513
+ events: Array.isArray(raw?.events) ? (raw!.events as RawAgUiEvent[]) : [],
514
+ total: typeof raw?.total === "number" ? raw!.total : 0,
515
+ truncated: Boolean(raw?.truncated),
516
+ };
517
+ },
518
+
422
519
  async state(sessionId: string): Promise<SessionStateSnapshot> {
423
520
  if (runtimeConfig.useMockBackend) {
424
521
  return mockBackend.state();