@brainpilot/web 0.0.5 → 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.
Files changed (58) hide show
  1. package/dist/assets/index-DWOsU22G.css +1 -0
  2. package/dist/assets/index-j3rGyO6m.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +6 -3
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollBehavior.test.ts +48 -0
  8. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  9. package/src/__tests__/demoConversation.test.ts +96 -0
  10. package/src/__tests__/demoReset.test.ts +24 -0
  11. package/src/__tests__/internalToolStrip.test.ts +108 -0
  12. package/src/__tests__/runningToast.test.ts +29 -0
  13. package/src/__tests__/tokenUsage.test.ts +48 -0
  14. package/src/__tests__/toolDisplay.test.ts +55 -0
  15. package/src/__tests__/traceReducer.test.ts +62 -0
  16. package/src/components/chat/MessageStream.tsx +104 -56
  17. package/src/components/chat/PromptComposer.tsx +120 -29
  18. package/src/components/chat/chatScrollMemory.ts +49 -0
  19. package/src/components/demo/DemoView.tsx +98 -29
  20. package/src/components/demo/TraceNodeModal.tsx +6 -2
  21. package/src/components/demo/demoBundle.ts +7 -2
  22. package/src/components/demo/demoReset.ts +16 -0
  23. package/src/components/session/AgentNetwork.tsx +68 -75
  24. package/src/components/session/AgentTraceViews.tsx +35 -70
  25. package/src/components/session/AnalyticsTab.tsx +58 -224
  26. package/src/components/session/TraceGraphView.tsx +36 -30
  27. package/src/components/session/TraceNodeDetail.tsx +61 -24
  28. package/src/components/session/agentNetworkShared.ts +10 -0
  29. package/src/components/session/traceLayout.ts +32 -0
  30. package/src/components/settings/SettingsDialog.tsx +19 -1
  31. package/src/components/shell/DesktopShell.tsx +72 -17
  32. package/src/components/sidebar/SessionList.tsx +127 -0
  33. package/src/components/sidebar/Sidebar.tsx +94 -98
  34. package/src/contexts/SSEContext.tsx +90 -1
  35. package/src/contexts/SessionContext.tsx +397 -43
  36. package/src/contexts/agentsReducer.ts +49 -0
  37. package/src/contexts/messageGroups.ts +56 -0
  38. package/src/contexts/messageReducer.ts +4 -0
  39. package/src/contexts/runningToast.ts +33 -0
  40. package/src/contexts/traceReducer.ts +62 -0
  41. package/src/contexts/turnTimer.test.ts +97 -0
  42. package/src/contexts/turnTimer.ts +108 -0
  43. package/src/contexts/useTurnTimer.ts +104 -0
  44. package/src/contracts/backend.ts +53 -2
  45. package/src/i18n/messages/analytics.ts +16 -6
  46. package/src/i18n/messages/chat.ts +26 -4
  47. package/src/i18n/messages/contexts.ts +2 -0
  48. package/src/i18n/messages/network.ts +13 -9
  49. package/src/i18n/messages/profile.ts +4 -0
  50. package/src/i18n/messages/settings.ts +4 -0
  51. package/src/i18n/messages/shell.ts +2 -0
  52. package/src/i18n/messages/trace.ts +69 -17
  53. package/src/mocks/backend.ts +7 -0
  54. package/src/styles/global.css +289 -70
  55. package/src/utils/api.ts +105 -8
  56. package/src/utils/toolDisplay.ts +74 -0
  57. package/dist/assets/index-C-8G4D4j.js +0 -448
  58. 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,19 @@ 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, traceUnread } = useSessions();
27
28
  const t = useT();
28
- 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;
29
36
  const [activePage, setActivePage] = useState<"workspace" | "demo">("workspace");
37
+ // Bumped on every sidebar "Live Demo" click so DemoView returns to its
38
+ // session-selection landing even when the demo page is already open (#111).
39
+ const [demoResetSignal, setDemoResetSignal] = useState(0);
30
40
  const [sidebarWidth, setSidebarWidth] = useState(268);
31
41
  const [isSidebarResizing, setIsSidebarResizing] = useState(false);
32
42
  const [isSearchOpen, setIsSearchOpen] = useState(false);
@@ -45,6 +55,21 @@ export function DesktopShell() {
45
55
  }
46
56
  }, [operation]);
47
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
+
48
73
  // Show warning dialog once per page session when disk usage is >= 90% but < 100%
