@brainpilot/web 0.0.8 → 0.0.9

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.
@@ -15,6 +15,13 @@ import { getMessageEdge, msgTypeKind } from "./agentNetworkShared";
15
15
  interface TimelineTabProps {
16
16
  messages: ChatMessage[];
17
17
  now: number;
18
+ /**
19
+ * Whether the session is actively running (≥1 agent in a running state).
20
+ * Only while running does the axis track wall-clock `now` and show the
21
+ * live "now" marker; a finished session freezes the axis at the last
22
+ * message so the plot doesn't grow a blank right gutter over time (#166).
23
+ */
24
+ isRunning?: boolean;
18
25
  /** Click a dot → caller selects that agent (and flips to Detail tab). */
19
26
  onSelectMessage: (agentName: string) => void;
20
27
  }
@@ -33,7 +40,30 @@ const LABEL_W = 88;
33
40
  const PAD_TOP = 28;
34
41
  const TICK_COUNT = 6;
35
42
 
36
- export function TimelineTab({ messages, now, onSelectMessage }: TimelineTabProps) {
43
+ /**
44
+ * Compute the timeline's [start, end] axis bounds (#166).
45
+ *
46
+ * The axis always starts at the first message. It ends at the LAST message,
47
+ * and only extends to wall-clock `now` while the session is actively running.
48
+ * A finished session therefore freezes its right edge at the last event
49
+ * instead of accreting blank space as real time marches on.
50
+ *
51
+ * `tsList` must be ascending (as produced by the sorted `dots`).
52
+ */
53
+ export function computeTimeBounds(
54
+ tsList: number[],
55
+ now: number,
56
+ isRunning: boolean,
57
+ ): { start: number; end: number } {
58
+ if (tsList.length === 0) return { start: now - 60_000, end: now };
59
+ const start = tsList[0];
60
+ const lastTs = tsList[tsList.length - 1];
61
+ const end = isRunning ? Math.max(now, lastTs) : lastTs;
62
+ // Degenerate span (single dot / identical timestamps): give it a minute.
63
+ return { start, end: end === start ? start + 60_000 : end };
64
+ }
65
+
66
+ export function TimelineTab({ messages, now, isRunning = false, onSelectMessage }: TimelineTabProps) {
37
67
  const t = useT();
38
68
  const containerRef = useRef<HTMLDivElement | null>(null);
39
69
  const [zoom, setZoom] = useState(1);
@@ -78,12 +108,10 @@ export function TimelineTab({ messages, now, onSelectMessage }: TimelineTabProps
78
108
  return names;
79
109
  }, [dots]);
80
110
 
81
- const timeBounds = useMemo(() => {
82
- if (dots.length === 0) return { start: now - 60_000, end: now };
83
- const start = dots[0].ts;
84
- const end = Math.max(now, dots[dots.length - 1].ts);
85
- return { start, end: end === start ? start + 60_000 : end };
86
- }, [dots, now]);
111
+ const timeBounds = useMemo(
112
+ () => computeTimeBounds(dots.map((d) => d.ts), now, isRunning),
113
+ [dots, now, isRunning],
114
+ );
87
115
 
88
116
  if (dots.length === 0) {
89
117
  return (
@@ -243,8 +271,10 @@ export function TimelineTab({ messages, now, onSelectMessage }: TimelineTabProps
243
271
  </g>
244
272
  ))}
245
273
 
246
- {/* "now" marker */}
247
- <line x1={xOf(now)} x2={xOf(now)} y1={PAD_TOP - 6} y2={svgH - 4} className="agent-timeline__now" />
274
+ {/* "now" marker — only meaningful while the session is live (#166) */}
275
+ {isRunning && (
276
+ <line x1={xOf(now)} x2={xOf(now)} y1={PAD_TOP - 6} y2={svgH - 4} className="agent-timeline__now" />
277
+ )}
248
278
 
249
279
  {/* delegate→result arcs */}
250
280
  {arcs.map((a) => {
@@ -102,6 +102,25 @@ const SessionContext = createContext<SessionContextValue | null>(null);
102
102
  // CONTENT / END leaves old long sessions looking empty.
103
103
  const HISTORY_REHYDRATE_LIMIT = 0;
104
104
 
105
+ /**
106
+ * #194-B1: merge the full rehydrated history under whatever the live message
107
+ * list already holds. On refresh the SSE ring-buffer tail seeds a few recent
108
+ * messages before history arrives; we must NOT discard the (complete) history
109
+ * just because the list is non-empty. The persisted history is the base; we
110
+ * append only the messages already shown that history doesn't contain (by id) —
111
+ * in-flight optimistic sends, or events newer than the persisted file. Ordering
112
+ * matters: history first (chronological), then the live-only tail.
113
+ */
114
+ export function mergeRehydratedMessages(
115
+ existing: ChatMessage[],
116
+ history: ChatMessage[],
117
+ ): ChatMessage[] {
118
+ if (existing.length === 0) return history;
119
+ const historyIds = new Set(history.map((m) => m.id));
120
+ const extra = existing.filter((m) => !historyIds.has(m.id));
121
+ return [...history, ...extra];
122
+ }
123
+
105
124
  function foldSessionHistory(events: unknown[], sessionId: string): {
106
125
  messages: ChatMessage[];
107
126
  trace: TraceGraph | null;
@@ -293,13 +312,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
293
312
  hydratedSessionsRef.current.add(sessionId);
294
313
  if (lastUsage) setTokenUsage(lastUsage);
295
314
 
296
- // Only seed the message list if the user hasn't already started typing
297
- // / receiving live SSE for this session in the brief window before
298
- // history arrived (otherwise we'd clobber their in-flight messages).
299
- setMessagesBySession((current) => {
300
- if ((current[sessionId]?.length ?? 0) > 0) return current;
301
- return { ...current, [sessionId]: nextMessages };
302
- });
315
+ // Merge the full history under whatever SSE / optimistic messages have
316
+ // already landed do NOT bail just because the list is non-empty
317
+ // (#194-B1). On refresh the SSE ring-buffer tail arrives first and seeds
318
+ // a few recent messages; the old `length > 0 → skip` guard then dropped
319
+ // the entire rehydrated history, leaving only those few. The persisted
320
+ // history is the complete log, so use it as the base and append only the
321
+ // messages SSE already showed that the history doesn't contain (by id)
322
+ // in-flight optimistic sends, or events newer than the persisted file.
323
+ setMessagesBySession((current) => ({
324
+ ...current,
325
+ [sessionId]: mergeRehydratedMessages(current[sessionId] ?? [], nextMessages),
326
+ }));
303
327
  if (nextTrace) {
304
328
  setTraceBySession((current) =>
305
329
  current[sessionId] ? current : { ...current, [sessionId]: nextTrace! },
@@ -171,6 +171,25 @@ export function reduceMessagesForEvent(existing: ChatMessage[], event: WebSocket
171
171
  // Strip NO-RENDER wrapper used by record_trace "Message Complete" hint
172
172
  delta = delta.replace(/<!--NO-RENDER-->[\s\S]*?<!--\/NO-RENDER-->/g, "");
173
173
  if (!delta) return existing;
174
+ // Orphaned CONTENT (no matching START) — recover gracefully instead of
175
+ // dropping it. This happens when a demo bundle was exported from a
176
+ // tail-sliced history: the leading START of the earliest messages is gone,
177
+ // and a plain `.map` here would no-op, silently swallowing the opening
178
+ // replies. Synthesize the message so the content still renders.
179
+ if (!existing.some((m) => m.id === id)) {
180
+ return [
181
+ ...existing,
182
+ {
183
+ id,
184
+ role: "assistant",
185
+ content: delta,
186
+ createdAt: new Date().toISOString(),
187
+ agent,
188
+ streaming: true,
189
+ kind: "text",
190
+ },
191
+ ];
192
+ }
174
193
  return existing.map((m) =>
175
194
  m.id === id ? { ...m, content: (m.content ?? "") + delta } : m,
176
195
  );
@@ -481,7 +481,14 @@ function normalizeStringArray(value: unknown): string[] {
481
481
  }
482
482
 
483
483
  function camelizeKey(key: string): string {
484
- return key.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
484
+ // Preserve a leading-underscore prefix: `_ts` / `_seq` are AG-UI transport
485
+ // metadata whose underscore is significant. Without this guard the regex
486
+ // turns `_ts` into `Ts`, so `normalizeAgUiEvent` strips the timestamp and the
487
+ // demo replay's timeline collapses (every event lands at ms=0). Only internal
488
+ // snake_case boundaries (e.g. `agent_name` → `agentName`) are camelized.
489
+ const lead = key.match(/^_+/)?.[0] ?? "";
490
+ const rest = key.slice(lead.length);
491
+ return lead + rest.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
485
492
  }
486
493
 
487
494
  function camelizeObject(value: unknown): unknown {
@@ -17,6 +17,7 @@ export default defineMessages(
17
17
  "files.aria.tree": "文件树",
18
18
  "files.selectForDownload": "选择 {name} 以下载",
19
19
  "files.error.notRunning": "Sandbox 未运行,无法读取文件。",
20
+ "files.error.loadFailed": "加载文件列表失败",
20
21
  "files.error.downloadFailed": "下载文件失败",
21
22
  "files.error.refreshFailed": "刷新文件失败",
22
23
  "files.error.previewFailed": "无法预览文件",
@@ -56,6 +57,7 @@ export default defineMessages(
56
57
  "files.aria.tree": "File tree",
57
58
  "files.selectForDownload": "Select {name} for download",
58
59
  "files.error.notRunning": "Sandbox is not running; cannot read files.",
60
+ "files.error.loadFailed": "Failed to load file list",
59
61
  "files.error.downloadFailed": "Failed to download file",
60
62
  "files.error.refreshFailed": "Failed to refresh files",
61
63
  "files.error.previewFailed": "Unable to preview file",
package/src/utils/api.ts CHANGED
@@ -123,6 +123,20 @@ export const api = {
123
123
  return handleJson(await apiFetch(`${API_BASE}/version`));
124
124
  },
125
125
 
126
+ // #156: real on-disk paths for the Files panel (local mode only). Hosted
127
+ // backends return `{ localMode: false }` with no host path. Best-effort:
128
+ // any failure resolves to a non-local shape so callers fall back cleanly.
129
+ async getInfo(): Promise<{ localMode: boolean; dataDir?: string; workspacesRoot?: string }> {
130
+ if (runtimeConfig.useMockBackend) {
131
+ return { localMode: false };
132
+ }
133
+ try {
134
+ return await handleJson(await apiFetch(`${API_BASE}/info`));
135
+ } catch {
136
+ return { localMode: false };
137
+ }
138
+ },
139
+
126
140
  auth: {
127
141
  async me(): Promise<User> {
128
142
  if (runtimeConfig.useMockBackend) {