@brainpilot/web 0.0.6 → 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.
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="color-scheme" content="light dark" />
7
7
  <title>BrainPilot</title>
8
- <script type="module" crossorigin src="/assets/index-CeUzk-ej.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-Br55rkHb.css">
8
+ <script type="module" crossorigin src="/assets/index-j3rGyO6m.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DWOsU22G.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@brainpilot/web",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
7
- "license": "Apache-2.0",
7
+ "license": "AGPL-3.0-only",
8
8
  "type": "module",
9
9
  "files": [
10
10
  "dist",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {},
33
33
  "devDependencies": {
34
- "@brainpilot/protocol": "^0.0.6",
34
+ "@brainpilot/protocol": "^0.0.7",
35
35
  "@fontsource-variable/geist": "^5.2.9",
36
36
  "@fontsource-variable/geist-mono": "^5.2.8",
37
37
  "@types/react": "^18.3.12",
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // #133 — chat restore must land at the bottom instantly when the user was
6
+ // pinned, with NO visible top-to-bottom smooth-scroll replay.
7
+ //
8
+ // MessageStream restores scroll position imperatively (`scrollTop = …`) on
9
+ // tab-switch remount and on pinned-bottom live append. A global
10
+ // `scroll-behavior: smooth` on the scroll container turns those instant jumps
11
+ // into an animation through the history — exactly the jumpiness this guards.
12
+ //
13
+ // The repo has no jsdom/happy-dom (see vitest.config.ts), so we can't drive a
14
+ // real scroll. Instead we assert the *intent* at its two sources of truth:
15
+ // 1. the container CSS does not opt the stack into smooth scrolling, and
16
+ // 2. the component pins `scroll-behavior: auto` locally before each
17
+ // imperative scroll write (belt-and-suspenders against an inherited rule).
18
+ const cssPath = fileURLToPath(new URL("../styles/global.css", import.meta.url));
19
+ const streamPath = fileURLToPath(
20
+ new URL("../components/chat/MessageStream.tsx", import.meta.url),
21
+ );
22
+ const css = readFileSync(cssPath, "utf8");
23
+ const stream = readFileSync(streamPath, "utf8");
24
+
25
+ /** Extract the body of a top-level `.selector { … }` rule from a stylesheet. */
26
+ function ruleBody(source: string, selector: string): string {
27
+ const start = source.indexOf(`${selector} {`);
28
+ if (start < 0) throw new Error(`rule not found: ${selector}`);
29
+ const open = source.indexOf("{", start);
30
+ const close = source.indexOf("}", open);
31
+ return source.slice(open + 1, close);
32
+ }
33
+
34
+ describe("#133 chat scroll restore is instant, not smooth", () => {
35
+ it(".message-stack does not declare smooth scrolling", () => {
36
+ // Strip CSS comments first — the rule deliberately documents WHY smooth is
37
+ // absent, and that prose mentions the property name.
38
+ const body = ruleBody(css, ".message-stack").replace(/\/\*[\s\S]*?\*\//g, "");
39
+ expect(body).not.toMatch(/scroll-behavior\s*:\s*smooth/);
40
+ });
41
+
42
+ it("MessageStream forces scroll-behavior auto before imperative restore", () => {
43
+ // The mount-restore effect and the pinned-bottom append effect both set
44
+ // scrollTop; each must pin auto first so neither animates.
45
+ const autoWrites = stream.match(/scrollBehavior\s*=\s*["']auto["']/g) ?? [];
46
+ expect(autoWrites.length).toBeGreaterThanOrEqual(2);
47
+ });
48
+ });
@@ -59,14 +59,37 @@ describe("isDemoConversational (#98 multi-agent transcript)", () => {
59
59
  expect(isDemoConversational(msg({ role: "system", kind: "system_message", content: "" }))).toBe(false);
60
60
  });
61
61
 
62
- it("drops reasoning, tool calls/results, hooks and interactive cards", () => {
62
+ it("drops reasoning, tool calls/results, hooks and auto_retry cards", () => {
63
63
  expect(isDemoConversational(msg({ kind: "thinking", content: "let me think" }))).toBe(false);
64
64
  expect(isDemoConversational(msg({ kind: "tool", content: "Tool: read" }))).toBe(false);
65
65
  expect(isDemoConversational(msg({ role: "system", kind: "hook", content: "reset" }))).toBe(false);
66
- expect(isDemoConversational(msg({ kind: "ask_user", content: "pick one" }))).toBe(false);
67
66
  expect(isDemoConversational(msg({ kind: "auto_retry", content: "retrying" }))).toBe(false);
68
67
  });
69
68
 
69
+ it("keeps an answered ask_user card but drops an unanswered prompt (#132)", () => {
70
+ // Answered: question + user answer are a user-facing decision point, kept as
71
+ // a read-only Q&A step in the replay.
72
+ expect(
73
+ isDemoConversational(
74
+ msg({
75
+ kind: "ask_user",
76
+ content: "pick one",
77
+ askUser: { requestId: "req_1", agent: "principal", question: "pick one", answer: "A" },
78
+ }),
79
+ ),
80
+ ).toBe(true);
81
+ // Unanswered prompt has no meaning in a read-only replay.
82
+ expect(
83
+ isDemoConversational(
84
+ msg({
85
+ kind: "ask_user",
86
+ content: "pick one",
87
+ askUser: { requestId: "req_1", agent: "principal", question: "pick one" },
88
+ }),
89
+ ),
90
+ ).toBe(false);
91
+ });
92
+
70
93
  it("drops empty text placeholders", () => {
71
94
  expect(isDemoConversational(msg({ role: "assistant", kind: "text", content: " " }))).toBe(false);
72
95
  });
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ buildRenderItems,
4
+ isInternalToolName,
5
+ stripInternalToolMessages,
6
+ } from "../contexts/messageGroups";
7
+ import type { ChatMessage } from "../contracts/backend";
8
+
9
+ // #134 — record_trace (and the trace-agent graph tools) are internal plumbing.
10
+ // Their tool call AND result must be hidden from the chat stream, while the
11
+ // model still receives them. These cover the pure presentation filter.
12
+
13
+ function toolCall(over: Partial<ChatMessage> = {}): ChatMessage {
14
+ return {
15
+ id: over.id ?? "call-1",
16
+ role: "assistant",
17
+ content: "Tool: record_trace",
18
+ createdAt: "2026-06-21T00:00:00.000Z",
19
+ agent: "principal",
20
+ kind: "tool",
21
+ toolName: "record_trace",
22
+ ...over,
23
+ };
24
+ }
25
+
26
+ function toolResult(over: Partial<ChatMessage> = {}): ChatMessage {
27
+ return {
28
+ id: over.id ?? "res-1",
29
+ role: "assistant",
30
+ content: "Tool result",
31
+ createdAt: "2026-06-21T00:00:00.000Z",
32
+ agent: "principal",
33
+ kind: "tool",
34
+ toolResult: "trace event dispatched",
35
+ toolCallId: "call-1",
36
+ ...over,
37
+ };
38
+ }
39
+
40
+ function assistantText(content: string): ChatMessage {
41
+ return {
42
+ id: `t-${content}`,
43
+ role: "assistant",
44
+ content,
45
+ createdAt: "2026-06-21T00:00:00.000Z",
46
+ agent: "principal",
47
+ kind: "text",
48
+ };
49
+ }
50
+
51
+ describe("isInternalToolName", () => {
52
+ it("matches bare internal tool names", () => {
53
+ expect(isInternalToolName("record_trace")).toBe(true);
54
+ expect(isInternalToolName("create_trace_node")).toBe(true);
55
+ expect(isInternalToolName("get_trace_graph")).toBe(true);
56
+ });
57
+
58
+ it("matches mcp-namespaced internal tool names", () => {
59
+ expect(isInternalToolName("mcp__brainpilot__record_trace")).toBe(true);
60
+ });
61
+
62
+ it("does not match user-facing tools", () => {
63
+ expect(isInternalToolName("send_message")).toBe(false);
64
+ expect(isInternalToolName("skill_search")).toBe(false);
65
+ expect(isInternalToolName(undefined)).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe("stripInternalToolMessages (#134)", () => {
70
+ it("drops the record_trace call and its linked result", () => {
71
+ const out = stripInternalToolMessages([
72
+ assistantText("before"),
73
+ toolCall(),
74
+ toolResult(),
75
+ assistantText("after"),
76
+ ]);
77
+ expect(out.map((m) => m.id)).toEqual(["t-before", "t-after"]);
78
+ });
79
+
80
+ it("keeps user-facing tool calls and results", () => {
81
+ const sendCall = toolCall({ id: "s1", toolName: "send_message", content: "Tool: send_message" });
82
+ const sendResult = toolResult({ id: "s2", toolCallId: "s1", toolResult: "ok" });
83
+ const out = stripInternalToolMessages([sendCall, sendResult]);
84
+ expect(out.map((m) => m.id)).toEqual(["s1", "s2"]);
85
+ });
86
+
87
+ it("returns the same reference when there is nothing internal", () => {
88
+ const msgs = [assistantText("a"), assistantText("b")];
89
+ expect(stripInternalToolMessages(msgs)).toBe(msgs);
90
+ });
91
+
92
+ it("buildRenderItems hides an isolated internal tool block entirely", () => {
93
+ // A lone record_trace call+result would otherwise fold into one activity
94
+ // block; after stripping there is nothing to render.
95
+ const items = buildRenderItems([toolCall(), toolResult()]);
96
+ expect(items).toEqual([]);
97
+ });
98
+
99
+ it("buildRenderItems keeps surrounding conversation intact", () => {
100
+ const items = buildRenderItems([
101
+ assistantText("question?"),
102
+ toolCall(),
103
+ toolResult(),
104
+ ]);
105
+ expect(items).toHaveLength(1);
106
+ expect(items[0]).toMatchObject({ type: "single" });
107
+ });
108
+ });
@@ -166,6 +166,11 @@ function MessageStreamImpl({
166
166
  const apply = () => {
167
167
  const n = stackRef.current;
168
168
  if (!n) return;
169
+ // #133 — force an instant jump for the restore. The container CSS no
170
+ // longer sets `scroll-behavior: smooth`, but pin it locally too so a
171
+ // future global rule (or an inherited one) can never turn this restore
172
+ // into a visible top-to-bottom replay through the history.
173
+ n.style.scrollBehavior = "auto";
169
174
  n.scrollTop = resolveScrollTop(mem, n.scrollHeight);
170
175
  };
171
176
  apply();
@@ -190,6 +195,8 @@ function MessageStreamImpl({
190
195
  if (!node || !isPinnedRef.current) {
191
196
  return;
192
197
  }
198
+ // #133 — pinned-bottom live append also jumps instantly (no smooth replay).
199
+ node.style.scrollBehavior = "auto";
193
200
  node.scrollTop = node.scrollHeight;
194
201
  }, [messages, autoScroll]);
195
202
 
@@ -55,15 +55,22 @@ function basename(path: string): string {
55
55
  *
56
56
  * Keep: user prompts; assistant/system plain-text replies from ANY agent;
57
57
  * error and system_message bubbles (the agent-attributed warnings/alerts the
58
- * live Chat shows). Drop: reasoning, tool calls/results, hook diagnostics, and
59
- * the interactive ask_user / auto_retry cards (the reasoning graph on the right
60
- * tells the internal story, and the cards have no meaning in a read-only
61
- * replay), plus NO-RENDER placeholders and empties.
58
+ * live Chat shows), plus answered ask_user cards (the question + the user's
59
+ * answer are a user-facing decision point, issue #132 rendered read-only by
60
+ * AskUserCard since DemoView passes no onAskUserSubmit). Drop: reasoning, tool
61
+ * calls/results, hook diagnostics, the auto_retry card and UNANSWERED ask_user
62
+ * prompts (no meaning in a read-only replay), plus NO-RENDER placeholders and
63
+ * empties.
62
64
  */
63
65
  export function isDemoConversational(m: ChatMessage): boolean {
64
66
  if (m.role === "user") {
65
67
  return !!m.content?.trim();
66
68
  }
69
+ // Answered ask_user: keep as a read-only Q&A step. Unanswered prompts have no
70
+ // meaning in a replay and are dropped.
71
+ if (m.kind === "ask_user") {
72
+ return m.askUser?.answer !== undefined;
73
+ }
67
74
  // Agent-attributed warnings/errors the live Chat surfaces as standalone
68
75
  // bubbles. system_message carries its own payload; error carries content.
69
76
  if (m.kind === "system_message") {
@@ -24,9 +24,15 @@ const MAX_SIDEBAR_WIDTH = 420;
24
24
  export function DesktopShell() {
25
25
  const { isAuthReady } = useAuth();
26
26
  const { currentSandbox, operation, error, stats } = useSandbox();
27
- const { currentSession, currentView, isRefreshingMessages, refreshMessages, setCurrentView } = useSessions();
27
+ const { currentSession, currentView, isRefreshingMessages, refreshMessages, setCurrentView, traceUnread } = useSessions();
28
28
  const t = useT();
29
- const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
29
+ // #131 the sidebar collapses to an icon rail either manually (user toggle)
30
+ // or automatically at narrow widths. Both feed the same `isCollapsed` state so
31
+ // the collapsed rail's session popover trigger is available in both cases. A
32
+ // manual toggle wins until the viewport crosses the breakpoint again.
33
+ const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
34
+ const [isNarrow, setIsNarrow] = useState(false);
35
+ const isSidebarCollapsed = userCollapsed ?? isNarrow;
30
36
  const [activePage, setActivePage] = useState<"workspace" | "demo">("workspace");
31
37
  // Bumped on every sidebar "Live Demo" click so DemoView returns to its
32
38
  // session-selection landing even when the demo page is already open (#111).
@@ -49,6 +55,21 @@ export function DesktopShell() {
49
55
  }
50
56
  }, [operation]);
51
57
 
58
+ // #131 — track the narrow breakpoint. Crossing it resets the manual override
59
+ // so the layout follows the viewport again (a user who manually expanded on a
60
+ // wide screen still gets the auto-rail when they shrink the window, and vice
61
+ // versa). 860px matches the existing responsive rail breakpoint in global.css.
62
+ useEffect(() => {
63
+ const mql = window.matchMedia("(max-width: 860px)");
64
+ const apply = () => {
65
+ setIsNarrow(mql.matches);
66
+ setUserCollapsed(null);
67
+ };
68
+ setIsNarrow(mql.matches);
69
+ mql.addEventListener("change", apply);
70
+ return () => mql.removeEventListener("change", apply);
71
+ }, []);
72
+
52
73
  // Show warning dialog once per page session when disk usage is >= 90% but < 100%
53
74
  useEffect(() => {
54
75
  const percent = stats?.disk.percentOfQuota ?? 0;
@@ -128,7 +149,7 @@ export function DesktopShell() {
128
149
  sidebarResizeRef.current = { pointerX, width: sidebarWidth };
129
150
  setIsSidebarResizing(true);
130
151
  }}
131
- onToggle={() => setIsSidebarCollapsed((current) => !current)}
152
+ onToggle={() => setUserCollapsed(!isSidebarCollapsed)}
132
153
  />
133
154
 
134
155
  {activePage === "demo" ? (
@@ -189,7 +210,7 @@ export function DesktopShell() {
189
210
  </button>
190
211
  <button
191
212
  aria-selected={currentView === "trace"}
192
- className={currentView === "trace" ? "is-active" : ""}
213
+ className={`workspace-view-tab--badged ${currentView === "trace" ? "is-active" : ""}`}
193
214
  onClick={() => setCurrentView("trace")}
194
215
  role="tab"
195
216
  title={t("shell.view.trace")}
@@ -197,6 +218,15 @@ export function DesktopShell() {
197
218
  >
198
219
  <GitBranch size={14} />
199
220
  <span className="sr-only">{t("shell.view.trace")}</span>
221
+ {/* #134 — quiet unread dot: trace changed for this session and
222
+ the user hasn't opened the Trace view since. Cleared on open. */}
223
+ {traceUnread && currentView !== "trace" ? (
224
+ <span
225
+ className="workspace-view-tab__badge"
226
+ aria-label={t("shell.view.traceUpdated")}
227
+ role="status"
228
+ />
229
+ ) : null}
200
230
  </button>
201
231
  </div>
202
232
  {currentView === "chat" ? (
@@ -0,0 +1,127 @@
1
+ import { Check, MessageCircle, PenLine, Search, Trash2, X } from "lucide-react";
2
+ import { FormEvent, useState } from "react";
3
+ import type { Session } from "../../contracts/backend";
4
+ import { useT } from "../../i18n/useT";
5
+ import { IconButton } from "../primitives/IconButton";
6
+
7
+ type SessionListProps = {
8
+ sessions: Session[];
9
+ currentId: string | undefined;
10
+ isLoading: boolean;
11
+ /** Select an existing session (callers also switch to the workspace page). */
12
+ onSelect: (sessionId: string) => void;
13
+ /** Rename a session by id. */
14
+ onRename: (sessionId: string, title: string) => void | Promise<void>;
15
+ /** Delete a session by id. */
16
+ onDelete: (sessionId: string) => void | Promise<void>;
17
+ /** Open the search dialog. */
18
+ onOpenSearch: () => void;
19
+ };
20
+
21
+ /**
22
+ * #131 — the conversation list, extracted from Sidebar so the same markup and
23
+ * rename/delete affordances render both inline (expanded sidebar) and inside
24
+ * the icon-rail session popover. Owns only its transient edit/confirm UI state;
25
+ * the session data and mutations are passed in by the host.
26
+ */
27
+ export function SessionList({
28
+ sessions,
29
+ currentId,
30
+ isLoading,
31
+ onSelect,
32
+ onRename,
33
+ onDelete,
34
+ onOpenSearch,
35
+ }: SessionListProps) {
36
+ const t = useT();
37
+ const [editingId, setEditingId] = useState<string | null>(null);
38
+ const [editingTitle, setEditingTitle] = useState("");
39
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
40
+
41
+ const submitRename = async (event: FormEvent) => {
42
+ event.preventDefault();
43
+ if (!editingId || !editingTitle.trim()) {
44
+ setEditingId(null);
45
+ return;
46
+ }
47
+ await onRename(editingId, editingTitle.trim());
48
+ setEditingId(null);
49
+ };
50
+
51
+ return (
52
+ <div className="conversation-stack">
53
+ <button className="conversation-search-trigger" onClick={onOpenSearch} type="button">
54
+ <Search size={14} />
55
+ <span>{t("sidebar.search")}</span>
56
+ </button>
57
+ <p className="muted-label">
58
+ {isLoading ? t("sidebar.loading") : t("sidebar.sessionCount", { count: sessions.length })}
59
+ </p>
60
+ {sessions.length === 0 && !isLoading ? <p className="sidebar-empty">{t("sidebar.empty")}</p> : null}
61
+ {sessions.map((session) => {
62
+ const isEditing = editingId === session.id;
63
+ const isConfirming = confirmDeleteId === session.id;
64
+ return (
65
+ <div className={`conversation-item ${currentId === session.id ? "is-active" : ""}`} key={session.id}>
66
+ {isEditing ? (
67
+ <form className="conversation-edit" onSubmit={submitRename}>
68
+ <input
69
+ autoFocus
70
+ onChange={(event) => setEditingTitle(event.target.value)}
71
+ value={editingTitle}
72
+ />
73
+ <IconButton label={t("sidebar.aria.saveTitle")} type="submit">
74
+ <Check size={14} />
75
+ </IconButton>
76
+ <IconButton label={t("sidebar.aria.cancelRename")} onClick={() => setEditingId(null)}>
77
+ <X size={14} />
78
+ </IconButton>
79
+ </form>
80
+ ) : (
81
+ <>
82
+ <button className="conversation-row" onClick={() => onSelect(session.id)} type="button">
83
+ <MessageCircle size={16} />
84
+ <span>{session.title}</span>
85
+ <small>{new Date(session.updatedAt).toLocaleDateString()}</small>
86
+ </button>
87
+ <div className="conversation-actions">
88
+ {isConfirming ? (
89
+ <>
90
+ <IconButton
91
+ label={t("sidebar.aria.confirmDelete")}
92
+ onClick={() => {
93
+ void onDelete(session.id);
94
+ setConfirmDeleteId(null);
95
+ }}
96
+ >
97
+ <Check size={14} />
98
+ </IconButton>
99
+ <IconButton label={t("sidebar.aria.cancelDelete")} onClick={() => setConfirmDeleteId(null)}>
100
+ <X size={14} />
101
+ </IconButton>
102
+ </>
103
+ ) : (
104
+ <>
105
+ <IconButton
106
+ label={t("sidebar.aria.rename")}
107
+ onClick={() => {
108
+ setEditingId(session.id);
109
+ setEditingTitle(session.title);
110
+ }}
111
+ >
112
+ <PenLine size={14} />
113
+ </IconButton>
114
+ <IconButton label={t("sidebar.aria.delete")} onClick={() => setConfirmDeleteId(session.id)}>
115
+ <Trash2 size={14} />
116
+ </IconButton>
117
+ </>
118
+ )}
119
+ </div>
120
+ </>
121
+ )}
122
+ </div>
123
+ );
124
+ })}
125
+ </div>
126
+ );
127
+ }