@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.
- package/dist/assets/index-Br55rkHb.css +1 -0
- package/dist/assets/index-CeUzk-ej.js +445 -0
- package/dist/index.html +2 -2
- package/package.json +5 -2
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +118 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +73 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/runningToast.test.ts +29 -0
- package/src/__tests__/tokenUsage.test.ts +48 -0
- package/src/__tests__/toolDisplay.test.ts +55 -0
- package/src/__tests__/traceReducer.test.ts +62 -0
- package/src/components/chat/MessageStream.tsx +97 -56
- package/src/components/chat/PromptComposer.tsx +120 -29
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoView.tsx +91 -29
- package/src/components/demo/TraceNodeModal.tsx +6 -2
- package/src/components/demo/demoBundle.ts +7 -2
- package/src/components/demo/demoReset.ts +16 -0
- package/src/components/session/AgentNetwork.tsx +68 -75
- package/src/components/session/AgentTraceViews.tsx +35 -70
- package/src/components/session/AnalyticsTab.tsx +58 -224
- package/src/components/session/TraceGraphView.tsx +36 -30
- package/src/components/session/TraceNodeDetail.tsx +61 -24
- package/src/components/session/agentNetworkShared.ts +10 -0
- package/src/components/session/traceLayout.ts +32 -0
- package/src/components/settings/SettingsDialog.tsx +19 -1
- package/src/components/shell/DesktopShell.tsx +39 -14
- package/src/components/sidebar/Sidebar.tsx +6 -2
- package/src/contexts/SSEContext.tsx +90 -1
- package/src/contexts/SessionContext.tsx +354 -43
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/runningToast.ts +33 -0
- package/src/contexts/traceReducer.ts +62 -0
- package/src/contexts/turnTimer.test.ts +97 -0
- package/src/contexts/turnTimer.ts +108 -0
- package/src/contexts/useTurnTimer.ts +104 -0
- package/src/contracts/backend.ts +53 -2
- package/src/i18n/messages/analytics.ts +16 -6
- package/src/i18n/messages/chat.ts +26 -4
- package/src/i18n/messages/contexts.ts +2 -0
- package/src/i18n/messages/network.ts +13 -9
- package/src/i18n/messages/profile.ts +4 -0
- package/src/i18n/messages/settings.ts +4 -0
- package/src/i18n/messages/trace.ts +69 -17
- package/src/mocks/backend.ts +7 -0
- package/src/styles/global.css +204 -55
- package/src/utils/api.ts +105 -8
- package/src/utils/toolDisplay.ts +74 -0
- package/dist/assets/index-C-8G4D4j.js +0 -448
- 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>
|
|
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>{
|
|
45
|
-
<span>{
|
|
46
|
-
|
|
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
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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} />
|
|
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"}
|
|
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} />
|
|
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} />
|
|
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} />
|
|
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} />
|
|
157
|
+
<h4><Clock3 size={13} /> {t("trace.node.timeline")}</h4>
|
|
129
158
|
<dl>
|
|
130
159
|
<div>
|
|
131
|
-
<dt>
|
|
160
|
+
<dt>{t("trace.node.created")}</dt>
|
|
132
161
|
<dd>{formatTime(node.timestamp?.createdAt || node.createdAt)}</dd>
|
|
133
162
|
</div>
|
|
134
|
-
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
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,
|
|
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={() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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],
|