@brainpilot/web 0.0.6 → 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 +3 -3
- package/src/__tests__/chatScrollBehavior.test.ts +48 -0
- package/src/__tests__/demoConversation.test.ts +25 -2
- package/src/__tests__/internalToolStrip.test.ts +108 -0
- package/src/components/chat/MessageStream.tsx +7 -0
- package/src/components/demo/DemoView.tsx +11 -4
- package/src/components/shell/DesktopShell.tsx +34 -4
- package/src/components/sidebar/SessionList.tsx +127 -0
- package/src/components/sidebar/Sidebar.tsx +92 -100
- package/src/contexts/SessionContext.tsx +44 -1
- package/src/contexts/messageGroups.ts +56 -0
- package/src/contexts/messageReducer.ts +4 -0
- package/src/i18n/messages/shell.ts +2 -0
- package/src/styles/global.css +85 -15
- package/dist/assets/index-Br55rkHb.css +0 -1
- package/dist/assets/index-CeUzk-ej.js +0 -445
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<meta name="color-scheme" content="light dark" />
|
|
7
7
|
<title>BrainPilot</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-j3rGyO6m.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DWOsU22G.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brainpilot/web",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22"
|
|
6
6
|
},
|
|
7
|
-
"license": "
|
|
7
|
+
"license": "AGPL-3.0-only",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@brainpilot/protocol": "^0.0.
|
|
34
|
+
"@brainpilot/protocol": "^0.0.7",
|
|
35
35
|
"@fontsource-variable/geist": "^5.2.9",
|
|
36
36
|
"@fontsource-variable/geist-mono": "^5.2.8",
|
|
37
37
|
"@types/react": "^18.3.12",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
// #133 — chat restore must land at the bottom instantly when the user was
|
|
6
|
+
// pinned, with NO visible top-to-bottom smooth-scroll replay.
|
|
7
|
+
//
|
|
8
|
+
// MessageStream restores scroll position imperatively (`scrollTop = …`) on
|
|
9
|
+
// tab-switch remount and on pinned-bottom live append. A global
|
|
10
|
+
// `scroll-behavior: smooth` on the scroll container turns those instant jumps
|
|
11
|
+
// into an animation through the history — exactly the jumpiness this guards.
|
|
12
|
+
//
|
|
13
|
+
// The repo has no jsdom/happy-dom (see vitest.config.ts), so we can't drive a
|
|
14
|
+
// real scroll. Instead we assert the *intent* at its two sources of truth:
|
|
15
|
+
// 1. the container CSS does not opt the stack into smooth scrolling, and
|
|
16
|
+
// 2. the component pins `scroll-behavior: auto` locally before each
|
|
17
|
+
// imperative scroll write (belt-and-suspenders against an inherited rule).
|
|
18
|
+
const cssPath = fileURLToPath(new URL("../styles/global.css", import.meta.url));
|
|
19
|
+
const streamPath = fileURLToPath(
|
|
20
|
+
new URL("../components/chat/MessageStream.tsx", import.meta.url),
|
|
21
|
+
);
|
|
22
|
+
const css = readFileSync(cssPath, "utf8");
|
|
23
|
+
const stream = readFileSync(streamPath, "utf8");
|
|
24
|
+
|
|
25
|
+
/** Extract the body of a top-level `.selector { … }` rule from a stylesheet. */
|
|
26
|
+
function ruleBody(source: string, selector: string): string {
|
|
27
|
+
const start = source.indexOf(`${selector} {`);
|
|
28
|
+
if (start < 0) throw new Error(`rule not found: ${selector}`);
|
|
29
|
+
const open = source.indexOf("{", start);
|
|
30
|
+
const close = source.indexOf("}", open);
|
|
31
|
+
return source.slice(open + 1, close);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("#133 chat scroll restore is instant, not smooth", () => {
|
|
35
|
+
it(".message-stack does not declare smooth scrolling", () => {
|
|
36
|
+
// Strip CSS comments first — the rule deliberately documents WHY smooth is
|
|
37
|
+
// absent, and that prose mentions the property name.
|
|
38
|
+
const body = ruleBody(css, ".message-stack").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
39
|
+
expect(body).not.toMatch(/scroll-behavior\s*:\s*smooth/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("MessageStream forces scroll-behavior auto before imperative restore", () => {
|
|
43
|
+
// The mount-restore effect and the pinned-bottom append effect both set
|
|
44
|
+
// scrollTop; each must pin auto first so neither animates.
|
|
45
|
+
const autoWrites = stream.match(/scrollBehavior\s*=\s*["']auto["']/g) ?? [];
|
|
46
|
+
expect(autoWrites.length).toBeGreaterThanOrEqual(2);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -59,14 +59,37 @@ describe("isDemoConversational (#98 multi-agent transcript)", () => {
|
|
|
59
59
|
expect(isDemoConversational(msg({ role: "system", kind: "system_message", content: "" }))).toBe(false);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it("drops reasoning, tool calls/results, hooks and
|
|
62
|
+
it("drops reasoning, tool calls/results, hooks and auto_retry cards", () => {
|
|
63
63
|
expect(isDemoConversational(msg({ kind: "thinking", content: "let me think" }))).toBe(false);
|
|
64
64
|
expect(isDemoConversational(msg({ kind: "tool", content: "Tool: read" }))).toBe(false);
|
|
65
65
|
expect(isDemoConversational(msg({ role: "system", kind: "hook", content: "reset" }))).toBe(false);
|
|
66
|
-
expect(isDemoConversational(msg({ kind: "ask_user", content: "pick one" }))).toBe(false);
|
|
67
66
|
expect(isDemoConversational(msg({ kind: "auto_retry", content: "retrying" }))).toBe(false);
|
|
68
67
|
});
|
|
69
68
|
|
|
69
|
+
it("keeps an answered ask_user card but drops an unanswered prompt (#132)", () => {
|
|
70
|
+
// Answered: question + user answer are a user-facing decision point, kept as
|
|
71
|
+
// a read-only Q&A step in the replay.
|
|
72
|
+
expect(
|
|
73
|
+
isDemoConversational(
|
|
74
|
+
msg({
|
|
75
|
+
kind: "ask_user",
|
|
76
|
+
content: "pick one",
|
|
77
|
+
askUser: { requestId: "req_1", agent: "principal", question: "pick one", answer: "A" },
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
// Unanswered prompt has no meaning in a read-only replay.
|
|
82
|
+
expect(
|
|
83
|
+
isDemoConversational(
|
|
84
|
+
msg({
|
|
85
|
+
kind: "ask_user",
|
|
86
|
+
content: "pick one",
|
|
87
|
+
askUser: { requestId: "req_1", agent: "principal", question: "pick one" },
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
70
93
|
it("drops empty text placeholders", () => {
|
|
71
94
|
expect(isDemoConversational(msg({ role: "assistant", kind: "text", content: " " }))).toBe(false);
|
|
72
95
|
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildRenderItems,
|
|
4
|
+
isInternalToolName,
|
|
5
|
+
stripInternalToolMessages,
|
|
6
|
+
} from "../contexts/messageGroups";
|
|
7
|
+
import type { ChatMessage } from "../contracts/backend";
|
|
8
|
+
|
|
9
|
+
// #134 — record_trace (and the trace-agent graph tools) are internal plumbing.
|
|
10
|
+
// Their tool call AND result must be hidden from the chat stream, while the
|
|
11
|
+
// model still receives them. These cover the pure presentation filter.
|
|
12
|
+
|
|
13
|
+
function toolCall(over: Partial<ChatMessage> = {}): ChatMessage {
|
|
14
|
+
return {
|
|
15
|
+
id: over.id ?? "call-1",
|
|
16
|
+
role: "assistant",
|
|
17
|
+
content: "Tool: record_trace",
|
|
18
|
+
createdAt: "2026-06-21T00:00:00.000Z",
|
|
19
|
+
agent: "principal",
|
|
20
|
+
kind: "tool",
|
|
21
|
+
toolName: "record_trace",
|
|
22
|
+
...over,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toolResult(over: Partial<ChatMessage> = {}): ChatMessage {
|
|
27
|
+
return {
|
|
28
|
+
id: over.id ?? "res-1",
|
|
29
|
+
role: "assistant",
|
|
30
|
+
content: "Tool result",
|
|
31
|
+
createdAt: "2026-06-21T00:00:00.000Z",
|
|
32
|
+
agent: "principal",
|
|
33
|
+
kind: "tool",
|
|
34
|
+
toolResult: "trace event dispatched",
|
|
35
|
+
toolCallId: "call-1",
|
|
36
|
+
...over,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assistantText(content: string): ChatMessage {
|
|
41
|
+
return {
|
|
42
|
+
id: `t-${content}`,
|
|
43
|
+
role: "assistant",
|
|
44
|
+
content,
|
|
45
|
+
createdAt: "2026-06-21T00:00:00.000Z",
|
|
46
|
+
agent: "principal",
|
|
47
|
+
kind: "text",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("isInternalToolName", () => {
|
|
52
|
+
it("matches bare internal tool names", () => {
|
|
53
|
+
expect(isInternalToolName("record_trace")).toBe(true);
|
|
54
|
+
expect(isInternalToolName("create_trace_node")).toBe(true);
|
|
55
|
+
expect(isInternalToolName("get_trace_graph")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("matches mcp-namespaced internal tool names", () => {
|
|
59
|
+
expect(isInternalToolName("mcp__brainpilot__record_trace")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not match user-facing tools", () => {
|
|
63
|
+
expect(isInternalToolName("send_message")).toBe(false);
|
|
64
|
+
expect(isInternalToolName("skill_search")).toBe(false);
|
|
65
|
+
expect(isInternalToolName(undefined)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("stripInternalToolMessages (#134)", () => {
|
|
70
|
+
it("drops the record_trace call and its linked result", () => {
|
|
71
|
+
const out = stripInternalToolMessages([
|
|
72
|
+
assistantText("before"),
|
|
73
|
+
toolCall(),
|
|
74
|
+
toolResult(),
|
|
75
|
+
assistantText("after"),
|
|
76
|
+
]);
|
|
77
|
+
expect(out.map((m) => m.id)).toEqual(["t-before", "t-after"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("keeps user-facing tool calls and results", () => {
|
|
81
|
+
const sendCall = toolCall({ id: "s1", toolName: "send_message", content: "Tool: send_message" });
|
|
82
|
+
const sendResult = toolResult({ id: "s2", toolCallId: "s1", toolResult: "ok" });
|
|
83
|
+
const out = stripInternalToolMessages([sendCall, sendResult]);
|
|
84
|
+
expect(out.map((m) => m.id)).toEqual(["s1", "s2"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns the same reference when there is nothing internal", () => {
|
|
88
|
+
const msgs = [assistantText("a"), assistantText("b")];
|
|
89
|
+
expect(stripInternalToolMessages(msgs)).toBe(msgs);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("buildRenderItems hides an isolated internal tool block entirely", () => {
|
|
93
|
+
// A lone record_trace call+result would otherwise fold into one activity
|
|
94
|
+
// block; after stripping there is nothing to render.
|
|
95
|
+
const items = buildRenderItems([toolCall(), toolResult()]);
|
|
96
|
+
expect(items).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("buildRenderItems keeps surrounding conversation intact", () => {
|
|
100
|
+
const items = buildRenderItems([
|
|
101
|
+
assistantText("question?"),
|
|
102
|
+
toolCall(),
|
|
103
|
+
toolResult(),
|
|
104
|
+
]);
|
|
105
|
+
expect(items).toHaveLength(1);
|
|
106
|
+
expect(items[0]).toMatchObject({ type: "single" });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -166,6 +166,11 @@ function MessageStreamImpl({
|
|
|
166
166
|
const apply = () => {
|
|
167
167
|
const n = stackRef.current;
|
|
168
168
|
if (!n) return;
|
|
169
|
+
// #133 — force an instant jump for the restore. The container CSS no
|
|
170
|
+
// longer sets `scroll-behavior: smooth`, but pin it locally too so a
|
|
171
|
+
// future global rule (or an inherited one) can never turn this restore
|
|
172
|
+
// into a visible top-to-bottom replay through the history.
|
|
173
|
+
n.style.scrollBehavior = "auto";
|
|
169
174
|
n.scrollTop = resolveScrollTop(mem, n.scrollHeight);
|
|
170
175
|
};
|
|
171
176
|
apply();
|
|
@@ -190,6 +195,8 @@ function MessageStreamImpl({
|
|
|
190
195
|
if (!node || !isPinnedRef.current) {
|
|
191
196
|
return;
|
|
192
197
|
}
|
|
198
|
+
// #133 — pinned-bottom live append also jumps instantly (no smooth replay).
|
|
199
|
+
node.style.scrollBehavior = "auto";
|
|
193
200
|
node.scrollTop = node.scrollHeight;
|
|
194
201
|
}, [messages, autoScroll]);
|
|
195
202
|
|
|
@@ -55,15 +55,22 @@ function basename(path: string): string {
|
|
|
55
55
|
*
|
|
56
56
|
* Keep: user prompts; assistant/system plain-text replies from ANY agent;
|
|
57
57
|
* error and system_message bubbles (the agent-attributed warnings/alerts the
|
|
58
|
-
* live Chat shows)
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
58
|
+
* live Chat shows), plus answered ask_user cards (the question + the user's
|
|
59
|
+
* answer are a user-facing decision point, issue #132 — rendered read-only by
|
|
60
|
+
* AskUserCard since DemoView passes no onAskUserSubmit). Drop: reasoning, tool
|
|
61
|
+
* calls/results, hook diagnostics, the auto_retry card and UNANSWERED ask_user
|
|
62
|
+
* prompts (no meaning in a read-only replay), plus NO-RENDER placeholders and
|
|
63
|
+
* empties.
|
|
62
64
|
*/
|
|
63
65
|
export function isDemoConversational(m: ChatMessage): boolean {
|
|
64
66
|
if (m.role === "user") {
|
|
65
67
|
return !!m.content?.trim();
|
|
66
68
|
}
|
|
69
|
+
// Answered ask_user: keep as a read-only Q&A step. Unanswered prompts have no
|
|
70
|
+
// meaning in a replay and are dropped.
|
|
71
|
+
if (m.kind === "ask_user") {
|
|
72
|
+
return m.askUser?.answer !== undefined;
|
|
73
|
+
}
|
|
67
74
|
// Agent-attributed warnings/errors the live Chat surfaces as standalone
|
|
68
75
|
// bubbles. system_message carries its own payload; error carries content.
|
|
69
76
|
if (m.kind === "system_message") {
|
|
@@ -24,9 +24,15 @@ const MAX_SIDEBAR_WIDTH = 420;
|
|
|
24
24
|
export function DesktopShell() {
|
|
25
25
|
const { isAuthReady } = useAuth();
|
|
26
26
|
const { currentSandbox, operation, error, stats } = useSandbox();
|
|
27
|
-
const { currentSession, currentView, isRefreshingMessages, refreshMessages, setCurrentView } = useSessions();
|
|
27
|
+
const { currentSession, currentView, isRefreshingMessages, refreshMessages, setCurrentView, traceUnread } = useSessions();
|
|
28
28
|
const t = useT();
|
|
29
|
-
|
|
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;
|
|
30
36
|
const [activePage, setActivePage] = useState<"workspace" | "demo">("workspace");
|
|
31
37
|
// Bumped on every sidebar "Live Demo" click so DemoView returns to its
|
|
32
38
|
// session-selection landing even when the demo page is already open (#111).
|
|
@@ -49,6 +55,21 @@ export function DesktopShell() {
|
|
|
49
55
|
}
|
|
50
56
|
}, [operation]);
|
|
51
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
|
+
|
|
52
73
|
// Show warning dialog once per page session when disk usage is >= 90% but < 100%
|
|
53
74
|
useEffect(() => {
|
|
54
75
|
const percent = stats?.disk.percentOfQuota ?? 0;
|
|
@@ -128,7 +149,7 @@ export function DesktopShell() {
|
|
|
128
149
|
sidebarResizeRef.current = { pointerX, width: sidebarWidth };
|
|
129
150
|
setIsSidebarResizing(true);
|
|
130
151
|
}}
|
|
131
|
-
onToggle={() =>
|
|
152
|
+
onToggle={() => setUserCollapsed(!isSidebarCollapsed)}
|
|
132
153
|
/>
|
|
133
154
|
|
|
134
155
|
{activePage === "demo" ? (
|
|
@@ -189,7 +210,7 @@ export function DesktopShell() {
|
|
|
189
210
|
</button>
|
|
190
211
|
<button
|
|
191
212
|
aria-selected={currentView === "trace"}
|
|
192
|
-
className={currentView === "trace" ? "is-active" : ""}
|
|
213
|
+
className={`workspace-view-tab--badged ${currentView === "trace" ? "is-active" : ""}`}
|
|
193
214
|
onClick={() => setCurrentView("trace")}
|
|
194
215
|
role="tab"
|
|
195
216
|
title={t("shell.view.trace")}
|
|
@@ -197,6 +218,15 @@ export function DesktopShell() {
|
|
|
197
218
|
>
|
|
198
219
|
<GitBranch size={14} />
|
|
199
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}
|
|
200
230
|
</button>
|
|
201
231
|
</div>
|
|
202
232
|
{currentView === "chat" ? (
|
|
@@ -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
|
+
}
|