@brainpilot/web 0.0.3 → 0.0.5

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 (97) hide show
  1. package/dist/assets/index-C-8G4D4j.js +448 -0
  2. package/dist/assets/index-C501m5OS.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +9 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/api.test.ts +103 -0
  8. package/src/__tests__/messageGroups.test.ts +80 -0
  9. package/src/__tests__/newUiComponents.test.tsx +101 -0
  10. package/src/__tests__/newUiEvents.test.ts +236 -0
  11. package/src/components/chat/AskUserCard.tsx +123 -0
  12. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  13. package/src/components/chat/ComposerInput.tsx +73 -0
  14. package/src/components/chat/ComposerSendButton.tsx +26 -0
  15. package/src/components/chat/MarkdownMessage.tsx +24 -0
  16. package/src/components/chat/MessageStream.tsx +464 -0
  17. package/src/components/chat/PromptComposer.tsx +398 -0
  18. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  19. package/src/components/demo/DemoFileTree.tsx +146 -0
  20. package/src/components/demo/DemoView.tsx +668 -0
  21. package/src/components/demo/TraceNodeModal.tsx +76 -0
  22. package/src/components/demo/demoBundle.ts +218 -0
  23. package/src/components/demo/demoCache.ts +42 -0
  24. package/src/components/files/FilePreviewView.tsx +153 -0
  25. package/src/components/files/FileSidebar.tsx +664 -0
  26. package/src/components/files/filePreview.ts +113 -0
  27. package/src/components/primitives/CustomSelect.tsx +200 -0
  28. package/src/components/primitives/IconButton.tsx +27 -0
  29. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  30. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  31. package/src/components/quota/QuotaFileManager.tsx +197 -0
  32. package/src/components/search/SearchDialog.tsx +101 -0
  33. package/src/components/session/AgentNetwork.tsx +1240 -0
  34. package/src/components/session/AgentTraceViews.tsx +381 -0
  35. package/src/components/session/AnalyticsTab.tsx +386 -0
  36. package/src/components/session/GlobalOverview.tsx +108 -0
  37. package/src/components/session/NodeTooltip.tsx +127 -0
  38. package/src/components/session/TimelineTab.tsx +320 -0
  39. package/src/components/session/TraceGraphView.tsx +301 -0
  40. package/src/components/session/TraceNodeDetail.tsx +142 -0
  41. package/src/components/session/agentAnalytics.ts +397 -0
  42. package/src/components/session/agentNetworkShared.ts +329 -0
  43. package/src/components/session/traceLayout.ts +150 -0
  44. package/src/components/settings/SettingsDialog.tsx +719 -0
  45. package/src/components/shell/DesktopShell.tsx +236 -0
  46. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  47. package/src/components/shell/SandboxStatus.tsx +287 -0
  48. package/src/components/shell/TerminalDrawer.tsx +387 -0
  49. package/src/components/sidebar/Sidebar.tsx +187 -0
  50. package/src/config.ts +10 -0
  51. package/src/contexts/AppProviders.tsx +20 -0
  52. package/src/contexts/AuthContext.tsx +61 -0
  53. package/src/contexts/PreferencesContext.tsx +125 -0
  54. package/src/contexts/SSEContext.tsx +175 -0
  55. package/src/contexts/SandboxContext.tsx +310 -0
  56. package/src/contexts/SessionContext.tsx +608 -0
  57. package/src/contexts/draftStore.ts +103 -0
  58. package/src/contexts/messageFilters.ts +29 -0
  59. package/src/contexts/messageGroups.ts +77 -0
  60. package/src/contexts/messageReducer.ts +401 -0
  61. package/src/contexts/newUiEvents.ts +190 -0
  62. package/src/contracts/backend.ts +846 -0
  63. package/src/contracts/demoBundle.ts +83 -0
  64. package/src/i18n/messages/analytics.ts +96 -0
  65. package/src/i18n/messages/chat.ts +108 -0
  66. package/src/i18n/messages/contexts.ts +40 -0
  67. package/src/i18n/messages/demo.ts +80 -0
  68. package/src/i18n/messages/files.ts +82 -0
  69. package/src/i18n/messages/network.ts +186 -0
  70. package/src/i18n/messages/profile.ts +40 -0
  71. package/src/i18n/messages/quota.ts +36 -0
  72. package/src/i18n/messages/sandbox.ts +116 -0
  73. package/src/i18n/messages/search.ts +16 -0
  74. package/src/i18n/messages/settings.ts +184 -0
  75. package/src/i18n/messages/shell.ts +38 -0
  76. package/src/i18n/messages/sidebar.ts +52 -0
  77. package/src/i18n/messages/terminal.ts +22 -0
  78. package/src/i18n/messages/trace.ts +84 -0
  79. package/src/i18n/messages.ts +32 -0
  80. package/src/i18n/translate.ts +46 -0
  81. package/src/i18n/types.ts +15 -0
  82. package/src/i18n/useT.ts +15 -0
  83. package/src/main.tsx +13 -0
  84. package/src/mocks/backend.ts +722 -0
  85. package/src/styles/global.css +7429 -0
  86. package/src/styles/tokens.css +161 -0
  87. package/src/utils/api.ts +627 -0
  88. package/src/utils/download.ts +18 -0
  89. package/src/utils/format.ts +7 -0
  90. package/src/utils/zip.ts +119 -0
  91. package/src/vite-env.d.ts +1 -0
  92. package/tsconfig.app.json +22 -0
  93. package/tsconfig.json +7 -0
  94. package/tsconfig.node.json +13 -0
  95. package/vite.config.ts +13 -0
  96. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  97. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ // No jsdom/@testing-library in the monorepo (deps not installable here), so we