49
74
  useEffect(() => {
50
75
  const percent = stats?.disk.percentOfQuota ?? 0;
@@ -109,7 +134,10 @@ export function DesktopShell() {
109
134
  <Sidebar
110
135
  isCollapsed={isSidebarCollapsed}
111
136
  activePage={activePage}
112
- onOpenDemo={() => setActivePage("demo")}
137
+ onOpenDemo={() => {
138
+ setActivePage("demo");
139
+ setDemoResetSignal((n) => n + 1);
140
+ }}
113
141
  onGoWorkspace={() => setActivePage("workspace")}
114
142
  onOpenSettings={() => setIsSettingsOpen(true)}
115
143
  onOpenSearch={() => setIsSearchOpen(true)}
@@ -121,11 +149,11 @@ export function DesktopShell() {
121
149
  sidebarResizeRef.current = { pointerX, width: sidebarWidth };
122
150
  setIsSidebarResizing(true);
123
151
  }}
124
- onToggle={() => setIsSidebarCollapsed((current) => !current)}
152
+ onToggle={() => setUserCollapsed(!isSidebarCollapsed)}
125
153
  />
126
154
 
127
155
  {activePage === "demo" ? (
128
- <DemoView />
156
+ <DemoView resetSignal={demoResetSignal} />
129
157
  ) : (
130
158
  <main
131
159
  className={`workspace ${isFilesOpen ? "workspace--files-open" : ""} ${
@@ -136,47 +164,69 @@ export function DesktopShell() {
136
164
  >
137
165
  <header className="workspace-toolbar" aria-label={t("shell.aria.toolbarActions")}>
138
166
  <div className="session-title" aria-label={t("shell.aria.activeSession")}>
139
- <span className="session-title__label">{t("shell.sessionLabel")}</span>
167
+ {/* #105: foreground the human-readable session title (same source as
168
+ the sidebar). The id is debug-only metadata now — surfaced as a
169
+ hover tooltip + muted short id, never the primary label. Falls
170
+ back to `Session <id8>` when the title is missing. */}
171
+ <span
172
+ className="session-title__name"
173
+ title={currentSession?.id ?? undefined}
174
+ >
175
+ {currentSession?.title ||
176
+ (currentSession?.id
177
+ ? `${t("shell.sessionLabel")} ${currentSession.id.slice(0, 8)}`
178
+ : t("shell.defaultWorkspace"))}
179
+ </span>
140
180
  {currentSession?.id ? (
141
181
  <span className="session-title__id">{currentSession.id.slice(0, 8)}</span>
142
182
  ) : null}
143
- {messages.length === 0 ? (
144
- <span className="session-title__name">
145
- {currentSession?.title || t("shell.defaultWorkspace")}
146
- </span>
147
- ) : null}
148
183
  </div>
149
184
  <div className="workspace-toolbar__actions">
150
- <div className="workspace-view-tabs" role="tablist" aria-label={t("shell.aria.viewTabs")}>
185
+ {/* #104: icon-only nav. The label stays in the DOM (visually
186
+ hidden) so it remains the button's accessible name, and `title`
187
+ gives a hover/focus tooltip — no separate aria-label needed. */}
188
+ <div className="workspace-view-tabs workspace-view-tabs--icon-only" role="tablist" aria-label={t("shell.aria.viewTabs")}>
151
189
  <button
152
190
  aria-selected={currentView === "chat"}
153
191
  className={currentView === "chat" ? "is-active" : ""}
154
192
  onClick={() => setCurrentView("chat")}
155
193
  role="tab"
194
+ title={t("shell.view.chat")}
156
195
  type="button"
157
196
  >
158
197
  <MessageSquare size={14} />
159
- <span>{t("shell.view.chat")}</span>
198
+ <span className="sr-only">{t("shell.view.chat")}</span>
160
199
  </button>
161
200
  <button
162
201
  aria-selected={currentView === "agents"}
163
202
  className={currentView === "agents" ? "is-active" : ""}
164
203
  onClick={() => setCurrentView("agents")}
165
204
  role="tab"
205
+ title={t("shell.view.agents")}
166
206
  type="button"
167
207
  >
168
208
  <Bot size={14} />
169
- <span>{t("shell.view.agents")}</span>
209
+ <span className="sr-only">{t("shell.view.agents")}</span>
170
210
  </button>
171
211
  <button
172
212
  aria-selected={currentView === "trace"}
173
- className={currentView === "trace" ? "is-active" : ""}
213
+ className={`workspace-view-tab--badged ${currentView === "trace" ? "is-active" : ""}`}
174
214
  onClick={() => setCurrentView("trace")}
175
215
  role="tab"
216
+ title={t("shell.view.trace")}
176
217
  type="button"
177
218
  >
178
219
  <GitBranch size={14} />
179
- <span>{t("shell.view.trace")}</span>
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}
180
230
  </button>
181
231
  </div>
182
232
  {currentView === "chat" ? (
@@ -188,7 +238,12 @@ export function DesktopShell() {
188
238
  <RefreshCw size={14} />
189
239
  </IconButton>
190
240
  ) : null}
191
- <SandboxStatus />
241
+ {/* #100: in local single-user mode there is no Docker sandbox to
242
+ inspect — the runtime IS the workspace, so the Sandbox status
243
+ popover would only show empty container metrics and read like a
244
+ fault. Hide it here; downstream multi-user Docker builds set
245
+ VITE_LOCAL_MODE=0 and keep the real container UI. */}
246
+ {runtimeConfig.localMode ? null : <SandboxStatus />}
192
247
  <IconButton
193
248
  aria-pressed={isFilesOpen}
194
249
  className={isFilesOpen ? "is-active" : ""}
@@ -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
+ }