@brainpilot/web 0.0.7 → 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__/composerSendTools.test.tsx +37 -0
- package/src/__tests__/demoTruncatedExport.test.ts +104 -0
- package/src/__tests__/rehydrateMerge.test.ts +40 -0
- package/src/__tests__/sidebarResize.test.ts +46 -0
- package/src/__tests__/timelineBounds.test.ts +51 -0
- package/src/components/chat/ComposerSendTools.tsx +31 -0
- package/src/components/chat/PromptComposer.tsx +91 -75
- 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/components/shell/DesktopShell.tsx +15 -8
- package/src/components/shell/sidebarResize.ts +49 -0
- 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-j3rGyO6m.js +0 -445
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
X,
|
|
14
14
|
} from "lucide-react";
|
|
15
15
|
import { FileContent, FileEntry } from "../../contracts/backend";
|
|
16
|
+
import { runtimeConfig } from "../../config";
|
|
16
17
|
import { useSandbox } from "../../contexts/SandboxContext";
|
|
17
18
|
import { useSessions } from "../../contexts/SessionContext";
|
|
18
|
-
import { runtimeConfig } from "../../config";
|
|
19
19
|
import { useT } from "../../i18n/useT";
|
|
20
20
|
import { api } from "../../utils/api";
|
|
21
21
|
import { downloadBlob } from "../../utils/download";
|
|
@@ -128,10 +128,14 @@ function findNode(root: FileNode, path: string | null): FileNode | null {
|
|
|
128
128
|
export function FileSidebar({ isOpen, onClose, onResize, onResizeEnd, onResizeStart, width }: FileSidebarProps) {
|
|
129
129
|
const { currentSandbox } = useSandbox();
|
|
130
130
|
const { currentSession } = useSessions();
|
|
131
|
-
//
|
|
132
|
-
// id
|
|
133
|
-
//
|
|
134
|
-
|
|
131
|
+
// The runtime always addresses a workspace by session id (workspaces/<sid>/),
|
|
132
|
+
// never by container id — in both local and remote mode. A container can host
|
|
133
|
+
// several sessions, and the file tree shows the *current session's* workspace.
|
|
134
|
+
// (#168) `currentSandbox.status` still gates whether files are live; the
|
|
135
|
+
// variable name stays `sandboxId` only because the call sites/sub-component
|
|
136
|
+
// prop are named that way — it has always carried the session id in local
|
|
137
|
+
// mode. A full rename rides with the planned session-management cleanup.
|
|
138
|
+
const sandboxId = currentSession?.id ?? null;
|
|
135
139
|
const t = useT();
|
|
136
140
|
const [tree, setTree] = useState<FileNode>(rootNode);
|
|
137
141
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["/workspace"]));
|
|
@@ -144,18 +148,82 @@ export function FileSidebar({ isOpen, onClose, onResize, onResizeEnd, onResizeSt
|
|
|
144
148
|
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
|
|
145
149
|
const resizeStartRef = useRef<{ pointerX: number; width: number } | null>(null);
|
|
146
150
|
|
|
151
|
+
// #156: in local mode, surface the real on-disk workspace dir so users know
|
|
152
|
+
// which directory the agent writes into. `workspacesRoot` comes from the
|
|
153
|
+
// backend (gated to local mode there too); the per-session dir is
|
|
154
|
+
// `<workspacesRoot>/<sessionId>`. Null in hosted mode → keep showing the
|
|
155
|
+
// virtual `/workspace` and never disclose a host path.
|
|
156
|
+
const [workspacesRoot, setWorkspacesRoot] = useState<string | null>(null);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!runtimeConfig.localMode) return;
|
|
159
|
+
let cancelled = false;
|
|
160
|
+
void api.getInfo().then((info) => {
|
|
161
|
+
if (!cancelled && info.localMode && info.workspacesRoot) {
|
|
162
|
+
setWorkspacesRoot(info.workspacesRoot);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
return () => {
|
|
166
|
+
cancelled = true;
|
|
167
|
+
};
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
// Join with the platform's separator: a Windows root contains "\", a POSIX
|
|
171
|
+
// root "/". Detect from the root itself rather than assuming the host.
|
|
172
|
+
const realWorkspacePath = useMemo(() => {
|
|
173
|
+
if (!workspacesRoot || !currentSession?.id) return null;
|
|
174
|
+
const sepChar = workspacesRoot.includes("\\") && !workspacesRoot.includes("/") ? "\\" : "/";
|
|
175
|
+
return `${workspacesRoot.replace(/[\\/]$/, "")}${sepChar}${currentSession.id}`;
|
|
176
|
+
}, [workspacesRoot, currentSession?.id]);
|
|
177
|
+
|
|
178
|
+
// Map a virtual `/workspace[/...]` path to its real on-disk equivalent for
|
|
179
|
+
// display. Returns the original virtual path when no real root is known.
|
|
180
|
+
const toDisplayPath = useCallback(
|
|
181
|
+
(virtualPath: string): string => {
|
|
182
|
+
if (!realWorkspacePath) return virtualPath;
|
|
183
|
+
const sepChar = realWorkspacePath.includes("\\") && !realWorkspacePath.includes("/") ? "\\" : "/";
|
|
184
|
+
if (virtualPath === "/workspace") return realWorkspacePath;
|
|
185
|
+
if (virtualPath.startsWith("/workspace/")) {
|
|
186
|
+
const rel = virtualPath.slice("/workspace/".length).split("/").join(sepChar);
|
|
187
|
+
return `${realWorkspacePath}${sepChar}${rel}`;
|
|
188
|
+
}
|
|
189
|
+
return virtualPath;
|
|
190
|
+
},
|
|
191
|
+
[realWorkspacePath],
|
|
192
|
+
);
|
|
193
|
+
|
|
147
194
|
const loadDirectory = useCallback(
|
|
148
195
|
async (path: string) => {
|
|
149
196
|
if (!currentSandbox || currentSandbox.status !== "running" || !sandboxId) {
|
|
197
|
+
// #193 diagnostics: distinguish "panel gated off" from "listed but empty".
|
|
198
|
+
// Logs the exact reason the gate blocked the load so a user (esp. on
|
|
199
|
+
// Windows, where the empty-panel report originates) can paste it back.
|
|
200
|
+
console.warn("[FileSidebar] load skipped — sandbox not ready", {
|
|
201
|
+
path,
|
|
202
|
+
sandboxId,
|
|
203
|
+
hasSandbox: !!currentSandbox,
|
|
204
|
+
sandboxStatus: currentSandbox?.status ?? null,
|
|
205
|
+
});
|
|
150
206
|
setError(t("files.error.notRunning"));
|
|
151
207
|
return;
|
|
152
208
|
}
|
|
153
209
|
setError(null);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
210
|
+
try {
|
|
211
|
+
// #193 diagnostics: log the exact request being addressed so an empty or
|
|
212
|
+
// failing listing can be traced to the real sandboxId + path on the wire.
|
|
213
|
+
console.debug("[FileSidebar] listFiles", { sandboxId, path });
|
|
214
|
+
const entries = await api.sandbox.listFiles(sandboxId, path);
|
|
215
|
+
console.debug("[FileSidebar] listFiles ok", { sandboxId, path, count: entries.length });
|
|
216
|
+
const children = entries.map((entry) => ({ ...entry, path: joinPath(path, entry.name) }));
|
|
217
|
+
setTree((current) => updateNode(current, path, (node) => ({ ...node, children, loaded: true })));
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// The runtime now returns a distinct error (instead of an empty array)
|
|
220
|
+
// when readdir fails for a reason other than ENOENT (#193). Surface it
|
|
221
|
+
// rather than leaving the panel stuck loading with no feedback.
|
|
222
|
+
console.error("[FileSidebar] listFiles failed", { sandboxId, path, error: err });
|
|
223
|
+
setError(err instanceof Error ? err.message : t("files.error.loadFailed"));
|
|
224
|
+
}
|
|
157
225
|
},
|
|
158
|
-
[currentSandbox, sandboxId],
|
|
226
|
+
[currentSandbox, sandboxId, t],
|
|
159
227
|
);
|
|
160
228
|
|
|
161
229
|
useEffect(() => {
|
|
@@ -443,7 +511,7 @@ export function FileSidebar({ isOpen, onClose, onResize, onResizeEnd, onResizeSt
|
|
|
443
511
|
</header>
|
|
444
512
|
|
|
445
513
|
<div className="file-sidebar__path">
|
|
446
|
-
<span
|
|
514
|
+
<span title={realWorkspacePath ?? "/workspace"}>{realWorkspacePath ?? "/workspace"}</span>
|
|
447
515
|
<small>{currentSandbox?.status === "running" ? t("files.live") : t("files.offline")}</small>
|
|
448
516
|
</div>
|
|
449
517
|
|
|
@@ -463,6 +531,7 @@ export function FileSidebar({ isOpen, onClose, onResize, onResizeEnd, onResizeSt
|
|
|
463
531
|
setIsPreviewMaximized(false);
|
|
464
532
|
}}
|
|
465
533
|
sandboxId={sandboxId}
|
|
534
|
+
toDisplayPath={toDisplayPath}
|
|
466
535
|
onToggleMaximize={() => setIsPreviewMaximized((current) => !current)}
|
|
467
536
|
/>
|
|
468
537
|
</>
|
|
@@ -475,6 +544,7 @@ function FilePreviewPanel({
|
|
|
475
544
|
isMaximized,
|
|
476
545
|
onClose,
|
|
477
546
|
sandboxId,
|
|
547
|
+
toDisplayPath,
|
|
478
548
|
onToggleMaximize,
|
|
479
549
|
}: {
|
|
480
550
|
file: FileNode | null;
|
|
@@ -482,6 +552,7 @@ function FilePreviewPanel({
|
|
|
482
552
|
isMaximized: boolean;
|
|
483
553
|
onClose: () => void;
|
|
484
554
|
sandboxId: string | null;
|
|
555
|
+
toDisplayPath: (virtualPath: string) => string;
|
|
485
556
|
onToggleMaximize: () => void;
|
|
486
557
|
}) {
|
|
487
558
|
const t = useT();
|
|
@@ -634,7 +705,7 @@ function FilePreviewPanel({
|
|
|
634
705
|
<dl className="file-preview__meta">
|
|
635
706
|
<div>
|
|
636
707
|
<dt>{t("files.preview.path")}</dt>
|
|
637
|
-
<dd>{file.path}</dd>
|
|
708
|
+
<dd>{toDisplayPath(file.path)}</dd>
|
|
638
709
|
</div>
|
|
639
710
|
<div>
|
|
640
711
|
<dt>{t("files.preview.size")}</dt>
|
|
@@ -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) => {
|
|
@@ -17,9 +17,7 @@ import { SandboxStatus } from "./SandboxStatus";
|
|
|
17
17
|
import { Sidebar } from "../sidebar/Sidebar";
|
|
18
18
|
import { DiskQuotaWarningDialog } from "../quota/DiskQuotaWarningDialog";
|
|
19
19
|
import { DiskQuotaCriticalDialog } from "../quota/DiskQuotaCriticalDialog";
|
|
20
|
-
|
|
21
|
-
const MIN_SIDEBAR_WIDTH = 220;
|
|
22
|
-
const MAX_SIDEBAR_WIDTH = 420;
|
|
20
|
+
import { DEFAULT_SIDEBAR_WIDTH, resolveResize } from "./sidebarResize";
|
|
23
21
|
|
|
24
22
|
export function DesktopShell() {
|
|
25
23
|
const { isAuthReady } = useAuth();
|
|
@@ -87,12 +85,21 @@ export function DesktopShell() {
|
|
|
87
85
|
return;
|
|
88
86
|
}
|
|
89
87
|
|
|
88
|
+
// #159 — drag the edge left past the collapse threshold and the rail snaps
|
|
89
|
+
// to the icon rail; otherwise apply the clamped expanded width. resolveResize
|
|
90
|
+
// owns the geometry (pure + unit-tested in sidebarResize.test.ts).
|
|
90
91
|
const delta = event.clientX - sidebarResizeRef.current.pointerX;
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
const outcome = resolveResize(sidebarResizeRef.current.width + delta);
|
|
93
|
+
if (outcome.collapse) {
|
|
94
|
+
setUserCollapsed(true);
|
|
95
|
+
sidebarResizeRef.current = null;
|
|
96
|
+
setIsSidebarResizing(false);
|
|
97
|
+
// Restore a sensible width so expanding again (toggle / drag) isn't stuck
|
|
98
|
+
// at the collapsed remnant.
|
|
99
|
+
setSidebarWidth(DEFAULT_SIDEBAR_WIDTH);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setSidebarWidth(outcome.width);
|
|
96
103
|
};
|
|
97
104
|
|
|
98
105
|
const handlePointerUp = () => {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sidebarResize.ts — pure geometry for the sidebar resize→collapse interaction
|
|
3
|
+
* (#159). Kept free of React so it can be unit-tested without jsdom (the
|
|
4
|
+
* monorepo has no jsdom/@testing-library; DesktopShell drives the real
|
|
5
|
+
* pointer events, these helpers decide the numbers).
|
|
6
|
+
*
|
|
7
|
+
* Behaviour: while dragging the sidebar's right edge leftward, once the would-be
|
|
8
|
+
* width crosses a collapse threshold that sits *below* the normal minimum, the
|
|
9
|
+
* rail snaps to the collapsed icon rail (rather than refusing to shrink past the
|
|
10
|
+
* minimum, which is what made drag-to-collapse impossible before #159).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Normal drag bounds — the sidebar clamps here while it stays expanded. */
|
|
14
|
+
export const MIN_SIDEBAR_WIDTH = 220;
|
|
15
|
+
export const MAX_SIDEBAR_WIDTH = 420;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Drag the edge below this (well under MIN, giving a deliberate "drag past the
|
|
19
|
+
* min a bit more" buffer so a normal min-width drag doesn't accidentally
|
|
20
|
+
* collapse) and the rail snaps shut.
|
|
21
|
+
*/
|
|
22
|
+
export const COLLAPSE_THRESHOLD = 160;
|
|
23
|
+
|
|
24
|
+
/** Width the rail restores to when it expands again (matches the default). */
|
|
25
|
+
export const DEFAULT_SIDEBAR_WIDTH = 268;
|
|
26
|
+
|
|
27
|
+
export interface ResizeOutcome {
|
|
28
|
+
/** Clamped width to apply while expanded (ignored when collapse is true). */
|
|
29
|
+
width: number;
|
|
30
|
+
/** True when the drag has gone narrow enough to collapse to the icon rail. */
|
|
31
|
+
collapse: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Given the drag's raw proposed width (start width + pointer delta), decide
|
|
36
|
+
* whether to collapse and, if not, the clamped expanded width.
|
|
37
|
+
*
|
|
38
|
+
* - proposed <= COLLAPSE_THRESHOLD → collapse.
|
|
39
|
+
* - otherwise clamp into [MIN, MAX].
|
|
40
|
+
*/
|
|
41
|
+
export function resolveResize(proposedWidth: number): ResizeOutcome {
|
|
42
|
+
if (proposedWidth <= COLLAPSE_THRESHOLD) {
|
|
43
|
+
return { width: MIN_SIDEBAR_WIDTH, collapse: true };
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
width: Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, proposedWidth)),
|
|
47
|
+
collapse: false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -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) {
|