@brainpilot/web 0.0.5 → 0.0.6

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.
Files changed (52) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +5 -2
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  8. package/src/__tests__/demoConversation.test.ts +73 -0
  9. package/src/__tests__/demoReset.test.ts +24 -0
  10. package/src/__tests__/runningToast.test.ts +29 -0
  11. package/src/__tests__/tokenUsage.test.ts +48 -0
  12. package/src/__tests__/toolDisplay.test.ts +55 -0
  13. package/src/__tests__/traceReducer.test.ts +62 -0
  14. package/src/components/chat/MessageStream.tsx +97 -56
  15. package/src/components/chat/PromptComposer.tsx +120 -29
  16. package/src/components/chat/chatScrollMemory.ts +49 -0
  17. package/src/components/demo/DemoView.tsx +91 -29
  18. package/src/components/demo/TraceNodeModal.tsx +6 -2
  19. package/src/components/demo/demoBundle.ts +7 -2
  20. package/src/components/demo/demoReset.ts +16 -0
  21. package/src/components/session/AgentNetwork.tsx +68 -75
  22. package/src/components/session/AgentTraceViews.tsx +35 -70
  23. package/src/components/session/AnalyticsTab.tsx +58 -224
  24. package/src/components/session/TraceGraphView.tsx +36 -30
  25. package/src/components/session/TraceNodeDetail.tsx +61 -24
  26. package/src/components/session/agentNetworkShared.ts +10 -0
  27. package/src/components/session/traceLayout.ts +32 -0
  28. package/src/components/settings/SettingsDialog.tsx +19 -1
  29. package/src/components/shell/DesktopShell.tsx +39 -14
  30. package/src/components/sidebar/Sidebar.tsx +6 -2
  31. package/src/contexts/SSEContext.tsx +90 -1
  32. package/src/contexts/SessionContext.tsx +354 -43
  33. package/src/contexts/agentsReducer.ts +49 -0
  34. package/src/contexts/runningToast.ts +33 -0
  35. package/src/contexts/traceReducer.ts +62 -0
  36. package/src/contexts/turnTimer.test.ts +97 -0
  37. package/src/contexts/turnTimer.ts +108 -0
  38. package/src/contexts/useTurnTimer.ts +104 -0
  39. package/src/contracts/backend.ts +53 -2
  40. package/src/i18n/messages/analytics.ts +16 -6
  41. package/src/i18n/messages/chat.ts +26 -4
  42. package/src/i18n/messages/contexts.ts +2 -0
  43. package/src/i18n/messages/network.ts +13 -9
  44. package/src/i18n/messages/profile.ts +4 -0
  45. package/src/i18n/messages/settings.ts +4 -0
  46. package/src/i18n/messages/trace.ts +69 -17
  47. package/src/mocks/backend.ts +7 -0
  48. package/src/styles/global.css +204 -55
  49. package/src/utils/api.ts +105 -8
  50. package/src/utils/toolDisplay.ts +74 -0
  51. package/dist/assets/index-C-8G4D4j.js +0 -448
  52. package/dist/assets/index-C501m5OS.css +0 -1
@@ -1,6 +1,7 @@
1
1
  import { AlertTriangle, ArrowRight, Box, Clock3, FileText, GitBranch, Timer, Wrench } from "lucide-react";
2
2
  import { TraceNode } from "../../contracts/backend";
3
3
  import { TranslateVars } from "../../i18n/translate";
