@brainpilot/web 0.0.8 → 0.0.10

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.
@@ -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
- // In single-user local mode the workspace is addressed by the active session
132
- // id (workspaces/<sid>/), not a container id. Elsewhere the sandbox id is the
133
- // addressing key. `currentSandbox.status` still gates whether files are live.
134
- const sandboxId = runtimeConfig.localMode ? currentSession?.id ?? null : currentSandbox?.id ?? null;
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
- const entries = await api.sandbox.listFiles(sandboxId, path);
155
- const children = entries.map((entry) => ({ ...entry, path: joinPath(path, entry.name) }));
156
- setTree((current) => updateNode(current, path, (node) => ({ ...node, children, loaded: true })));
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>/workspace</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>
@@ -822,6 +822,7 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
822
822
  <TimelineTab
823
823
  messages={messages}
824
824
  now={now}
825
+ isRunning={runningCount > 0}
825
826
  onSelectMessage={(agentName) => selectNode(agentName)}
826
827
  />
827
828
  )}
@@ -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) => {