@brainpilot/web 0.0.4 → 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/index.html +13 -0
- package/package.json +12 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +221 -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__/messageGroups.test.ts +80 -0
- package/src/__tests__/newUiComponents.test.tsx +101 -0
- package/src/__tests__/newUiEvents.test.ts +236 -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/AskUserCard.tsx +123 -0
- package/src/components/chat/AutoRetryIndicator.tsx +71 -0
- package/src/components/chat/ComposerInput.tsx +73 -0
- package/src/components/chat/ComposerSendButton.tsx +26 -0
- package/src/components/chat/MarkdownMessage.tsx +24 -0
- package/src/components/chat/MessageStream.tsx +505 -0
- package/src/components/chat/PromptComposer.tsx +489 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +730 -0
- package/src/components/demo/TraceNodeModal.tsx +80 -0
- package/src/components/demo/demoBundle.ts +223 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/demo/demoReset.ts +16 -0
- package/src/components/files/FilePreviewView.tsx +153 -0
- package/src/components/files/FileSidebar.tsx +664 -0
- package/src/components/files/filePreview.ts +113 -0
- package/src/components/primitives/CustomSelect.tsx +200 -0
- package/src/components/primitives/IconButton.tsx +27 -0
- package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
- package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
- package/src/components/quota/QuotaFileManager.tsx +197 -0
- package/src/components/search/SearchDialog.tsx +101 -0
- package/src/components/session/AgentNetwork.tsx +1233 -0
- package/src/components/session/AgentTraceViews.tsx +346 -0
- package/src/components/session/AnalyticsTab.tsx +220 -0
- package/src/components/session/GlobalOverview.tsx +108 -0
- package/src/components/session/NodeTooltip.tsx +127 -0
- package/src/components/session/TimelineTab.tsx +320 -0
- package/src/components/session/TraceGraphView.tsx +307 -0
- package/src/components/session/TraceNodeDetail.tsx +179 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +339 -0
- package/src/components/session/traceLayout.ts +182 -0
- package/src/components/settings/SettingsDialog.tsx +737 -0
- package/src/components/shell/DesktopShell.tsx +261 -0
- package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
- package/src/components/shell/SandboxStatus.tsx +287 -0
- package/src/components/shell/TerminalDrawer.tsx +387 -0
- package/src/components/sidebar/Sidebar.tsx +191 -0
- package/src/config.ts +10 -0
- package/src/contexts/AppProviders.tsx +20 -0
- package/src/contexts/AuthContext.tsx +61 -0
- package/src/contexts/PreferencesContext.tsx +125 -0
- package/src/contexts/SSEContext.tsx +264 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +919 -0
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/draftStore.ts +103 -0
- package/src/contexts/messageFilters.ts +29 -0
- package/src/contexts/messageGroups.ts +77 -0
- package/src/contexts/messageReducer.ts +401 -0
- package/src/contexts/newUiEvents.ts +190 -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 +897 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +106 -0
- package/src/i18n/messages/chat.ts +130 -0
- package/src/i18n/messages/contexts.ts +42 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +190 -0
- package/src/i18n/messages/profile.ts +44 -0
- package/src/i18n/messages/quota.ts +36 -0
- package/src/i18n/messages/sandbox.ts +116 -0
- package/src/i18n/messages/search.ts +16 -0
- package/src/i18n/messages/settings.ts +188 -0
- package/src/i18n/messages/shell.ts +38 -0
- package/src/i18n/messages/sidebar.ts +52 -0
- package/src/i18n/messages/terminal.ts +22 -0
- package/src/i18n/messages/trace.ts +136 -0
- package/src/i18n/messages.ts +32 -0
- package/src/i18n/translate.ts +46 -0
- package/src/i18n/types.ts +15 -0
- package/src/i18n/useT.ts +15 -0
- package/src/main.tsx +13 -0
- package/src/mocks/backend.ts +729 -0
- package/src/styles/global.css +7578 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +724 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/toolDisplay.ts +74 -0
- package/src/utils/zip.ts +119 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +22 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +13 -0
- package/vite.config.ts +13 -0
- package/dist/assets/index-Cd0Mi_WU.css +0 -1
- package/dist/assets/index-FGg-DeYR.js +0 -448
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AskUserView,
|
|
3
|
+
AutoRetryView,
|
|
4
|
+
ChatMessage,
|
|
5
|
+
SystemMessageView,
|
|
6
|
+
WebSocketEvent,
|
|
7
|
+
} from "../contracts/backend";
|
|
8
|
+
import { generateUUID } from "./messageReducer";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 修正6 — event → view-model mapping for the three new UI features
|
|
12
|
+
* (system_message bubble, ask_user card, auto-retry countdown).
|
|
13
|
+
*
|
|
14
|
+
* Kept as pure functions (no React, no DOM) so the mapping is unit-testable in
|
|
15
|
+
* isolation. The reducer in messageReducer.ts calls these to fold the new
|
|
16
|
+
* AG-UI events into the ChatMessage stream, consistently with the existing
|
|
17
|
+
* event handling.
|
|
18
|
+
*
|
|
19
|
+
* Events arrive post-`normalizeAgUiEvent`, which camelizes every wire key. So a
|
|
20
|
+
* wire `request_id` is read here as `requestId`, `allow_free_text` as
|
|
21
|
+
* `allowFreeText`, etc. We still tolerate the snake_case originals as a
|
|
22
|
+
* fallback in case an event bypassed normalization.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function str(value: unknown, fallback = ""): string {
|
|
26
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function optStr(value: unknown): string | undefined {
|
|
30
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function num(value: unknown, fallback: number): number {
|
|
34
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Map a `system_message` event to its view-model. */
|
|
38
|
+
export function toSystemMessageView(event: WebSocketEvent): SystemMessageView {
|
|
39
|
+
const e = event as Record<string, unknown>;
|
|
40
|
+
const rawLevel = str(e.level, "info");
|
|
41
|
+
const level: SystemMessageView["level"] =
|
|
42
|
+
rawLevel === "warning" || rawLevel === "error" || rawLevel === "fatal" ? rawLevel : "info";
|
|
43
|
+
// `recoverable` defaults to false for fatal, true otherwise, when the wire
|
|
44
|
+
// omits it.
|
|
45
|
+
const recoverable =
|
|
46
|
+
typeof e.recoverable === "boolean" ? e.recoverable : level !== "fatal";
|
|
47
|
+
return {
|
|
48
|
+
level,
|
|
49
|
+
message: str(e.message),
|
|
50
|
+
details: optStr(e.details),
|
|
51
|
+
agent: optStr(e.agent) ?? optStr(e.agentName),
|
|
52
|
+
recoverable,
|
|
53
|
+
timestamp: optStr(e.timestamp),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Map a `user_input_request` event to the ask_user card view-model. */
|
|
58
|
+
export function toAskUserView(event: WebSocketEvent): AskUserView {
|
|
59
|
+
const e = event as Record<string, unknown>;
|
|
60
|
+
const options = Array.isArray(e.options)
|
|
61
|
+
? (e.options as unknown[]).filter((o): o is string => typeof o === "string")
|
|
62
|
+
: undefined;
|
|
63
|
+
return {
|
|
64
|
+
requestId: str(e.requestId ?? e.request_id),
|
|
65
|
+
agent: str(e.agent ?? e.agentName, "principal"),
|
|
66
|
+
question: str(e.question),
|
|
67
|
+
options: options && options.length > 0 ? options : undefined,
|
|
68
|
+
allowFreeText:
|
|
69
|
+
typeof (e.allowFreeText ?? e.allow_free_text) === "boolean"
|
|
70
|
+
? (e.allowFreeText ?? e.allow_free_text) as boolean
|
|
71
|
+
: undefined,
|
|
72
|
+
timeoutSec: typeof (e.timeoutSec ?? e.timeout_sec) === "number"
|
|
73
|
+
? (e.timeoutSec ?? e.timeout_sec) as number
|
|
74
|
+
: undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Map an auto-retry indicator to its view-model. Pi's `auto_retry_start`
|
|
80
|
+
* surfaces as an `agent_status_update` (status `retrying`) carrying
|
|
81
|
+
* attempt/maxAttempts/delayMs, mirrored by a `system_message`. We read whichever
|
|
82
|
+
* fields are present, tolerating camel/snake casing.
|
|
83
|
+
*/
|
|
84
|
+
export function toAutoRetryView(event: WebSocketEvent): AutoRetryView {
|
|
85
|
+
const e = event as Record<string, unknown>;
|
|
86
|
+
// The retry detail may be nested under `data` / `value` / `autoRetry`.
|
|
87
|
+
const nested =
|
|
88
|
+
(e.autoRetry as Record<string, unknown> | undefined) ??
|
|
89
|
+
(e.data as Record<string, unknown> | undefined) ??
|
|
90
|
+
(e.value as Record<string, unknown> | undefined) ??
|
|
91
|
+
e;
|
|
92
|
+
return {
|
|
93
|
+
attempt: num(nested.attempt, 1),
|
|
94
|
+
maxAttempts: num(nested.maxAttempts ?? nested.max_attempts, 1),
|
|
95
|
+
delayMs: num(nested.delayMs ?? nested.delay_ms, 0),
|
|
96
|
+
reason: optStr(nested.reason) ?? optStr(e.message),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True iff this `agent_status_update` represents an auto-retry start. */
|
|
101
|
+
export function isAutoRetryStatus(event: WebSocketEvent): boolean {
|
|
102
|
+
const e = event as Record<string, unknown>;
|
|
103
|
+
if (e.type !== "agent_status_update") return false;
|
|
104
|
+
const status = str(e.status ?? (e as { runStatus?: string }).runStatus).toLowerCase();
|
|
105
|
+
// Pi auto_retry_start surfaces with a retrying/auto_retry status marker, or an
|
|
106
|
+
// explicit autoRetry payload.
|
|
107
|
+
if (status === "retrying" || status === "auto_retry" || status === "auto_retry_start") {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return Boolean(e.autoRetry ?? e.auto_retry);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Build the `system_message` ChatMessage. */
|
|
114
|
+
export function systemMessageToChatMessage(event: WebSocketEvent): ChatMessage {
|
|
115
|
+
const view = toSystemMessageView(event);
|
|
116
|
+
const e = event as Record<string, unknown>;
|
|
117
|
+
return {
|
|
118
|
+
id: str(e.id ?? e.messageId) || `sysmsg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
119
|
+
role: "system",
|
|
120
|
+
content: view.message,
|
|
121
|
+
createdAt: view.timestamp ?? new Date().toISOString(),
|
|
122
|
+
agent: view.agent,
|
|
123
|
+
streaming: false,
|
|
124
|
+
kind: "system_message",
|
|
125
|
+
systemMessage: view,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Build the `ask_user` ChatMessage. Keyed by requestId so it can be resolved. */
|
|
130
|
+
export function askUserToChatMessage(event: WebSocketEvent): ChatMessage {
|
|
131
|
+
const view = toAskUserView(event);
|
|
132
|
+
return {
|
|
133
|
+
id: view.requestId ? `ask-${view.requestId}` : generateUUID(),
|
|
134
|
+
role: "system",
|
|
135
|
+
content: view.question,
|
|
136
|
+
createdAt: new Date().toISOString(),
|
|
137
|
+
agent: view.agent,
|
|
138
|
+
streaming: false,
|
|
139
|
+
kind: "ask_user",
|
|
140
|
+
askUser: view,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Build the `auto_retry` ChatMessage from an auto-retry status update. */
|
|
145
|
+
export function autoRetryToChatMessage(event: WebSocketEvent): ChatMessage {
|
|
146
|
+
const view = toAutoRetryView(event);
|
|
147
|
+
const e = event as Record<string, unknown>;
|
|
148
|
+
return {
|
|
149
|
+
id: `retry-${str(e.agentName, "agent")}-${view.attempt}-${Date.now()}`,
|
|
150
|
+
role: "system",
|
|
151
|
+
content: view.reason ?? `Retrying (attempt ${view.attempt}/${view.maxAttempts})`,
|
|
152
|
+
createdAt: new Date().toISOString(),
|
|
153
|
+
agent: optStr(e.agentName),
|
|
154
|
+
streaming: false,
|
|
155
|
+
kind: "auto_retry",
|
|
156
|
+
autoRetry: view,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Pure interaction helpers (shared by the components; testable w/o a DOM) ──
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve an ask_user submission. Returns the `{ requestId, answer }` to send,
|
|
164
|
+
* or `null` when the input is empty or the card is no longer accepting input
|
|
165
|
+
* (already answered / timed out). The trimmed answer is what gets sent.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveAskUserSubmission(
|
|
168
|
+
view: AskUserView,
|
|
169
|
+
rawAnswer: string,
|
|
170
|
+
opts: { answered?: boolean; timedOut?: boolean } = {},
|
|
171
|
+
): { requestId: string; answer: string } | null {
|
|
172
|
+
if (opts.answered || opts.timedOut) return null;
|
|
173
|
+
const answer = rawAnswer.trim();
|
|
174
|
+
if (!answer || !view.requestId) return null;
|
|
175
|
+
return { requestId: view.requestId, answer };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Whether an ask_user card should still show interactive inputs. */
|
|
179
|
+
export function isAskUserOpen(
|
|
180
|
+
view: AskUserView,
|
|
181
|
+
opts: { timedOut?: boolean } = {},
|
|
182
|
+
): boolean {
|
|
183
|
+
return view.answer === undefined && !opts.timedOut;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Initial countdown (whole seconds) for an auto-retry from its delayMs. */
|
|
187
|
+
export function autoRetryCountdownSeconds(view: AutoRetryView): number {
|
|
188
|
+
return view.delayMs > 0 ? Math.ceil(view.delayMs / 1000) : 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helper for the "X 正在工作 / X is working" toast above the composer (#76).
|
|
3
|
+
*
|
|
4
|
+
* Selects which i18n key + vars the toast should render given the set of agents
|
|
5
|
+
* currently working. Kept pure (no React) so it's unit-testable without a DOM —
|
|
6
|
+
* the component just calls `t(key, vars)` with the result. The trace agent is
|
|
7
|
+
* excluded by the caller (it self-records continuously and isn't "the user's
|
|
8
|
+
* task"), matching the runtime's run-active aggregation.
|
|
9
|
+
*/
|
|
10
|
+
export interface ToastLabel {
|
|
11
|
+
key: "chat.agentWorking" | "chat.agentsWorking" | "chat.agentThinking";
|
|
12
|
+
/** Interpolation vars; shape matches i18n `TranslateVars` (string|number). */
|
|
13
|
+
vars?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param workingAgentNames names of non-trace agents with status "running"
|
|
18
|
+
* @param separator locale-appropriate join for multiple names (default "、")
|
|
19
|
+
*/
|
|
20
|
+
export function runningToastLabel(
|
|
21
|
+
workingAgentNames: readonly string[],
|
|
22
|
+
separator = "、",
|
|
23
|
+
): ToastLabel {
|
|
24
|
+
if (workingAgentNames.length === 1) {
|
|
25
|
+
return { key: "chat.agentWorking", vars: { name: workingAgentNames[0]! } };
|
|
26
|
+
}
|
|
27
|
+
if (workingAgentNames.length > 1) {
|
|
28
|
+
return { key: "chat.agentsWorking", vars: { names: workingAgentNames.join(separator) } };
|
|
29
|
+
}
|
|
30
|
+
// Streaming but no named running agent yet (status not yet "running"): keep the
|
|
31
|
+
// generic label so the toast never renders blank.
|
|
32
|
+
return { key: "chat.agentThinking" };
|
|
33
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { CUSTOM_EVENT } from "@brainpilot/protocol";
|
|
2
|
+
import type { TraceGraph, TraceNode, WebSocketEvent } from "../contracts/backend";
|
|
3
|
+
import { normalizeTraceNode } from "../contracts/backend";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* #79: merge a single `CUSTOM:trace_node` event into the live Graph of Trace.
|
|
7
|
+
*
|
|
8
|
+
* The runtime emits `CUSTOM { name:"trace_node", value:{ op, node } }` on every
|
|
9
|
+
* trace mutation (LLM `record_trace`/`create_trace_*` and the deterministic
|
|
10
|
+
* post-turn hook). This keeps the Trace panel live without polling the whole
|
|
11
|
+
* graph every few seconds.
|
|
12
|
+
*
|
|
13
|
+
* Merge rules:
|
|
14
|
+
* - a non-`trace_node` event returns the same graph reference (no-op);
|
|
15
|
+
* - an unparseable / id-less payload is ignored (same reference);
|
|
16
|
+
* - a node id already present is replaced in place (status/summary updates);
|
|
17
|
+
* - a new node id is appended;
|
|
18
|
+
* - `childIds` are recomputed from every node's `parentIds` so edges stay
|
|
19
|
+
* consistent regardless of arrival order (a child can arrive before its
|
|
20
|
+
* parent's childIds is known).
|
|
21
|
+
*/
|
|
22
|
+
export function reduceTraceForEvent(
|
|
23
|
+
graph: TraceGraph | null,
|
|
24
|
+
event: WebSocketEvent,
|
|
25
|
+
sessionId: string,
|
|
26
|
+
): TraceGraph | null {
|
|
27
|
+
const e = event as Record<string, unknown>;
|
|
28
|
+
if (e.type !== "CUSTOM" || e.name !== CUSTOM_EVENT.TRACE_NODE) return graph;
|
|
29
|
+
const value = (e.value ?? {}) as Record<string, unknown>;
|
|
30
|
+
const rawNode = value.node;
|
|
31
|
+
if (!rawNode || typeof rawNode !== "object") return graph;
|
|
32
|
+
const node = normalizeTraceNode(rawNode);
|
|
33
|
+
if (!node.id) return graph;
|
|
34
|
+
|
|
35
|
+
const base: TraceGraph = graph ?? {
|
|
36
|
+
meta: { sessionId },
|
|
37
|
+
nodes: [],
|
|
38
|
+
};
|
|
39
|
+
const idx = base.nodes.findIndex((n) => n.id === node.id);
|
|
40
|
+
const nextNodes =
|
|
41
|
+
idx >= 0
|
|
42
|
+
? base.nodes.map((n, i) => (i === idx ? node : n))
|
|
43
|
+
: [...base.nodes, node];
|
|
44
|
+
|
|
45
|
+
return { meta: base.meta, nodes: withChildIds(nextNodes) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Recompute every node's `childIds` from the parent links across the set. */
|
|
49
|
+
function withChildIds(nodes: TraceNode[]): TraceNode[] {
|
|
50
|
+
const childrenByParent = new Map<string, Set<string>>();
|
|
51
|
+
for (const n of nodes) {
|
|
52
|
+
for (const pid of n.parentIds) {
|
|
53
|
+
const set = childrenByParent.get(pid) ?? new Set<string>();
|
|
54
|
+
set.add(n.id);
|
|
55
|
+
childrenByParent.set(pid, set);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return nodes.map((n) => ({
|
|
59
|
+
...n,
|
|
60
|
+
childIds: Array.from(childrenByParent.get(n.id) ?? []),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
turnTimerReducer,
|
|
4
|
+
initialTurnTimerState,
|
|
5
|
+
type TurnTimerState,
|
|
6
|
+
} from "./turnTimer";
|
|
7
|
+
|
|
8
|
+
/** Apply a sequence of events from the initial state. */
|
|
9
|
+
function run(events: Parameters<typeof turnTimerReducer>[1][]): TurnTimerState {
|
|
10
|
+
return events.reduce(turnTimerReducer, initialTurnTimerState);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("turnTimerReducer (#99 whole-turn timing)", () => {
|
|
14
|
+
it("times a simple turn: user input → active true → active false → settle", () => {
|
|
15
|
+
const s = run([
|
|
16
|
+
{ type: "userInput", atMs: 1000 },
|
|
17
|
+
{ type: "active", value: true, atMs: 1010 },
|
|
18
|
+
{ type: "active", value: false, atMs: 4200 },
|
|
19
|
+
{ type: "settle" },
|
|
20
|
+
]);
|
|
21
|
+
expect(s.running).toBe(false);
|
|
22
|
+
// Duration spans user input (1000) → terminal active=false (4200) = 3.2s.
|
|
23
|
+
expect(s.lastDurationMs).toBe(3200);
|
|
24
|
+
expect(s.startedAt).toBeNull();
|
|
25
|
+
expect(s.candidateEndAt).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("does NOT end the turn on a mid-flap false that is re-woken before settle", () => {
|
|
29
|
+
let s = run([
|
|
30
|
+
{ type: "userInput", atMs: 1000 },
|
|
31
|
+
{ type: "active", value: true, atMs: 1010 },
|
|
32
|
+
// Principal turn ends momentarily...
|
|
33
|
+
{ type: "active", value: false, atMs: 2000 },
|
|
34
|
+
]);
|
|
35
|
+
expect(s.running).toBe(true); // still running (candidate pending, not settled)
|
|
36
|
+
expect(s.candidateEndAt).toBe(2000);
|
|
37
|
+
|
|
38
|
+
// ...but a hook / mailbox delivery re-wakes an agent before settle fires.
|
|
39
|
+
s = turnTimerReducer(s, { type: "active", value: true, atMs: 2050 });
|
|
40
|
+
expect(s.candidateEndAt).toBeNull(); // candidate discarded
|
|
41
|
+
expect(s.running).toBe(true);
|
|
42
|
+
expect(s.startedAt).toBe(1000); // original start preserved
|
|
43
|
+
|
|
44
|
+
// Real end later.
|
|
45
|
+
s = turnTimerReducer(s, { type: "active", value: false, atMs: 5000 });
|
|
46
|
+
s = turnTimerReducer(s, { type: "settle" });
|
|
47
|
+
expect(s.lastDurationMs).toBe(4000); // 1000 → 5000, the re-wake didn't reset it
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("uses the candidate-end timestamp, not the settle time, for the duration", () => {
|
|
51
|
+
// settle is dispatched 'later' but the committed duration must reflect the
|
|
52
|
+
// authoritative active=false timestamp (the settle window is not counted).
|
|
53
|
+
const s = run([
|
|
54
|
+
{ type: "userInput", atMs: 0 },
|
|
55
|
+
{ type: "active", value: true, atMs: 5 },
|
|
56
|
+
{ type: "active", value: false, atMs: 1000 },
|
|
57
|
+
{ type: "settle" }, // wall-clock-wise this happens ~800ms later, irrelevant
|
|
58
|
+
]);
|
|
59
|
+
expect(s.lastDurationMs).toBe(1000);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("seeds startedAt from active=true if the user-input open was missed (reconnect)", () => {
|
|
63
|
+
const s = run([
|
|
64
|
+
{ type: "active", value: true, atMs: 3000 },
|
|
65
|
+
{ type: "active", value: false, atMs: 3500 },
|
|
66
|
+
{ type: "settle" },
|
|
67
|
+
]);
|
|
68
|
+
expect(s.lastDurationMs).toBe(500);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("ignores active=false when no turn is running", () => {
|
|
72
|
+
const s = run([{ type: "active", value: false, atMs: 100 }]);
|
|
73
|
+
expect(s).toEqual(initialTurnTimerState);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("preserves the original start when a second userInput arrives mid-turn", () => {
|
|
77
|
+
const s = run([
|
|
78
|
+
{ type: "userInput", atMs: 1000 },
|
|
79
|
+
{ type: "active", value: true, atMs: 1010 },
|
|
80
|
+
{ type: "userInput", atMs: 1500 }, // steering / follow-up during the run
|
|
81
|
+
]);
|
|
82
|
+
expect(s.startedAt).toBe(1000);
|
|
83
|
+
expect(s.running).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("keeps lastDurationMs across a reset of the active turn but reset() clears all", () => {
|
|
87
|
+
let s = run([
|
|
88
|
+
{ type: "userInput", atMs: 0 },
|
|
89
|
+
{ type: "active", value: true, atMs: 1 },
|
|
90
|
+
{ type: "active", value: false, atMs: 2000 },
|
|
91
|
+
{ type: "settle" },
|
|
92
|
+
]);
|
|
93
|
+
expect(s.lastDurationMs).toBe(2000);
|
|
94
|
+
s = turnTimerReducer(s, { type: "reset" });
|
|
95
|
+
expect(s).toEqual(initialTurnTimerState);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turnTimer — derive a "whole-turn" wall-clock duration for the Chat footer
|
|
3
|
+
* ("本轮对话用时"), issue #99.
|
|
4
|
+
*
|
|
5
|
+
* A turn is NOT a single assistant message. It spans from the user's input
|
|
6
|
+
* until every agent (principal + delegated experts + delivery loops) has
|
|
7
|
+
* finished — i.e. the authoritative `runState.active` flag (derived by the
|
|
8
|
+
* runtime, trace agent excluded) goes false and STAYS false.
|
|
9
|
+
*
|
|
10
|
+
* The subtlety (#99): `runState.active` can briefly flip true→false→true when a
|
|
11
|
+
* turn ends and a hook / system message / queued mailbox delivery immediately
|
|
12
|
+
* re-wakes an agent. That mid-flap `false` is NOT the end of the turn. So we
|
|
13
|
+
* debounce the terminal transition with a settle window: a false only counts as
|
|
14
|
+
* the turn's end once it has held for `settleMs`. If `active` goes true again
|
|
15
|
+
* inside the window, the candidate end is discarded and the same turn continues.
|
|
16
|
+
*
|
|
17
|
+
* This reducer is pure and driven entirely by authoritative backend signals
|
|
18
|
+
* (`runState.active` + the event's ISO timestamp). The host attaches the settle
|
|
19
|
+
* timer and a live "ticking" clock for the running display.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface TurnTimerState {
|
|
23
|
+
/** ms epoch when the current turn started (user input / first active=true). */
|
|
24
|
+
startedAt: number | null;
|
|
25
|
+
/** Whether a turn is currently in progress (active, or within settle window). */
|
|
26
|
+
running: boolean;
|
|
27
|
+
/** Pending terminal end: active went false, awaiting settle confirmation. */
|
|
28
|
+
candidateEndAt: number | null;
|
|
29
|
+
/** The last settled whole-turn duration in ms, or null if none yet. */
|
|
30
|
+
lastDurationMs: number | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const initialTurnTimerState: TurnTimerState = {
|
|
34
|
+
startedAt: null,
|
|
35
|
+
running: false,
|
|
36
|
+
candidateEndAt: null,
|
|
37
|
+
lastDurationMs: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type TurnTimerEvent =
|
|
41
|
+
/** Authoritative runState.active snapshot at time `atMs` (from session_state). */
|
|
42
|
+
| { type: "active"; value: boolean; atMs: number }
|
|
43
|
+
/** The settle window elapsed with active still false → commit the turn end. */
|
|
44
|
+
| { type: "settle" }
|
|
45
|
+
/** A fresh user submission opens a new turn at `atMs` (optimistic start). */
|
|
46
|
+
| { type: "userInput"; atMs: number }
|
|
47
|
+
/** Session switch / reset — clear all timing. */
|
|
48
|
+
| { type: "reset" };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Advance the turn-timer state machine. Pure: returns the next state. The host
|
|
52
|
+
* is responsible for (re)arming the settle timer whenever `candidateEndAt`
|
|
53
|
+
* becomes non-null, and dispatching `{type:"settle"}` after `settleMs`.
|
|
54
|
+
*/
|
|
55
|
+
export function turnTimerReducer(state: TurnTimerState, event: TurnTimerEvent): TurnTimerState {
|
|
56
|
+
switch (event.type) {
|
|
57
|
+
case "reset":
|
|
58
|
+
return initialTurnTimerState;
|
|
59
|
+
|
|
60
|
+
case "userInput": {
|
|
61
|
+
// Opening (or continuing) a turn from the user side. If a turn is already
|
|
62
|
+
// running, keep its original start; otherwise begin a new one. Clears any
|
|
63
|
+
// stale candidate end.
|
|
64
|
+
if (state.running && state.startedAt !== null) {
|
|
65
|
+
return { ...state, candidateEndAt: null };
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
startedAt: event.atMs,
|
|
69
|
+
running: true,
|
|
70
|
+
candidateEndAt: null,
|
|
71
|
+
lastDurationMs: state.lastDurationMs,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case "active": {
|
|
76
|
+
if (event.value) {
|
|
77
|
+
// active=true: turn is (still) running. Cancel any pending end. Seed a
|
|
78
|
+
// start if the user-input optimistic open was missed (e.g. reconnect).
|
|
79
|
+
return {
|
|
80
|
+
startedAt: state.startedAt ?? event.atMs,
|
|
81
|
+
running: true,
|
|
82
|
+
candidateEndAt: null,
|
|
83
|
+
lastDurationMs: state.lastDurationMs,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// active=false: candidate terminal transition. Only meaningful if a turn
|
|
87
|
+
// is in progress. Record the candidate end; the host arms the settle
|
|
88
|
+
// timer. A re-wake (active=true) before settle discards this.
|
|
89
|
+
if (!state.running || state.startedAt === null) return state;
|
|
90
|
+
return { ...state, candidateEndAt: event.atMs };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case "settle": {
|
|
94
|
+
// Settle window held with active false → commit the whole-turn duration.
|
|
95
|
+
if (state.candidateEndAt === null || state.startedAt === null) return state;
|
|
96
|
+
const duration = Math.max(0, state.candidateEndAt - state.startedAt);
|
|
97
|
+
return {
|
|
98
|
+
startedAt: null,
|
|
99
|
+
running: false,
|
|
100
|
+
candidateEndAt: null,
|
|
101
|
+
lastDurationMs: duration,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
default:
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTurnTimer — React host for the whole-turn timer reducer (#99).
|
|
3
|
+
*
|
|
4
|
+
* Consumes the authoritative `runActive` signal from SessionContext
|
|
5
|
+
* (session_state.runState.active + backend timestamp) and produces:
|
|
6
|
+
* - `running`: a turn is in progress (active, or within the settle window);
|
|
7
|
+
* - `elapsedMs`: live elapsed while running (ticks ~every second), or the
|
|
8
|
+
* last settled whole-turn duration once finished;
|
|
9
|
+
* - `lastDurationMs`: the last settled whole-turn duration.
|
|
10
|
+
*
|
|
11
|
+
* The settle window debounces the true→false→true flap that happens when a hook
|
|
12
|
+
* / system message / queued mailbox delivery re-wakes an agent right after a
|
|
13
|
+
* turn momentarily ends — that mid-flap false must NOT end the turn.
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useReducer, useRef, useState } from "react";
|
|
16
|
+
import {
|
|
17
|
+
turnTimerReducer,
|
|
18
|
+
initialTurnTimerState,
|
|
19
|
+
type TurnTimerState,
|
|
20
|
+
} from "./turnTimer";
|
|
21
|
+
|
|
22
|
+
/** Default debounce for the terminal active=false transition. */
|
|
23
|
+
export const DEFAULT_SETTLE_MS = 900;
|
|
24
|
+
|
|
25
|
+
export interface TurnTiming {
|
|
26
|
+
running: boolean;
|
|
27
|
+
/** Live elapsed while running; the settled duration once finished; null if none. */
|
|
28
|
+
elapsedMs: number | null;
|
|
29
|
+
lastDurationMs: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface UseTurnTimerOptions {
|
|
33
|
+
/** Backend run-active snapshot; null until the first session_state arrives. */
|
|
34
|
+
runActive: { active: boolean; atMs: number } | null;
|
|
35
|
+
/** Key that resets the timer when it changes (e.g. session id). */
|
|
36
|
+
resetKey?: string | null;
|
|
37
|
+
settleMs?: number;
|
|
38
|
+
/** Test seam: clock source (defaults to Date.now). */
|
|
39
|
+
now?: () => number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useTurnTimer(options: UseTurnTimerOptions): TurnTiming {
|
|
43
|
+
const { runActive, resetKey, settleMs = DEFAULT_SETTLE_MS, now = Date.now } = options;
|
|
44
|
+
const [state, dispatch] = useReducer(turnTimerReducer, initialTurnTimerState);
|
|
45
|
+
const settleRef = useRef<number | undefined>(undefined);
|
|
46
|
+
const [, forceTick] = useState(0);
|
|
47
|
+
|
|
48
|
+
// Reset when the session changes.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
dispatch({ type: "reset" });
|
|
51
|
+
}, [resetKey]);
|
|
52
|
+
|
|
53
|
+
// Feed authoritative active transitions into the reducer.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!runActive) return;
|
|
56
|
+
dispatch({ type: "active", value: runActive.active, atMs: runActive.atMs });
|
|
57
|
+
}, [runActive]);
|
|
58
|
+
|
|
59
|
+
// Arm/disarm the settle timer based on a pending candidate end.
|
|
60
|
+
const hasCandidate = state.candidateEndAt !== null;
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!hasCandidate) {
|
|
63
|
+
if (settleRef.current !== undefined) {
|
|
64
|
+
window.clearTimeout(settleRef.current);
|
|
65
|
+
settleRef.current = undefined;
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
settleRef.current = window.setTimeout(() => {
|
|
70
|
+
settleRef.current = undefined;
|
|
71
|
+
dispatch({ type: "settle" });
|
|
72
|
+
}, settleMs);
|
|
73
|
+
return () => {
|
|
74
|
+
if (settleRef.current !== undefined) {
|
|
75
|
+
window.clearTimeout(settleRef.current);
|
|
76
|
+
settleRef.current = undefined;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}, [hasCandidate, settleMs]);
|
|
80
|
+
|
|
81
|
+
// Tick once per second while running so the live elapsed advances.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!state.running) return;
|
|
84
|
+
const id = window.setInterval(() => forceTick((n) => n + 1), 1000);
|
|
85
|
+
return () => window.clearInterval(id);
|
|
86
|
+
}, [state.running]);
|
|
87
|
+
|
|
88
|
+
return deriveTiming(state, now);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function deriveTiming(state: TurnTimerState, now: () => number): TurnTiming {
|
|
92
|
+
if (state.running && state.startedAt !== null) {
|
|
93
|
+
return {
|
|
94
|
+
running: true,
|
|
95
|
+
elapsedMs: Math.max(0, now() - state.startedAt),
|
|
96
|
+
lastDurationMs: state.lastDurationMs,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
running: false,
|
|
101
|
+
elapsedMs: state.lastDurationMs,
|
|
102
|
+
lastDurationMs: state.lastDurationMs,
|
|
103
|
+
};
|
|
104
|
+
}
|