@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.
- package/dist/assets/index-DWOsU22G.css +1 -0
- package/dist/assets/index-j3rGyO6m.js +445 -0
- package/dist/index.html +2 -2
- package/package.json +6 -3
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +118 -0
- package/src/__tests__/chatScrollBehavior.test.ts +48 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +96 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/internalToolStrip.test.ts +108 -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 +104 -56
- package/src/components/chat/PromptComposer.tsx +120 -29
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoView.tsx +98 -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 +72 -17
- package/src/components/sidebar/SessionList.tsx +127 -0
- package/src/components/sidebar/Sidebar.tsx +94 -98
- package/src/contexts/SSEContext.tsx +90 -1
- package/src/contexts/SessionContext.tsx +397 -43
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/messageGroups.ts +56 -0
- package/src/contexts/messageReducer.ts +4 -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/shell.ts +2 -0
- package/src/i18n/messages/trace.ts +69 -17
- package/src/mocks/backend.ts +7 -0
- package/src/styles/global.css +289 -70
- 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,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,
|
|
27
|
+
const { currentSession, currentView, isRefreshingMessages, refreshMessages, setCurrentView, traceUnread } = useSessions();
|
|
27
28
|
const t = useT();
|
|
28
|
-
|
|
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={() =>
|
|
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={() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|