@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.
- package/dist/assets/index-CJNvdeGz.js +445 -0
- package/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/__tests__/api.test.ts +41 -0
- package/src/__tests__/demoTruncatedExport.test.ts +104 -0
- package/src/__tests__/rehydrateMerge.test.ts +40 -0
- package/src/__tests__/timelineBounds.test.ts +51 -0
- package/src/components/chat/PromptComposer.tsx +91 -6
- package/src/components/demo/demoBundle.ts +8 -3
- package/src/components/files/FileSidebar.tsx +82 -11
- package/src/components/session/AgentNetwork.tsx +1 -0
- package/src/components/session/TimelineTab.tsx +39 -9
- package/src/contexts/SessionContext.tsx +31 -7
- package/src/contexts/messageReducer.ts +19 -0
- package/src/contracts/backend.ts +8 -1
- package/src/i18n/messages/files.ts +2 -0
- package/src/utils/api.ts +14 -0
- package/dist/assets/index-162Pskp8.js +0 -438
|
@@ -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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
);
|
package/src/contracts/backend.ts
CHANGED
|
@@ -481,7 +481,14 @@ function normalizeStringArray(value: unknown): string[] {
|
|
|
481
481
|
}
|
|
482
482
|
|
|
483
483
|
function camelizeKey(key: string): string {
|
|
484
|
-
|
|
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) {
|