@brainpilot/web 0.0.9 → 0.0.11

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,8 @@ import { ComposerInput } from "./ComposerInput";
15
15
  import { ComposerSendButton } from "./ComposerSendButton";
16
16
  import { ComposerSendTools } from "./ComposerSendTools";
17
17
  import { MessageStream } from "./MessageStream";
18
+ import { RunningScriptsPanel } from "./RunningScriptsPanel";
19
+ import { selectActiveScripts } from "./runningScripts";
18
20
 
19
21
  export function PromptComposer() {
20
22
  const t = useT();
@@ -69,6 +71,14 @@ export function PromptComposer() {
69
71
  const hasMessages = visibleMessages.length > 0;
70
72
  const isAgentRunning = agents.some((a) => a.status === "running");
71
73
  const lastAssistantStreaming = visibleMessages[visibleMessages.length - 1]?.role === "assistant" && visibleMessages[visibleMessages.length - 1]?.streaming;
74
+ // A bash tool is in flight iff selectActiveScripts finds anything; when it
75
+ // does, the RunningScriptsPanel below the toast owns the Stop button so
76
+ // we don't render a duplicate. When no scripts are running (e.g. the agent
77
+ // is thinking or streaming text), the toast keeps its own Stop.
78
+ const hasActiveScripts = useMemo(
79
+ () => selectActiveScripts(visibleMessages).length > 0,
80
+ [visibleMessages],
81
+ );
72
82
 
73
83
  // Agents whose run is still active. Threaded to MessageStream so a folded
74
84
  // activity block stays "in progress" across ReAct rounds — without this, the
@@ -298,6 +308,7 @@ export function PromptComposer() {
298
308
  showTiming
299
309
  turnTiming={turnTiming}
300
310
  runningAgents={runningAgents}
311
+ groupExpertActivity
301
312
  onAskUserSubmit={(requestId, answer) => void respondToInput(requestId, answer)}
302
313
  onRetryCancel={() => void interruptCurrent()}
303
314
  />
@@ -312,19 +323,26 @@ export function PromptComposer() {
312
323
  return t(label.key, label.vars);
313
324
  })()}
314
325
  </span>
315
- <button
316
- className="agent-running-toast__stop"
317
- type="button"
318
- onClick={() => void interruptCurrent()}
319
- aria-label={t("chat.aria.stop")}
320
- title={t("chat.aria.stop")}
321
- >
322
- <Square size={10} fill="currentColor" />
323
- <span>{t("chat.stop")}</span>
324
- </button>
326
+ {hasActiveScripts ? null : (
327
+ <button
328
+ className="agent-running-toast__stop"
329
+ type="button"
330
+ onClick={() => void interruptCurrent()}
331
+ aria-label={t("chat.aria.stop")}
332
+ title={t("chat.aria.stop")}
333
+ >
334
+ <Square size={10} fill="currentColor" />
335
+ <span>{t("chat.stop")}</span>
336
+ </button>
337
+ )}
325
338
  </div>
326
339
  ) : null}
327
340
 
341
+ <RunningScriptsPanel
342
+ messages={visibleMessages}
343
+ onStop={() => void interruptCurrent()}
344
+ />
345
+
328
346
  <form className="composer" aria-label={t("chat.aria.newPrompt")} onSubmit={handleSubmit}>
329
347
  <ComposerInput
330
348
  sessionId={sessionId}