4
+ import { formatToolName } from "../../utils/toolDisplay";
4
5
  import {
5
6
  artifactLabels,
6
7
  formatDuration,
@@ -13,11 +14,13 @@ import {
13
14
 
14
15
  interface TraceNodeDetailProps {
15
16
  node: TraceNode | null;
17
+ nodes?: TraceNode[];
16
18
  onSelectNode: (id: string) => void;
17
19
  /** When provided, artifact rows become buttons that focus that file. */
18
20
  onSelectArtifact?: (path: string) => void;
19
21
  /** Currently focused artifact path (for highlight). */
20
22
  activeArtifactPath?: string | null;
23
+ formatKind?: (kind: string) => string;
21
24
  t: (key: string, vars?: TranslateVars) => string;
22
25
  }
23
26
 
@@ -26,11 +29,31 @@ interface TraceNodeDetailProps {
26
29
  * TracePanel so the live trace view and the demo replay share it. In the demo
27
30
  * an `onSelectArtifact` handler wires artifact rows to the file preview.
28
31
  */
29
- export function TraceNodeDetail({ node, onSelectNode, onSelectArtifact, activeArtifactPath, t }: TraceNodeDetailProps) {
32
+ export function TraceNodeDetail({ node, nodes, onSelectNode, onSelectArtifact, activeArtifactPath, formatKind, t }: TraceNodeDetailProps) {
30
33
  if (!node) {
31
- return <p>No trace node selected.</p>;
34
+ return <p>{t("trace.node.noneSelected")}</p>;
32
35
  }
33
36
  const statusKey = getStatusLabelKey(node.status);
37
+ const nodeById = new Map((nodes ?? []).map((item) => [item.id, item]));
38
+ const kind = getNodeKind(node);
39
+ const kindLabel = formatKind?.(kind) ?? kind;
40
+ const parentLabel = (id: string) =>
41
+ nodeById.get(id)?.title || t("trace.node.parentFallback");
42
+ const childNodes = node.childIds
43
+ .map((id) => ({ id, title: nodeById.get(id)?.title }))
44
+ .filter((item) => item.title);
45
+ const metrics = [
46
+ node.durationMs !== undefined
47
+ ? { key: "duration", icon: <Timer size={13} />, label: formatDuration(node.durationMs) }
48
+ : null,
49
+ node.toolCalls.length > 0
50
+ ? { key: "tools", icon: <Wrench size={13} />, label: t("trace.node.tools", { count: node.toolCalls.length }) }
51
+ : null,
52
+ node.artifacts.length > 0
53
+ ? { key: "artifacts", icon: <Box size={13} />, label: t("trace.node.artifacts", { count: node.artifacts.length }) }
54
+ : null,
55
+ ].filter((item): item is { key: string; icon: JSX.Element; label: string } => item !== null);
56
+
34
57
  return (
35
58
  <>
36
59
  <div className="trace-detail__title">
@@ -41,9 +64,13 @@ export function TraceNodeDetail({ node, onSelectNode, onSelectArtifact, activeAr
41
64
  </span>
42
65
  </div>
43
66
  <div className="trace-detail__badges">
44
- <span>{node.id}</span>
45
- <span>{getNodeKind(node)}</span>
46
- <span>{node.agent || "agent unknown"}</span>
67
+ <span title={kind}>{kindLabel}</span>
68
+ {node.agent ? <span>{node.agent}</span> : null}
69
+ {node.metadata?.auto ? (
70
+ <span className="trace-detail__badge--auto" title={t("trace.node.autoTitle")}>
71
+ {t("trace.node.auto")}
72
+ </span>
73
+ ) : null}
47
74
  </div>
48
75
  <p>{node.summary || node.description || node.content || "No summary recorded."}</p>
49
76
  {node.reason ? (
@@ -58,19 +85,21 @@ export function TraceNodeDetail({ node, onSelectNode, onSelectArtifact, activeAr
58
85
  <p>{node.context}</p>
59
86
  </section>
60
87
  ) : null}
61
- <div className="trace-detail__metrics">
62
- <span><Timer size={13} /> {formatDuration(node.durationMs)}</span>
63
- <span><Wrench size={13} /> {node.toolCalls.length} tools</span>
64
- <span><Box size={13} /> {node.artifacts.length} artifacts</span>
65
- </div>
88
+ {metrics.length > 0 ? (
89
+ <div className="trace-detail__metrics">
90
+ {metrics.map((metric) => (
91
+ <span key={metric.key}>{metric.icon} {metric.label}</span>
92
+ ))}
93
+ </div>
94
+ ) : null}
66
95
  {node.parents.length > 0 ? (
67
96
  <section className="trace-detail__section">
68
- <h4><GitBranch size={13} /> Dependencies</h4>
97
+ <h4><GitBranch size={13} /> {t("trace.node.dependencies")}</h4>
69
98
  <div className="trace-relation-list">
70
99
  {node.parents.map((parent) => (
71
- <button key={parent.id} onClick={() => onSelectNode(parent.id)} type="button">
72
- <strong>{parent.id}</strong>
73
- <span>{relationLabels[parent.relation || ""] || parent.relation || "parent"}{parent.edgeType ? ` · ${parent.edgeType}` : ""}</span>
100
+ <button key={parent.id} onClick={() => onSelectNode(parent.id)} title={parent.id} type="button">
101
+ <strong>{parentLabel(parent.id)}</strong>
102
+ <span>{relationLabels[parent.relation || ""] || parent.relation || "parent"}</span>
74
103
  {parent.explanation ? <small>{parent.explanation}</small> : null}
75
104
  </button>
76
105
  ))}
@@ -79,21 +108,21 @@ export function TraceNodeDetail({ node, onSelectNode, onSelectArtifact, activeAr
79
108
  ) : null}
80
109
  {node.toolCalls.length > 0 ? (
81
110
  <section className="trace-detail__section">
82
- <h4><Wrench size={13} /> Tool Calls</h4>
111
+ <h4><Wrench size={13} /> {t("trace.node.toolCalls")}</h4>
83
112
  <div className="trace-chip-list">
84
- {node.toolCalls.map((tool) => <span key={tool}>{tool}</span>)}
113
+ {node.toolCalls.map((tool) => <span key={tool} title={tool}>{formatToolName(tool)}</span>)}
85
114
  </div>
86
115
  </section>
87
116
  ) : null}
88
117
  {node.errorMessage ? (
89
118
  <section className="trace-detail__section trace-detail__section--error">
90
- <h4><AlertTriangle size={13} /> Error</h4>
119
+ <h4><AlertTriangle size={13} /> {t("trace.node.error")}</h4>
91
120
  <p>{node.errorMessage}</p>
92
121
  </section>
93
122
  ) : null}
94
123
  {node.artifacts.length > 0 ? (
95
124
  <section className="trace-detail__section">
96
- <h4><Box size={13} /> Artifacts</h4>
125
+ <h4><Box size={13} /> {t("trace.node.artifactsTitle")}</h4>
97
126
  <div className="trace-artifact-list">
98
127
  {node.artifacts.map((artifact) => {
99
128
  const label = artifactLabels[artifact.type || ""] || artifact.type || "file";
@@ -125,16 +154,24 @@ export function TraceNodeDetail({ node, onSelectNode, onSelectArtifact, activeAr
125
154
  </section>
126
155
  ) : null}
127
156
  <section className="trace-detail__section">
128
- <h4><Clock3 size={13} /> Timeline</h4>
157
+ <h4><Clock3 size={13} /> {t("trace.node.timeline")}</h4>
129
158
  <dl>
130
159
  <div>
131
- <dt>Created</dt>
160
+ <dt>{t("trace.node.created")}</dt>
132
161
  <dd>{formatTime(node.timestamp?.createdAt || node.createdAt)}</dd>
133
162
  </div>
134
- <div>
135
- <dt>Children</dt>
136
- <dd>{node.childIds.join(", ") || "-"}</dd>
137
- </div>
163
+ {childNodes.length > 0 ? (
164
+ <div>
165
+ <dt>{t("trace.node.children")}</dt>
166
+ <dd className="trace-detail__children">
167
+ {childNodes.map((child) => (
168
+ <button key={child.id} onClick={() => onSelectNode(child.id)} title={child.id} type="button">
169
+ {child.title}
170
+ </button>
171
+ ))}
172
+ </dd>
173
+ </div>
174
+ ) : null}
138
175
  </dl>
139
176
  </section>
140
177
  </>
@@ -13,6 +13,7 @@ import {
13
13
  GitBranch,
14
14
  Microscope,
15
15
  PenLine,
16
+ ShieldCheck,
16
17
  Sparkles,
17
18
  UserRoundCog,
18
19
  Wrench,
@@ -92,6 +93,13 @@ export const AGENT_PROFILES: Record<string, AgentProfile> = {
92
93
  accent: "warning",
93
94
  defaultTools: ["Read", "Write", "Grep", "Bash", "send_message"],
94
95
  },
96
+ auditor: {
97
+ displayName: "Auditor",
98
+ role: "profile.auditor.role",
99
+ description: "profile.auditor.desc",
100
+ accent: "danger",
101
+ defaultTools: ["Read", "Grep", "Bash", "Write", "send_message", "record_trace"],
102
+ },
95
103
  user: {
96
104
  displayName: "You",
97
105
  role: "profile.user.role",
@@ -127,6 +135,7 @@ export const BUILTIN_AGENT_NAMES = [
127
135
  "experimentalist",
128
136
  "engineer",
129
137
  "writer",
138
+ "auditor",
130
139
  ] as const;
131
140
 
132
141
  /* --------------------------------------------------------------------------
@@ -278,6 +287,7 @@ export function getAgentIcon(name: string) {
278
287
  if (normalized.includes("experiment")) return Microscope;
279
288
  if (normalized.includes("engineer")) return Wrench;
280
289
  if (normalized.includes("writer")) return PenLine;
290
+ if (normalized.includes("audit")) return ShieldCheck;
281
291
  if (normalized.includes("idea") || normalized.includes("creat")) return Sparkles;
282
292
  return Bot;
283
293
  }
@@ -31,6 +31,35 @@ export function getNodeKind(node: TraceNode): string {
31
31
  return node.nodeType || node.type || "step";
32
32
  }
33
33
 
34
+ export function getNodeKindLabelKey(kind: string): string | null {
35
+ switch (kind) {
36
+ case "task":
37
+ return "trace.kind.task";
38
+ case "trace":
39
+ return "trace.kind.trace";
40
+ case "action":
41
+ return "trace.kind.action";
42
+ case "observation":
43
+ return "trace.kind.observation";
44
+ case "decision":
45
+ return "trace.kind.decision";
46
+ case "milestone":
47
+ return "trace.kind.milestone";
48
+ case "validation":
49
+ return "trace.kind.validation";
50
+ case "audit":
51
+ return "trace.kind.audit";
52
+ case "writing":
53
+ return "trace.kind.writing";
54
+ case "research":
55
+ return "trace.kind.research";
56
+ case "step":
57
+ return "trace.kind.step";
58
+ default:
59
+ return null;
60
+ }
61
+ }
62
+
34
63
  export function truncateNodeTitle(title?: string, maxUnits = 26): string {
35
64
  if (!title) {
36
65
  return "";
@@ -65,6 +94,9 @@ export const relationLabels: Record<string, string> = {
65
94
  used: "used",
66
95
  produced: "produced",
67
96
  comparison_with: "compared with",
97
+ follows: "then",
98
+ depends_on: "depends on",
99
+ parent: "parent",
68
100
  };
69
101
 
70
102
  export const artifactLabels: Record<string, string> = {
@@ -1,7 +1,7 @@
1
1
  import { FormEvent, useEffect, useState } from "react";
2
2
  import { Check, Eye, EyeOff, Loader2, Plug, Plus, Settings, SlidersHorizontal, Trash2, UserRound, X } from "lucide-react";
3
3
  import type { LucideIcon } from "lucide-react";
4
- import type { McpServerEntry, ProviderProfile } from "../../contracts/backend";
4
+ import type { McpServerEntry, ProviderProfile, ProviderApi } from "../../contracts/backend";
5
5
  import { useAuth } from "../../contexts/AuthContext";
6
6
  import { usePreferences } from "../../contexts/PreferencesContext";
7
7
  import { useT } from "../../i18n/useT";
@@ -26,6 +26,7 @@ const tabs: Array<{ id: SettingsTab; labelKey: string; icon: LucideIcon }> = [
26
26
  const DEFAULT_PROVIDER_FORM = {
27
27
  name: "",
28
28
  baseUrl: "https://api.anthropic.com",
29
+ api: "anthropic-messages" as ProviderApi,
29
30
  apiKey: "",
30
31
  apiKeyMasked: "",
31
32
  models: ["claude-opus-4-6"],
@@ -184,6 +185,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
184
185
  ? await api.providers.update(editingProviderId, {
185
186
  name: providerForm.name,
186
187
  baseUrl: providerForm.baseUrl,
188
+ api: providerForm.api,
187
189
  ...(providerForm.apiKey ? { apiKey: providerForm.apiKey } : {}),
188
190
  models,
189
191
  iconColor: providerForm.iconColor,
@@ -192,6 +194,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
192
194
  : await api.providers.create({
193
195
  name: providerForm.name,
194
196
  baseUrl: providerForm.baseUrl,
197
+ api: providerForm.api,
195
198
  apiKey: providerForm.apiKey,
196
199
  models,
197
200
  iconColor: providerForm.iconColor,
@@ -214,6 +217,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
214
217
  setProviderForm({
215
218
  name: provider.name,
216
219
  baseUrl: provider.baseUrl,
220
+ api: provider.api,
217
221
  apiKey: "",
218
222
  apiKeyMasked: provider.apiKeyMasked || "",
219
223
  models: provider.models.length ? provider.models : [""],
@@ -562,6 +566,20 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
562
566
  <span>{t("settings.providerForm.baseUrl")}</span>
563
567
  <input placeholder="https://api.anthropic.com" required value={providerForm.baseUrl} onChange={(event) => setProviderForm({ ...providerForm, baseUrl: event.target.value })} />
564
568
  </label>
569
+ <div className="provider-form__field">
570
+ <span>{t("settings.providerForm.protocol")}</span>
571
+ <CustomSelect
572
+ ariaLabel={t("settings.providerForm.protocolAria")}
573
+ onChange={(value) => setProviderForm({ ...providerForm, api: value as ProviderApi })}
574
+ options={[
575
+ { label: "Anthropic Messages", value: "anthropic-messages" },
576
+ { label: "OpenAI Completions", value: "openai-completions" },
577
+ { label: "OpenAI Responses", value: "openai-responses" },
578
+ { label: "Azure OpenAI Responses", value: "azure-openai-responses" },
579
+ ]}
580
+ value={providerForm.api}
581
+ />
582
+ </div>
565
583
  <label className="provider-form__key">
566
584
  <span>{t("settings.providerForm.apiKey")} {editingProviderId ? t("settings.providerForm.apiKeyKeep") : ""}</span>
567
585
  <input
@@ -4,6 +4,7 @@ import { useAuth } from "../../contexts/AuthContext";
4
4
  import { useSandbox } from "../../contexts/SandboxContext";
5
5
  import { useSessions } from "../../contexts/SessionContext";
6
6
  import { useT } from "../../i18n/useT";
7
+ import { runtimeConfig } from "../../config";
7
8
  import { PromptComposer } from "../chat/PromptComposer";
8
9
  import { DemoView } from "../demo/DemoView";
9
10
  import { FileSidebar } from "../files/FileSidebar";
@@ -23,10 +24,13 @@ const MAX_SIDEBAR_WIDTH = 420;
23
24
  export function DesktopShell() {
24
25
  const { isAuthReady } = useAuth();
25
26
  const { currentSandbox, operation, error, stats } = useSandbox();
26
- const { currentSession, currentView, messages, isRefreshingMessages, refreshMessages, setCurrentView } = useSessions();
27
+ const { currentSession, currentView, isRefreshingMessages, refreshMessages, setCurrentView } = useSessions();
27
28
  const t = useT();
28
29
  const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
29
30
  const [activePage, setActivePage] = useState<"workspace" | "demo">("workspace");
31
+ // Bumped on every sidebar "Live Demo" click so DemoView returns to its
32
+ // session-selection landing even when the demo page is already open (#111).
33
+ const [demoResetSignal, setDemoResetSignal] = useState(0);
30
34
  const [sidebarWidth, setSidebarWidth] = useState(268);
31
35
  const [isSidebarResizing, setIsSidebarResizing] = useState(false);
32
36
  const [isSearchOpen, setIsSearchOpen] = useState(false);
@@ -109,7 +113,10 @@ export function DesktopShell() {
109
113
  <Sidebar
110
114
  isCollapsed={isSidebarCollapsed}
111
115
  activePage={activePage}
112
- onOpenDemo={() => setActivePage("demo")}
116
+ onOpenDemo={() => {
117
+ setActivePage("demo");
118
+ setDemoResetSignal((n) => n + 1);
119
+ }}
113
120
  onGoWorkspace={() => setActivePage("workspace")}
114
121
  onOpenSettings={() => setIsSettingsOpen(true)}
115
122
  onOpenSearch={() => setIsSearchOpen(true)}
@@ -125,7 +132,7 @@ export function DesktopShell() {
125
132
  />
126
133
 
127
134
  {activePage === "demo" ? (
128
- <DemoView />
135
+ <DemoView resetSignal={demoResetSignal} />
129
136
  ) : (
130
137
  <main
131
138
  className={`workspace ${isFilesOpen ? "workspace--files-open" : ""} ${
@@ -136,47 +143,60 @@ export function DesktopShell() {
136
143
  >
137
144
  <header className="workspace-toolbar" aria-label={t("shell.aria.toolbarActions")}>
138
145
  <div className="session-title" aria-label={t("shell.aria.activeSession")}>
139
- <span className="session-title__label">{t("shell.sessionLabel")}</span>
146
+ {/* #105: foreground the human-readable session title (same source as
147
+ the sidebar). The id is debug-only metadata now — surfaced as a
148
+ hover tooltip + muted short id, never the primary label. Falls
149
+ back to `Session <id8>` when the title is missing. */}
150
+ <span
151
+ className="session-title__name"
152
+ title={currentSession?.id ?? undefined}
153
+ >
154
+ {currentSession?.title ||
155
+ (currentSession?.id
156
+ ? `${t("shell.sessionLabel")} ${currentSession.id.slice(0, 8)}`
157
+ : t("shell.defaultWorkspace"))}
158
+ </span>
140
159
  {currentSession?.id ? (
141
160
  <span className="session-title__id">{currentSession.id.slice(0, 8)}</span>
142
161
  ) : null}
143
- {messages.length === 0 ? (
144
- <span className="session-title__name">
145
- {currentSession?.title || t("shell.defaultWorkspace")}
146
- </span>
147
- ) : null}
148
162
  </div>
149
163
  <div className="workspace-toolbar__actions">
150
- <div className="workspace-view-tabs" role="tablist" aria-label={t("shell.aria.viewTabs")}>
164
+ {/* #104: icon-only nav. The label stays in the DOM (visually
165
+ hidden) so it remains the button's accessible name, and `title`
166
+ gives a hover/focus tooltip — no separate aria-label needed. */}
167
+ <div className="workspace-view-tabs workspace-view-tabs--icon-only" role="tablist" aria-label={t("shell.aria.viewTabs")}>
151
168
  <button
152
169
  aria-selected={currentView === "chat"}
153
170
  className={currentView === "chat" ? "is-active" : ""}
154
171
  onClick={() => setCurrentView("chat")}
155
172
  role="tab"
173
+ title={t("shell.view.chat")}
156
174
  type="button"
157
175
  >
158
176
  <MessageSquare size={14} />
159
- <span>{t("shell.view.chat")}</span>
177
+ <span className="sr-only">{t("shell.view.chat")}</span>
160
178
  </button>
161
179
  <button
162
180
  aria-selected={currentView === "agents"}
163
181
  className={currentView === "agents" ? "is-active" : ""}
164
182
  onClick={() => setCurrentView("agents")}
165
183
  role="tab"
184
+ title={t("shell.view.agents")}
166
185
  type="button"
167
186
  >
168
187
  <Bot size={14} />
169
- <span>{t("shell.view.agents")}</span>
188
+ <span className="sr-only">{t("shell.view.agents")}</span>
170
189
  </button>
171
190
  <button
172
191
  aria-selected={currentView === "trace"}
173
192
  className={currentView === "trace" ? "is-active" : ""}
174
193
  onClick={() => setCurrentView("trace")}
175
194
  role="tab"
195
+ title={t("shell.view.trace")}
176
196
  type="button"
177
197
  >
178
198
  <GitBranch size={14} />
179
- <span>{t("shell.view.trace")}</span>
199
+ <span className="sr-only">{t("shell.view.trace")}</span>
180
200
  </button>
181
201
  </div>
182
202
  {currentView === "chat" ? (
@@ -188,7 +208,12 @@ export function DesktopShell() {
188
208
  <RefreshCw size={14} />
189
209
  </IconButton>
190
210
  ) : null}
191
- <SandboxStatus />
211
+ {/* #100: in local single-user mode there is no Docker sandbox to
212
+ inspect — the runtime IS the workspace, so the Sandbox status
213
+ popover would only show empty container metrics and read like a
214
+ fault. Hide it here; downstream multi-user Docker builds set
215
+ VITE_LOCAL_MODE=0 and keep the real container UI. */}
216
+ {runtimeConfig.localMode ? null : <SandboxStatus />}
192
217
  <IconButton
193
218
  aria-pressed={isFilesOpen}
194
219
  className={isFilesOpen ? "is-active" : ""}
@@ -1,12 +1,10 @@
1
1
  import {
2
- Clock3,
3
2
  Check,
4
3
  MessageCircle,
5
4
  MessageSquarePlus,
6
5
  MonitorPlay,
7
6
  PanelLeft,
8
7
  PenLine,
9
- Plug,
10
8
  Search,
11
9
  Settings,
12
10
  Trash2,
@@ -80,6 +78,11 @@ export function Sidebar({ isCollapsed, activePage, onOpenDemo, onGoWorkspace, on
80
78
  <PenLine size={16} />
81
79
  <span>{t("sidebar.newChat")}</span>
82
80
  </button>
81
+ {/*
82
+ issue #44: 插件 / 自动化 have no view yet — as clickable no-op buttons
83
+ they read as broken navigation. Hidden until the views exist; the
84
+ i18n keys (sidebar.plugins / sidebar.automations) are kept. Re-add the
85
+ Plug / Clock3 lucide imports when restoring these.
83
86
  <button className="nav-item" type="button">
84
87
  <Plug size={16} />
85
88
  <span>{t("sidebar.plugins")}</span>
@@ -88,6 +91,7 @@ export function Sidebar({ isCollapsed, activePage, onOpenDemo, onGoWorkspace, on
88
91
  <Clock3 size={16} />
89
92
  <span>{t("sidebar.automations")}</span>
90
93
  </button>
94
+ */}
91
95
  <button
92
96
  className={`nav-item ${activePage === "demo" ? "is-active" : ""}`}
93
97
  onClick={onOpenDemo}
@@ -25,11 +25,18 @@ const SSEContext = createContext<SSEContextValue | null>(null);
25
25
 
26
26
  const RECONNECT_BASE_MS = 3000;
27
27
  const RECONNECT_MAX_MS = 30000;
28
+ // #106: if an EventSource never fires `onopen` within this window we treat the
29
+ // connection as dead and force a rebuild. A frozen tab / bfcache restore can
30
+ // leave a stale source stuck in CONNECTING whose onopen/onerror never fire
31
+ // again — without this watchdog the UI sits on "正在连接实时通道" forever.
32
+ const OPEN_WATCHDOG_MS = 8000;
28
33
 
29
34
  interface SessionConn {
30
35
  source: EventSource;
31
36
  reconnectAttempt: number;
32
37
  reconnectTimer: number | null;
38
+ /** #106: fires if onopen doesn't arrive in time — forces a reconnect. */
39
+ openWatchdog: number | null;
33
40
  /** Whether disconnectSession was called — disable auto-reconnect. */
34
41
  manuallyClosed: boolean;
35
42
  }
@@ -61,20 +68,62 @@ export function SSEProvider({ children }: { children: ReactNode }) {
61
68
  return;
62
69
  }
63
70
 
71
+ // A stale entry may exist (e.g. watchdog-forced rebuild) — clear its timers
72
+ // and close its source before replacing it.
73
+ if (conn) {
74
+ if (conn.reconnectTimer !== null) window.clearTimeout(conn.reconnectTimer);
75
+ if (conn.openWatchdog !== null) window.clearTimeout(conn.openWatchdog);
76
+ try {
77
+ conn.source.close();
78
+ } catch {
79
+ /* already closed */
80
+ }
81
+ }
82
+
64
83
  console.log(`[SSE] openConnection: ${sessionId}`);
65
84
  setStatus(sessionId, "connecting");
66
85
  const source = new EventSource(getSSEUrl(sessionId));
67
86
 
68
87
  const entry: SessionConn = {
69
88
  source,
70
- reconnectAttempt: 0,
89
+ reconnectAttempt: conn?.reconnectAttempt ?? 0,
71
90
  reconnectTimer: null,
91
+ openWatchdog: null,
72
92
  manuallyClosed: false,
73
93
  };
74
94
  connsRef.current.set(sessionId, entry);
75
95
 
96
+ // #106: if onopen never lands, the connection is wedged. Tear it down and
97
+ // reconnect through the normal backoff path so the composer doesn't stay
98
+ // disabled on a dead "connecting" state.
99
+ entry.openWatchdog = window.setTimeout(() => {
100
+ entry.openWatchdog = null;
101
+ if (entry.manuallyClosed) return;
102
+ if (entry.source.readyState === EventSource.OPEN) return;
103
+ console.warn(`[SSE] open watchdog fired for ${sessionId} — forcing reconnect`);
104
+ try {
105
+ entry.source.close();
106
+ } catch {
107
+ /* already closed */
108
+ }
109
+ setStatus(sessionId, "error");
110
+ entry.reconnectAttempt += 1;
111
+ const delay = Math.min(
112
+ RECONNECT_BASE_MS * Math.pow(2, entry.reconnectAttempt - 1),
113
+ RECONNECT_MAX_MS,
114
+ );
115
+ entry.reconnectTimer = window.setTimeout(() => {
116
+ entry.reconnectTimer = null;
117
+ openConnection(sessionId);
118
+ }, delay);
119
+ }, OPEN_WATCHDOG_MS);
120
+
76
121
  source.onopen = () => {
77
122
  entry.reconnectAttempt = 0;
123
+ if (entry.openWatchdog !== null) {
124
+ window.clearTimeout(entry.openWatchdog);
125
+ entry.openWatchdog = null;
126
+ }
78
127
  console.log(`[SSE] onopen: ${sessionId}`);
79
128
  setStatus(sessionId, "open");
80
129
  };
@@ -106,6 +155,10 @@ export function SSEProvider({ children }: { children: ReactNode }) {
106
155
 
107
156
  source.onerror = () => {
108
157
  console.error(`[SSE] onerror: ${sessionId}, reconnectAttempt=${entry.reconnectAttempt + 1}`);
158
+ if (entry.openWatchdog !== null) {
159
+ window.clearTimeout(entry.openWatchdog);
160
+ entry.openWatchdog = null;
161
+ }
109
162
  setStatus(sessionId, "error");
110
163
  source.close();
111
164
  if (entry.manuallyClosed) return;
@@ -139,6 +192,10 @@ export function SSEProvider({ children }: { children: ReactNode }) {
139
192
  window.clearTimeout(entry.reconnectTimer);
140
193
  entry.reconnectTimer = null;
141
194
  }
195
+ if (entry.openWatchdog !== null) {
196
+ window.clearTimeout(entry.openWatchdog);
197
+ entry.openWatchdog = null;
198
+ }
142
199
  entry.source.close();
143
200
  connsRef.current.delete(sessionId);
144
201
  setStatus(sessionId, "idle");
@@ -152,12 +209,44 @@ export function SSEProvider({ children }: { children: ReactNode }) {
152
209
  if (entry.reconnectTimer !== null) {
153
210
  window.clearTimeout(entry.reconnectTimer);
154
211
  }
212
+ if (entry.openWatchdog !== null) {
213
+ window.clearTimeout(entry.openWatchdog);
214
+ }
155
215
  entry.source.close();
156
216
  }
157
217
  connsRef.current.clear();
158
218
  };
159
219
  }, [isAuthReady, currentSandbox?.status]);
160
220
 
221
+ // #106: bfcache / frozen-tab restore can leave an EventSource that looks
222
+ // alive (readyState !== CLOSED) but whose onopen/onerror never fire again, so
223
+ // the composer stays stuck on "connecting". On page restore or tab
224
+ // re-focus, force any non-open connection to rebuild. The browser-native
225
+ // `pageshow` (persisted) covers bfcache; `visibilitychange` covers the more
226
+ // common "switched away and back" case.
227
+ useEffect(() => {
228
+ const revive = () => {
229
+ for (const [sessionId, entry] of connsRef.current) {
230
+ if (entry.manuallyClosed) continue;
231
+ if (entry.source.readyState === EventSource.OPEN) continue;
232
+ console.log(`[SSE] revive stale connection on restore: ${sessionId}`);
233
+ openConnection(sessionId);
234
+ }
235
+ };
236
+ const onPageShow = (event: PageTransitionEvent) => {
237
+ if (event.persisted) revive();
238
+ };
239
+ const onVisibility = () => {
240
+ if (document.visibilityState === "visible") revive();
241
+ };
242
+ window.addEventListener("pageshow", onPageShow);
243
+ document.addEventListener("visibilitychange", onVisibility);
244
+ return () => {
245
+ window.removeEventListener("pageshow", onPageShow);
246
+ document.removeEventListener("visibilitychange", onVisibility);
247
+ };
248
+ }, [openConnection]);
249
+
161
250
  const value = useMemo<SSEContextValue>(
162
251
  () => ({ connectSession, disconnectSession, queueRef, tick, connections }),
163
252
  [connectSession, disconnectSession, tick, connections],