4
+ // render to static markup with react-dom/server and assert on the output. This
5
+ // covers the level→class mapping for the bubble and the structural shape of the
6
+ // ask_user card and auto-retry indicator. Interaction (submit/cancel) is covered
7
+ // by the pure helpers in newUiEvents.test.ts (resolveAskUserSubmission etc.),
8
+ // which are the exact functions these components call.
9
+ vi.mock("../i18n/useT", () => ({
10
+ useT: () => (k: string, v?: Record<string, unknown>) => (v ? `${k}:${JSON.stringify(v)}` : k),
11
+ }));
12
+
13
+ import { renderToStaticMarkup } from "react-dom/server";
14
+ import { SystemMessageBubble } from "../components/chat/SystemMessageBubble";
15
+ import { AskUserCard } from "../components/chat/AskUserCard";
16
+ import { AutoRetryIndicator } from "../components/chat/AutoRetryIndicator";
17
+
18
+ describe("SystemMessageBubble — 4 levels", () => {
19
+ for (const level of ["info", "warning", "error", "fatal"] as const) {
20
+ it(`renders the ${level} bubble`, () => {
21
+ const html = renderToStaticMarkup(
22
+ <SystemMessageBubble
23
+ view={{ level, message: `${level} text`, recoverable: level !== "fatal" }}
24
+ />,
25
+ );
26
+ expect(html).toContain(`system-message--${level}`);
27
+ expect(html).toContain(`${level} text`);
28
+ expect(html).toContain(`data-level="${level}"`);
29
+ });
30
+ }
31
+
32
+ it("fatal gets the emphasis modifier + alert role", () => {
33
+ const html = renderToStaticMarkup(
34
+ <SystemMessageBubble view={{ level: "fatal", message: "boom", recoverable: false }} />,
35
+ );
36
+ expect(html).toContain("system-message--emphasis");
37
+ expect(html).toContain('role="alert"');
38
+ });
39
+
40
+ it("renders an expandable details block when details present", () => {
41
+ const html = renderToStaticMarkup(
42
+ <SystemMessageBubble
43
+ view={{ level: "error", message: "oops", details: "stack-trace-here", recoverable: true }}
44
+ />,
45
+ );
46
+ expect(html).toContain("<details");
47
+ expect(html).toContain("stack-trace-here");
48
+ });
49
+ });
50
+
51
+ describe("AskUserCard — structure", () => {
52
+ it("renders option buttons + free-text input when open", () => {
53
+ const html = renderToStaticMarkup(
54
+ <AskUserCard
55
+ view={{ requestId: "r1", agent: "principal", question: "Pick", options: ["A", "B"], allowFreeText: true }}
56
+ onSubmit={() => {}}
57
+ />,
58
+ );
59
+ expect(html).toContain("ask-user");
60
+ expect(html).toContain('data-request-id="r1"');
61
+ expect(html).toContain(">A<");
62
+ expect(html).toContain(">B<");
63
+ expect(html).toContain("ask-user__input");
64
+ });
65
+
66
+ it("renders the answered state once resolved", () => {
67
+ const html = renderToStaticMarkup(
68
+ <AskUserCard
69
+ view={{ requestId: "r1", agent: "principal", question: "Pick", answer: "A" }}
70
+ onSubmit={() => {}}
71
+ />,
72
+ );
73
+ expect(html).toContain("ask-user--answered");
74
+ expect(html).not.toContain("ask-user__option");
75
+ });
76
+ });
77
+
78
+ describe("AutoRetryIndicator — structure", () => {
79
+ it("renders countdown + cancel button while active", () => {
80
+ const html = renderToStaticMarkup(
81
+ <AutoRetryIndicator
82
+ view={{ attempt: 2, maxAttempts: 5, delayMs: 3000 }}
83
+ onCancel={() => {}}
84
+ />,
85
+ );
86
+ expect(html).toContain("auto-retry");
87
+ expect(html).toContain("auto-retry__cancel");
88
+ expect(html).toContain("chat.retry.attempt");
89
+ });
90
+
91
+ it("hides the cancel button once cancelled", () => {
92
+ const html = renderToStaticMarkup(
93
+ <AutoRetryIndicator
94
+ view={{ attempt: 2, maxAttempts: 5, delayMs: 3000, cancelled: true }}
95
+ onCancel={() => {}}
96
+ />,
97
+ );
98
+ expect(html).toContain("auto-retry--cancelled");
99
+ expect(html).not.toContain("auto-retry__cancel");
100
+ });
101
+ });
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ toSystemMessageView,
4
+ toAskUserView,
5
+ toAutoRetryView,
6
+ isAutoRetryStatus,
7
+ resolveAskUserSubmission,
8
+ isAskUserOpen,
9
+ autoRetryCountdownSeconds,
10
+ } from "../contexts/newUiEvents";
11
+ import { reduceMessagesForEvent } from "../contexts/messageReducer";
12
+ import type { ChatMessage, WebSocketEvent } from "../contracts/backend";
13
+
14
+ // Events arrive post-normalizeAgUiEvent (camelCase). We exercise the mapping +
15
+ // reducer with that shape, plus tolerate the raw snake_case fallback.
16
+
17
+ describe("system_message mapping", () => {
18
+ it("maps each level and defaults recoverable", () => {
19
+ for (const level of ["info", "warning", "error"] as const) {
20
+ const v = toSystemMessageView({ type: "system_message", level, message: "m" } as WebSocketEvent);
21
+ expect(v.level).toBe(level);
22
+ expect(v.recoverable).toBe(true); // non-fatal defaults recoverable
23
+ }
24
+ const fatal = toSystemMessageView({ type: "system_message", level: "fatal", message: "boom" } as WebSocketEvent);
25
+ expect(fatal.level).toBe("fatal");
26
+ expect(fatal.recoverable).toBe(false); // fatal defaults non-recoverable
27
+ });
28
+
29
+ it("honors explicit recoverable + details + agent", () => {
30
+ const v = toSystemMessageView({
31
+ type: "system_message",
32
+ level: "fatal",
33
+ message: "m",
34
+ details: "stacktrace",
35
+ agent: "librarian",
36
+ recoverable: true,
37
+ } as WebSocketEvent);
38
+ expect(v.recoverable).toBe(true);
39
+ expect(v.details).toBe("stacktrace");
40
+ expect(v.agent).toBe("librarian");
41
+ });
42
+
43
+ it("falls back to info for unknown level", () => {
44
+ const v = toSystemMessageView({ type: "system_message", level: "bogus", message: "m" } as WebSocketEvent);
45
+ expect(v.level).toBe("info");
46
+ });
47
+
48
+ it("reduces into a system_message ChatMessage", () => {
49
+ const out = reduceMessagesForEvent([], {
50
+ type: "system_message",
51
+ level: "warning",
52
+ message: "watch out",
53
+ } as WebSocketEvent);
54
+ expect(out).toHaveLength(1);
55
+ expect(out[0].kind).toBe("system_message");
56
+ expect(out[0].systemMessage?.level).toBe("warning");
57
+ expect(out[0].content).toBe("watch out");
58
+ });
59
+ });
60
+
61
+ describe("ask_user mapping + submit + reducer round-trip", () => {
62
+ const event = {
63
+ type: "user_input_request",
64
+ requestId: "req-42",
65
+ agent: "principal",
66
+ question: "Pick one",
67
+ options: ["A", "B"],
68
+ allowFreeText: true,
69
+ timeoutSec: 30,
70
+ } as WebSocketEvent;
71
+
72
+ it("maps camelCase wire fields", () => {
73
+ const v = toAskUserView(event);
74
+ expect(v.requestId).toBe("req-42");
75
+ expect(v.options).toEqual(["A", "B"]);
76
+ expect(v.allowFreeText).toBe(true);
77
+ expect(v.timeoutSec).toBe(30);
78
+ });
79
+
80
+ it("tolerates snake_case fallback", () => {
81
+ const v = toAskUserView({
82
+ type: "user_input_request",
83
+ request_id: "rid",
84
+ agent: "a",
85
+ question: "q",
86
+ allow_free_text: true,
87
+ timeout_sec: 10,
88
+ } as unknown as WebSocketEvent);
89
+ expect(v.requestId).toBe("rid");
90
+ expect(v.allowFreeText).toBe(true);
91
+ expect(v.timeoutSec).toBe(10);
92
+ });
93
+
94
+ it("reduces into an ask_user card keyed by requestId, dedupes re-emit", () => {
95
+ let msgs = reduceMessagesForEvent([], event);
96
+ expect(msgs).toHaveLength(1);
97
+ expect(msgs[0].kind).toBe("ask_user");
98
+ expect(msgs[0].id).toBe("ask-req-42");
99
+ // Re-emit of the same request must not stack a second card.
100
+ msgs = reduceMessagesForEvent(msgs, event);
101
+ expect(msgs).toHaveLength(1);
102
+ });
103
+
104
+ it("submit resolves with the right requestId + trimmed answer", () => {
105
+ const v = toAskUserView(event);
106
+ expect(resolveAskUserSubmission(v, " B ")).toEqual({ requestId: "req-42", answer: "B" });
107
+ // Empty / closed inputs do not submit.
108
+ expect(resolveAskUserSubmission(v, " ")).toBeNull();
109
+ expect(resolveAskUserSubmission(v, "B", { answered: true })).toBeNull();
110
+ expect(resolveAskUserSubmission(v, "B", { timedOut: true })).toBeNull();
111
+ });
112
+
113
+ it("a user_input_response resolves the matching card by requestId", () => {
114
+ const cards = reduceMessagesForEvent([], event);
115
+ const resolved = reduceMessagesForEvent(cards, {
116
+ type: "user_input_response",
117
+ requestId: "req-42",
118
+ answer: "A",
119
+ } as WebSocketEvent);
120
+ expect(resolved[0].askUser?.answer).toBe("A");
121
+ expect(isAskUserOpen(resolved[0].askUser!)).toBe(false);
122
+ // A non-matching requestId leaves the card open.
123
+ const other = reduceMessagesForEvent(cards, {
124
+ type: "user_input_response",
125
+ requestId: "nope",
126
+ answer: "X",
127
+ } as WebSocketEvent);
128
+ expect(other[0].askUser?.answer).toBeUndefined();
129
+ expect(isAskUserOpen(other[0].askUser!)).toBe(true);
130
+ });
131
+ });
132
+
133
+ describe("auto_retry mapping + detection + countdown", () => {
134
+ const retryEvent = {
135
+ type: "agent_status_update",
136
+ agentName: "principal",
137
+ status: "retrying",
138
+ attempt: 2,
139
+ maxAttempts: 5,
140
+ delayMs: 3000,
141
+ reason: "rate limited",
142
+ } as WebSocketEvent;
143
+
144
+ it("detects auto-retry status (and only that)", () => {
145
+ expect(isAutoRetryStatus(retryEvent)).toBe(true);
146
+ expect(isAutoRetryStatus({ type: "agent_status_update", status: "running" } as WebSocketEvent)).toBe(false);
147
+ expect(isAutoRetryStatus({ type: "agent_status_update", autoRetry: { attempt: 1 } } as unknown as WebSocketEvent)).toBe(true);
148
+ expect(isAutoRetryStatus({ type: "system_message", level: "info", message: "x" } as WebSocketEvent)).toBe(false);
149
+ });
150
+
151
+ it("maps attempt / maxAttempts / delayMs", () => {
152
+ const v = toAutoRetryView(retryEvent);
153
+ expect(v.attempt).toBe(2);
154
+ expect(v.maxAttempts).toBe(5);
155
+ expect(v.delayMs).toBe(3000);
156
+ expect(v.reason).toBe("rate limited");
157
+ });
158
+
159
+ it("computes ceil-seconds countdown", () => {
160
+ expect(autoRetryCountdownSeconds({ attempt: 1, maxAttempts: 1, delayMs: 3000 })).toBe(3);
161
+ expect(autoRetryCountdownSeconds({ attempt: 1, maxAttempts: 1, delayMs: 2500 })).toBe(3);
162
+ expect(autoRetryCountdownSeconds({ attempt: 1, maxAttempts: 1, delayMs: 0 })).toBe(0);
163
+ });
164
+
165
+ it("reduces a retrying status into an auto_retry ChatMessage", () => {
166
+ const out = reduceMessagesForEvent([], retryEvent);
167
+ expect(out).toHaveLength(1);
168
+ expect(out[0].kind).toBe("auto_retry");
169
+ expect(out[0].autoRetry?.attempt).toBe(2);
170
+ // A normal status update produces no message.
171
+ expect(reduceMessagesForEvent([], { type: "agent_status_update", status: "running" } as WebSocketEvent)).toHaveLength(0);
172
+ });
173
+ });
174
+
175
+ describe("retry cancel callback wiring", () => {
176
+ it("fires the cancel callback when invoked (host-supplied abort path)", () => {
177
+ // The component delegates cancel to a host callback; we assert that calling
178
+ // the supplied handler triggers exactly once (mirrors PromptComposer wiring
179
+ // of onRetryCancel -> interruptCurrent).
180
+ let cancelled = 0;
181
+ const onCancel = () => { cancelled += 1; };
182
+ onCancel();
183
+ expect(cancelled).toBe(1);
184
+ });
185
+ });
186
+
187
+ // Sanity: the reducer leaves an unrelated message list untouched for unknown events.
188
+ describe("reducer passthrough", () => {
189
+ it("ignores unknown event types", () => {
190
+ const existing: ChatMessage[] = [
191
+ { id: "1", role: "user", content: "hi", createdAt: new Date().toISOString() },
192
+ ];
193
+ expect(reduceMessagesForEvent(existing, { type: "WHATEVER" } as WebSocketEvent)).toBe(existing);
194
+ });
195
+ });
196
+
197
+ // Regression: a START with no matching END leaves a message stuck at
198
+ // streaming:true, so the activity group shows "智能体思考中" forever. The run
199
+ // terminators (RUN_FINISHED / RUN_ERROR) must sweep dangling streaming flags.
200
+ describe("run terminators clear dangling streaming flags", () => {
201
+ const open = (agent: string): ChatMessage => ({
202
+ id: `m-${agent}`,
203
+ role: "assistant",
204
+ content: "partial…",
205
+ createdAt: new Date().toISOString(),
206
+ agent,
207
+ streaming: true,
208
+ kind: "text",
209
+ });
210
+
211
+ it("RUN_FINISHED clears streaming on the run agent's messages", () => {
212
+ const out = reduceMessagesForEvent([open("principal")], {
213
+ type: "RUN_FINISHED",
214
+ agentName: "principal",
215
+ } as WebSocketEvent);
216
+ expect(out[0]!.streaming).toBe(false);
217
+ });
218
+
219
+ it("RUN_FINISHED leaves OTHER agents' messages streaming (multi-agent isolation)", () => {
220
+ const out = reduceMessagesForEvent([open("worker")], {
221
+ type: "RUN_FINISHED",
222
+ agentName: "principal",
223
+ } as WebSocketEvent);
224
+ expect(out[0]!.streaming).toBe(true);
225
+ });
226
+
227
+ it("RUN_ERROR clears streaming AND appends an error message", () => {
228
+ const out = reduceMessagesForEvent([open("principal")], {
229
+ type: "RUN_ERROR",
230
+ agentName: "principal",
231
+ message: "boom",
232
+ } as WebSocketEvent);
233
+ expect(out[0]!.streaming).toBe(false);
234
+ expect(out.some((m) => m.kind === "error" && m.content === "boom")).toBe(true);
235
+ });
236
+ });
@@ -0,0 +1,123 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { CheckCircle2, HelpCircle } from "lucide-react";
3
+ import type { AskUserView } from "../../contracts/backend";
4
+ import { resolveAskUserSubmission } from "../../contexts/newUiEvents";
5
+ import { useT } from "../../i18n/useT";
6
+
7
+ interface AskUserCardProps {
8
+ view: AskUserView;
9
+ /** Submit the chosen/typed answer back through the send path. */
10
+ onSubmit: (requestId: string, answer: string) => void;
11
+ }
12
+
13
+ /**
14
+ * 修正6 — ask_user interaction card. Renders a `user_input_request` as an
15
+ * interactive card: option buttons and/or a free-text input. On submit it
16
+ * fires `onSubmit(requestId, answer)` so the host can post a
17
+ * user_input_response. Supports an optional countdown when `timeoutSec` is set.
18
+ */
19
+ export function AskUserCard({ view, onSubmit }: AskUserCardProps) {
20
+ const t = useT();
21
+ const [freeText, setFreeText] = useState("");
22
+ const answered = view.answer !== undefined;
23
+
24
+ // Optional countdown. Only ticks while unanswered and a timeout is given.
25
+ const initialSec = useMemo(
26
+ () => (typeof view.timeoutSec === "number" && view.timeoutSec > 0 ? Math.floor(view.timeoutSec) : null),
27
+ [view.timeoutSec],
28
+ );
29
+ const [secondsLeft, setSecondsLeft] = useState<number | null>(initialSec);
30
+
31
+ useEffect(() => {
32
+ if (answered || initialSec === null) return;
33
+ setSecondsLeft(initialSec);
34
+ const id = window.setInterval(() => {
35
+ setSecondsLeft((prev) => {
36
+ if (prev === null) return prev;
37
+ if (prev <= 1) {
38
+ window.clearInterval(id);
39
+ return 0;
40
+ }
41
+ return prev - 1;
42
+ });
43
+ }, 1000);
44
+ return () => window.clearInterval(id);
45
+ }, [answered, initialSec]);
46
+
47
+ const timedOut = secondsLeft === 0;
48
+ const disabled = answered || timedOut;
49
+
50
+ const submit = (answer: string) => {
51
+ const resolved = resolveAskUserSubmission(view, answer, { answered, timedOut });
52
+ if (!resolved) return;
53
+ onSubmit(resolved.requestId, resolved.answer);
54
+ };
55
+
56
+ if (answered) {
57
+ return (
58
+ <div className="ask-user ask-user--answered" data-testid="ask-user" data-request-id={view.requestId}>
59
+ <div className="ask-user__head">
60
+ <CheckCircle2 size={15} className="ask-user__icon" aria-hidden="true" />
61
+ <span className="ask-user__title">{view.agent}</span>
62
+ </div>
63
+ <p className="ask-user__question">{view.question}</p>
64
+ <p className="ask-user__answer">{t("chat.ask.answered", { answer: view.answer ?? "" })}</p>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div className="ask-user" data-testid="ask-user" data-request-id={view.requestId}>
71
+ <div className="ask-user__head">
72
+ <HelpCircle size={15} className="ask-user__icon" aria-hidden="true" />
73
+ <span className="ask-user__title">{t("chat.ask.title")}</span>
74
+ <span className="ask-user__agent">{view.agent}</span>
75
+ {secondsLeft !== null ? (
76
+ <span className="ask-user__timer">
77
+ {timedOut ? t("chat.ask.timedOut") : t("chat.ask.timeoutLeft", { sec: secondsLeft })}
78
+ </span>
79
+ ) : null}
80
+ </div>
81
+ <p className="ask-user__question">{view.question}</p>
82
+
83
+ {view.options && view.options.length > 0 ? (
84
+ <div className="ask-user__options">
85
+ {view.options.map((option) => (
86
+ <button
87
+ key={option}
88
+ type="button"
89
+ className="ask-user__option"
90
+ disabled={disabled}
91
+ onClick={() => submit(option)}
92
+ >
93
+ {option}
94
+ </button>
95
+ ))}
96
+ </div>
97
+ ) : null}
98
+
99
+ {view.allowFreeText || !view.options || view.options.length === 0 ? (
100
+ <form
101
+ className="ask-user__free"
102
+ onSubmit={(e) => {
103
+ e.preventDefault();
104
+ submit(freeText);
105
+ }}
106
+ >
107
+ <input
108
+ className="ask-user__input"
109
+ type="text"
110
+ value={freeText}
111
+ disabled={disabled}
112
+ placeholder={t("chat.ask.freeTextPlaceholder")}
113
+ onChange={(e) => setFreeText(e.target.value)}
114
+ aria-label={view.question}
115
+ />
116
+ <button type="submit" className="ask-user__submit" disabled={disabled || freeText.trim() === ""}>
117
+ {t("chat.ask.submit")}
118
+ </button>
119
+ </form>
120
+ ) : null}
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { RotateCw, X } from "lucide-react";
3
+ import type { AutoRetryView } from "../../contracts/backend";
4
+ import { autoRetryCountdownSeconds } from "../../contexts/newUiEvents";
5
+ import { useT } from "../../i18n/useT";
6
+
7
+ interface AutoRetryIndicatorProps {
8
+ view: AutoRetryView;
9
+ /** Abort the pending retry (wired to the interrupt / abort path). */
10
+ onCancel: () => void;
11
+ }
12
+
13
+ /**
14
+ * 修正6 — auto-retry countdown + cancel. Surfaces a Pi `auto_retry_start`
15
+ * (attempt/maxAttempts/delayMs) as a countdown indicator with a Cancel button
16
+ * that calls the interrupt/abort path.
17
+ */
18
+ export function AutoRetryIndicator({ view, onCancel }: AutoRetryIndicatorProps) {
19
+ const t = useT();
20
+ const cancelled = view.cancelled === true;
21
+
22
+ const initialSec = useMemo(() => autoRetryCountdownSeconds(view), [view]);
23
+ const [secondsLeft, setSecondsLeft] = useState(initialSec);
24
+
25
+ useEffect(() => {
26
+ if (cancelled || initialSec <= 0) return;
27
+ setSecondsLeft(initialSec);
28
+ const id = window.setInterval(() => {
29
+ setSecondsLeft((prev) => {
30
+ if (prev <= 1) {
31
+ window.clearInterval(id);
32
+ return 0;
33
+ }
34
+ return prev - 1;
35
+ });
36
+ }, 1000);
37
+ return () => window.clearInterval(id);
38
+ }, [cancelled, initialSec]);
39
+
40
+ return (
41
+ <div
42
+ className={`auto-retry${cancelled ? " auto-retry--cancelled" : ""}`}
43
+ role="status"
44
+ data-testid="auto-retry"
45
+ >
46
+ <RotateCw size={14} className="auto-retry__icon" aria-hidden="true" />
47
+ <div className="auto-retry__body">
48
+ <span className="auto-retry__title">{t("chat.retry.title")}</span>
49
+ <span className="auto-retry__attempt">
50
+ {t("chat.retry.attempt", { attempt: view.attempt, max: view.maxAttempts })}
51
+ </span>
52
+ {cancelled ? (
53
+ <span className="auto-retry__status">{t("chat.retry.cancelled")}</span>
54
+ ) : secondsLeft > 0 ? (
55
+ <span className="auto-retry__countdown">{t("chat.retry.countdown", { sec: secondsLeft })}</span>
56
+ ) : null}
57
+ </div>
58
+ {!cancelled ? (
59
+ <button
60
+ type="button"
61
+ className="auto-retry__cancel"
62
+ onClick={onCancel}
63
+ aria-label={t("chat.retry.cancel")}
64
+ >
65
+ <X size={13} aria-hidden="true" />
66
+ {t("chat.retry.cancel")}
67
+ </button>
68
+ ) : null}
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,73 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { KeyboardEvent } from "react";
3
+ import { useDraft } from "../../contexts/draftStore";
4
+
5
+ interface ComposerInputProps {
6
+ /** Active session id; drafts are isolated per session. null disables editing. */
7
+ sessionId: string | null;
8
+ placeholder: string;
9
+ /** Aria label for the textarea (sr-only label uses the same text). */
10
+ ariaLabel: string;
11
+ }
12
+
13
+ /**
14
+ * Isolated textarea bound to draftStore[sessionId].
15
+ *
16
+ * Splitting this out of PromptComposer is the whole point of the input-lag
17
+ * fix: keystrokes used to re-render the whole chat subtree because draft state
18
+ * lived on SessionContext. Now keystrokes only re-render this component (and
19
+ * its sibling ComposerSendButton, which also subscribes to the same store).
20
+ *
21
+ * Form submission is owned by the enclosing <form> in PromptComposer; this
22
+ * component just handles Enter-to-submit by walking up to the form.
23
+ */
24
+ export function ComposerInput({ sessionId, placeholder, ariaLabel }: ComposerInputProps) {
25
+ const [draft, setDraft] = useDraft(sessionId);
26
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
27
+
28
+ // Grow textarea to fit content. Reading scrollHeight forces a layout — fine
29
+ // here because only this component re-renders on draft change.
30
+ const autoResize = () => {
31
+ const textarea = textareaRef.current;
32
+ if (!textarea) return;
33
+ textarea.style.height = "auto";
34
+ textarea.style.height = `${textarea.scrollHeight}px`;
35
+ };
36
+
37
+ // Resize on draft change (covers store-driven updates: slash menu /
38
+ // suggestions / switching sessions) and initial mount with a pre-existing
39
+ // draft (e.g. user typed, switched tabs, switched back).
40
+ useEffect(() => {
41
+ autoResize();
42
+ }, [draft]);
43
+
44
+ const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
45
+ // Shift+Enter inserts a newline; bare Enter submits. Skip while IME is
46
+ // composing so CJK candidate selection doesn't fire submit.
47
+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) {
48
+ return;
49
+ }
50
+ event.preventDefault();
51
+ event.currentTarget.form?.requestSubmit();
52
+ };
53
+
54
+ return (
55
+ <>
56
+ <label className="sr-only" htmlFor="prompt-input">
57
+ {ariaLabel}
58
+ </label>
59
+ <textarea
60
+ ref={textareaRef}
61
+ id="prompt-input"
62
+ rows={1}
63
+ value={draft}
64
+ onChange={(event) => {
65
+ setDraft(event.target.value);
66
+ autoResize();
67
+ }}
68
+ onKeyDown={handleKeyDown}
69
+ placeholder={placeholder}
70
+ />
71
+ </>
72
+ );
73
+ }
@@ -0,0 +1,26 @@
1
+ import { ArrowUp } from "lucide-react";
2
+ import { useDraft } from "../../contexts/draftStore";
3
+ import { IconButton } from "../primitives/IconButton";
4
+
5
+ interface ComposerSendButtonProps {
6
+ sessionId: string | null;
7
+ canSend: boolean;
8
+ label: string;
9
+ }
10
+
11
+ /**
12
+ * Send button that subscribes only to draftStore[sessionId] to compute its
13
+ * disabled state. Sibling of ComposerInput; lives inside the same <form> in
14
+ * PromptComposer, so click triggers normal form submission.
15
+ *
16
+ * Subscribing here (rather than passing isEmpty down from PromptComposer)
17
+ * keeps the parent off the keystroke render path entirely.
18
+ */
19
+ export function ComposerSendButton({ sessionId, canSend, label }: ComposerSendButtonProps) {
20
+ const [draft] = useDraft(sessionId);
21
+ return (
22
+ <IconButton disabled={!canSend || !draft.trim()} label={label} type="submit" variant="strong">
23
+ <ArrowUp size={18} />
24
+ </IconButton>
25
+ );
26
+ }
@@ -0,0 +1,24 @@
1
+ import { memo } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import rehypeHighlight from "rehype-highlight";
5
+
6
+ interface MarkdownMessageProps {
7
+ content: string;
8
+ }
9
+
10
+ function MarkdownMessageImpl({ content }: MarkdownMessageProps) {
11
+ return (
12
+ <div className="message-card__content">
13
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
14
+ {content}
15
+ </ReactMarkdown>
16
+ </div>
17
+ );
18
+ }
19
+
20
+ // Memoized so re-renders driven by unrelated parent state (e.g. a sibling
21
+ // component changing) don't re-parse markdown for every visible message —
22
+ // `content` is a string so default shallow-equal compare is precise.
23
+ export const MarkdownMessage = memo(MarkdownMessageImpl);
24
+