@@ -0,0 +1,118 @@
1
+ import { ChevronDown, Square } from "lucide-react";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import type { ChatMessage } from "../../contracts/backend";
4
+ import { useT } from "../../i18n/useT";
5
+ import { formatElapsed } from "../../utils/format";
6
+ import { selectActiveScripts } from "./runningScripts";
7
+
8
+ interface Props {
9
+ messages: ChatMessage[];
10
+ onStop: () => void;
11
+ }
12
+
13
+ /**
14
+ * "Running scripts" panel — sits directly above the composer while any bash
15
+ * tool call is in flight, and unmounts the moment the last one ends.
16
+ *
17
+ * The message-stream activity block folds a whole turn's reasoning + tool
18
+ * calls into one collapsed history entry; the composer toast only names the
19
+ * working agent. Neither answers what users most often ask when they see the
20
+ * agent pause: "what shell command is running right now, and can I kill it?"
21
+ *
22
+ * Per-script elapsed timing is derived locally from a ref-held Map keyed by
23
+ * `toolCallId` — the runtime doesn't emit a start timestamp on
24
+ * `tool_call_start`, and per-second precision is fine for a "how long has
25
+ * this been going" affordance. `role="status"` (no `aria-live`) so the
26
+ * once-a-second tick doesn't spam screen readers with elapsed digits.
27
+ */
28
+ export function RunningScriptsPanel({ messages, onStop }: Props) {
29
+ const t = useT();
30
+ const scripts = useMemo(() => selectActiveScripts(messages), [messages]);
31
+
32
+ // New scripts get a stamp the first render they appear in; finished ones
33
+ // are pruned so a re-appearing tool-call-id (shouldn't happen, but be
34
+ // defensive) restarts its clock cleanly.
35
+ const startedAt = useRef<Map<string, number>>(new Map());
36
+ useEffect(() => {
37
+ const now = performance.now();
38
+ const live = new Set(scripts.map((s) => s.id));
39
+ for (const id of live) {
40
+ if (!startedAt.current.has(id)) startedAt.current.set(id, now);
41
+ }
42
+ for (const id of startedAt.current.keys()) {
43
+ if (!live.has(id)) startedAt.current.delete(id);
44
+ }
45
+ }, [scripts]);
46
+
47
+ // Tick once a second while at least one script is in flight so the
48
+ // per-script elapsed advances without needing external state.
49
+ const [, setTick] = useState(0);
50
+ useEffect(() => {
51
+ if (scripts.length === 0) return;
52
+ const id = window.setInterval(() => setTick((n) => n + 1), 1000);
53
+ return () => window.clearInterval(id);
54
+ }, [scripts.length]);
55
+
56
+ // Open by default — the whole point of the panel is that the user sees
57
+ // what's running. `<details>` preserves the user's toggle across re-renders
58
+ // as long as the DOM node is reused (which it is: the panel itself doesn't
59
+ // remount while scripts come and go).
60
+ const [open, setOpen] = useState(true);
61
+
62
+ if (scripts.length === 0) return null;
63
+
64
+ const now = performance.now();
65
+ const starts = Array.from(startedAt.current.values());
66
+ const oldest = starts.length > 0 ? Math.min(...starts) : now;
67
+
68
+ return (
69
+ <div className="running-scripts" role="status">
70
+ <details
71
+ open={open}
72
+ onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
73
+ >
74
+ <summary>
75
+ <span className="running-scripts__dot" aria-hidden="true" />
76
+ <ChevronDown size={14} className="running-scripts__chevron" aria-hidden="true" />
77
+ <span className="running-scripts__label">
78
+ {t("chat.runningScripts.count", { count: scripts.length })}
79
+ {` · ${formatElapsed(now - oldest)}`}
80
+ </span>
81
+ <button
82
+ className="running-scripts__stop"
83
+ type="button"
84
+ onClick={(e) => {
85
+ // Don't toggle the <details> when clicking Stop.
86
+ e.stopPropagation();
87
+ e.preventDefault();
88
+ onStop();
89
+ }}
90
+ aria-label={t("chat.aria.stop")}
91
+ title={t("chat.aria.stop")}
92
+ >
93
+ <Square size={10} fill="currentColor" />
94
+ <span>{t("chat.stop")}</span>
95
+ </button>
96
+ </summary>
97
+ <ul className="running-scripts__list">
98
+ {scripts.map((s) => {
99
+ const start = startedAt.current.get(s.id);
100
+ const elapsed = start === undefined ? "" : formatElapsed(now - start);
101
+ return (
102
+ <li className="running-scripts__item" key={s.id}>
103
+ <div className="running-scripts__item-head">
104
+ <span className="running-scripts__item-agent">{s.agent}</span>
105
+ <span className="running-scripts__item-name">bash</span>
106
+ <span className="running-scripts__item-elapsed">{elapsed}</span>
107
+ </div>
108
+ <pre className="running-scripts__cmd">
109
+ {s.command || t("chat.runningScripts.pending")}
110
+ </pre>
111
+ </li>
112
+ );
113
+ })}
114
+ </ul>
115
+ </details>
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,88 @@
1
+ import type { ChatMessage } from "../../contracts/backend";
2
+
3
+ /**
4
+ * "Running script" panel data model (pure, no React).
5
+ *
6
+ * The composer's toast tells the user *which agent* is thinking, and the
7
+ * message stream's activity block shows the *history* of every reasoning /
8
+ * tool step. Neither surfaces the piece users most often ask for: "what
9
+ * shell command is running right now, and can I stop it?"
10
+ *
11
+ * A "script" here is a tool call whose kind is `tool`, whose name resolves
12
+ * to `bash` (bare or mcp-namespaced), and whose `streaming` flag is still
13
+ * true — i.e. TOOL_CALL_START has arrived but TOOL_CALL_END has not. Once
14
+ * the end event lands, the reducer clears `streaming`, the row falls off
15
+ * this list, and the panel collapses when the last one leaves.
16
+ *
17
+ * Restricting to bash on purpose: filesystem reads/greps/edits are cheap
18
+ * and finish fast, so listing them would just add noise. Long-running
19
+ * work — pytest, wget, training loops — flows through bash.
20
+ */
21
+
22
+ /**
23
+ * Match a tool name against the bash tool, whether it arrived bare
24
+ * (Pi's built-in) or namespaced (`mcp__<server>__bash` from a bridged MCP
25
+ * server). Mirrors the bare-name extraction used by `isInternalToolName`
26
+ * in messageGroups.ts so the two visibility filters stay consistent.
27
+ */
28
+ export function isBashTool(name: string | undefined): boolean {
29
+ if (!name) return false;
30
+ if (name === "bash") return true;
31
+ const bare = name.includes("__") ? name.slice(name.lastIndexOf("__") + 2) : name;
32
+ return bare === "bash";
33
+ }
34
+
35
+ /**
36
+ * Best-effort extraction of the shell command from the tool call args.
37
+ *
38
+ * The reducer feeds `TOOL_CALL_ARGS` deltas verbatim into `toolInput` as a
39
+ * string, so once enough of the JSON has arrived we get `{"command": "..."}`
40
+ * (or `cmd`, depending on the runtime). While the JSON is still partial we
41
+ * just show the raw fragment — better than nothing, and it stops flickering
42
+ * once the delta stream completes.
43
+ */
44
+ export function extractCommand(toolInput: unknown): string {
45
+ if (typeof toolInput !== "string" || toolInput.length === 0) return "";
46
+ const trimmed = toolInput.trim();
47
+ try {
48
+ const parsed = JSON.parse(trimmed);
49
+ if (parsed && typeof parsed === "object") {
50
+ const obj = parsed as Record<string, unknown>;
51
+ for (const key of ["command", "cmd", "script", "shell"]) {
52
+ const value = obj[key];
53
+ if (typeof value === "string" && value.length > 0) return value;
54
+ }
55
+ }
56
+ } catch {
57
+ // Partial JSON while args are still streaming — fall through.
58
+ }
59
+ return trimmed;
60
+ }
61
+
62
+ /** A bash tool call that is still executing. */
63
+ export interface ActiveScript {
64
+ id: string;
65
+ agent: string;
66
+ command: string;
67
+ }
68
+
69
+ /**
70
+ * Distil the flat message log down to the bash calls still in flight, in
71
+ * arrival order. Everything else — text, thinking, non-bash tools, completed
72
+ * bash calls — is filtered out. Callers can trigger the panel purely off
73
+ * `result.length > 0`.
74
+ */
75
+ export function selectActiveScripts(messages: ChatMessage[]): ActiveScript[] {
76
+ const out: ActiveScript[] = [];
77
+ for (const m of messages) {
78
+ if (m.kind !== "tool") continue;
79
+ if (!m.streaming) continue;
80
+ if (!isBashTool(m.toolName)) continue;
81
+ out.push({
82
+ id: m.id,
83
+ agent: m.agent ?? "principal",
84
+ command: extractCommand(m.toolInput),
85
+ });
86
+ }
87
+ return out;
88
+ }
@@ -614,7 +614,7 @@ export function DemoView({ resetSignal }: DemoViewProps = {}) {
614
614
  {condensedMessages.length === 0 ? (
615
615
  <p className="demo-panel__empty">{t("demo.conversation.empty")}</p>
616
616
  ) : (
617
- <MessageStream messages={condensedMessages} showToolbarCount={false} className="demo-message-stream" />
617
+ <MessageStream messages={condensedMessages} showToolbarCount={false} groupExpertActivity className="demo-message-stream" />
618
618
  )}
619
619
  </section>
620
620
 
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
- import { Network, Pause, Play, RefreshCw, Search, UserRoundCog, X } from "lucide-react";
2
+ import { Network, Pause, Play, RefreshCw, Search, X } from "lucide-react";
3
3
  import { TraceNode } from "../../contracts/backend";
4
4
  import { useSessions } from "../../contexts/SessionContext";
5
5
  import { useT } from "../../i18n/useT";
@@ -24,14 +24,10 @@ export function AgentsPanel() {
24
24
  <section className="workspace-panel" aria-labelledby="agents-panel-heading">
25
25
  <div className="workspace-panel__inner workspace-panel__inner--trace">
26
26
  <header className="workspace-panel__header">
27
- <div>
28
- <span className="workspace-panel__eyebrow">
29
- <Network size={11} style={{ marginRight: 4, verticalAlign: "-1px" }} />
30
- {t("trace.agents.eyebrow")}
31
- </span>
32
- <h2 id="agents-panel-heading">{t("trace.agents.title")}</h2>
33
- </div>
34
- <UserRoundCog size={18} />
27
+ <h2 id="agents-panel-heading" className="workspace-panel__title-icon">
28
+ <Network size={18} />
29
+ {t("trace.agents.eyebrow")}
30
+ </h2>
35
31
  </header>
36
32
 
37
33
  {!currentSession ? (