wave-code 1.5.0 → 1.6.0

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 (40) hide show
  1. package/bin/wave-code.js +2 -0
  2. package/dist/bundle/wave.mjs +777 -855
  3. package/package.json +7 -7
  4. package/src/cli.tsx +9 -3
  5. package/src/components/FileSelector.tsx +11 -8
  6. package/src/components/HelpView.tsx +1 -1
  7. package/src/components/InputBox.tsx +3 -5
  8. package/src/components/McpManager.tsx +26 -0
  9. package/src/components/MessageBlockItem.tsx +14 -2
  10. package/src/components/RemoteControlView.tsx +1 -1
  11. package/src/components/RewindCommand.tsx +40 -46
  12. package/src/contexts/useChat.tsx +61 -49
  13. package/src/daemon/commands.ts +266 -44
  14. package/src/daemon/daemonLog.ts +132 -0
  15. package/src/daemon/socketPath.ts +123 -0
  16. package/src/daemon-cli.ts +19 -6
  17. package/src/hooks/useInputManager.ts +27 -25
  18. package/src/index.ts +36 -4
  19. package/src/managers/inputHandlers.ts +3 -13
  20. package/src/managers/inputReducer.ts +44 -13
  21. package/src/print-cli.ts +199 -49
  22. package/src/remote/hostIdentity.ts +1 -0
  23. package/src/remote/imagePreview.ts +182 -0
  24. package/src/remote/messageWindow.ts +15 -5
  25. package/src/remote/notificationCoalescer.ts +16 -3
  26. package/src/remote/relayClient.ts +1 -1
  27. package/src/remote/remoteControlCliHost.ts +26 -4
  28. package/src/remote/remoteControlService.ts +70 -19
  29. package/src/remote/remoteNotifications.ts +5 -4
  30. package/src/remote/textPreview.ts +155 -0
  31. package/src/stdio/agentBridge.ts +450 -117
  32. package/src/stdio/daemonServer.ts +49 -33
  33. package/src/stdio/hostSlashCommands.ts +9 -4
  34. package/src/stdio/uiPayload.ts +50 -2
  35. package/src/utils/agentSelector.ts +29 -0
  36. package/src/utils/argvCompat.ts +23 -0
  37. package/src/utils/printOutput.ts +403 -0
  38. package/src/utils/usageSummary.ts +74 -24
  39. package/src/utils/worktree.ts +222 -16
  40. package/src/utils/rewindCheckpoints.ts +0 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI-based code assistant powered by AI, built with React and Ink",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,7 +59,7 @@
59
59
  "wrap-ansi": "^10.0.0",
60
60
  "yargs": "^17.7.2",
61
61
  "zod": "^3.23.8",
62
- "wave-agent-sdk": "1.5.0"
62
+ "wave-agent-sdk": "1.6.0"
63
63
  },
64
64
  "engines": {
65
65
  "node": ">=22"
@@ -69,11 +69,11 @@
69
69
  "wave": "tsx --tsconfig tsconfig.dev.json src/index.ts",
70
70
  "build": "rimraf dist && node scripts/bundle.mjs",
71
71
  "type-check": "tsc --noEmit --incremental",
72
- "test": "vitest run --reporter=dot",
73
- "test:coverage": "vitest run --coverage --reporter=dot",
74
- "test:unit": "vitest run --reporter=dot --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
75
- "test:unit:coverage": "vitest run --coverage --reporter=dot --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
76
- "test:integration": "vitest run --reporter=dot tests/integration .integration.test",
72
+ "test": "vitest run",
73
+ "test:coverage": "vitest run --coverage",
74
+ "test:unit": "vitest run --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
75
+ "test:unit:coverage": "vitest run --coverage --exclude 'tests/integration/**' --exclude '**/*.integration.test.ts'",
76
+ "test:integration": "vitest run tests/integration .integration.test",
77
77
  "lint": "oxlint",
78
78
  "format": "prettier --write ."
79
79
  }
package/src/cli.tsx CHANGED
@@ -3,7 +3,7 @@ import { render } from "ink";
3
3
  import { ensureRuntimeDeps } from "wave-agent-sdk";
4
4
  import { App } from "./components/App.js";
5
5
  import { cleanupLogs } from "./utils/logger.js";
6
- import { removeWorktree } from "./utils/worktree.js";
6
+ import { describeWorktreeSurvival, removeWorktree } from "./utils/worktree.js";
7
7
  import { BaseAppProps } from "./types.js";
8
8
 
9
9
  export interface CliOptions extends BaseAppProps {
@@ -96,8 +96,14 @@ export async function startCli(options: CliOptions): Promise<void> {
96
96
  if (shouldRemoveWorktree && worktreeSession) {
97
97
  process.stdout.write("\nDeleting worktree ...\n");
98
98
  process.chdir(worktreeSession.repoRoot);
99
- await removeWorktree(worktreeSession);
100
- process.stdout.write("Done.\n");
99
+ // "Done." only when it is: removal is best-effort, and a surviving
100
+ // directory (with its branch kept for it) needs the cleanup commands
101
+ // printed rather than a success line over the leftovers.
102
+ process.stdout.write(
103
+ (await removeWorktree(worktreeSession))
104
+ ? "Done.\n"
105
+ : `\n${describeWorktreeSurvival(worktreeSession)}\n`,
106
+ );
101
107
  }
102
108
 
103
109
  process.exit(0);
@@ -1,18 +1,18 @@
1
1
  import React, { useReducer, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
- import type { FileItem } from "wave-agent-sdk";
3
+ import type { FileSelectorItem } from "../managers/inputReducer.js";
4
4
  import {
5
5
  selectorReducer,
6
6
  type SelectorState,
7
7
  } from "../reducers/selectorReducer.js";
8
8
 
9
- export { type FileItem } from "wave-agent-sdk";
9
+ export type { FileSelectorItem };
10
10
 
11
11
  export interface FileSelectorProps {
12
- files: FileItem[];
12
+ files: FileSelectorItem[];
13
13
  searchQuery: string;
14
14
  isLoading?: boolean;
15
- onSelect: (filePath: string) => void;
15
+ onSelect: (item: FileSelectorItem) => void;
16
16
  onCancel: () => void;
17
17
  }
18
18
 
@@ -23,11 +23,11 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
23
23
  onSelect,
24
24
  onCancel,
25
25
  }) => {
26
- const [state, dispatch] = useReducer(selectorReducer<FileItem>, {
26
+ const [state, dispatch] = useReducer(selectorReducer<FileSelectorItem>, {
27
27
  selectedIndex: 0,
28
28
  pendingDecision: null,
29
29
  items: [],
30
- } as SelectorState<FileItem>);
30
+ } as SelectorState<FileSelectorItem>);
31
31
 
32
32
  const { selectedIndex, pendingDecision, items: filesInState } = state;
33
33
 
@@ -42,7 +42,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
42
42
 
43
43
  if (pendingDecision === "select" || pendingDecision === "insert") {
44
44
  if (filesInState.length > 0 && selectedIndex < filesInState.length) {
45
- onSelect(filesInState[selectedIndex].path);
45
+ onSelect(filesInState[selectedIndex]);
46
46
  }
47
47
  } else if (pendingDecision === "cancel") {
48
48
  onCancel();
@@ -124,8 +124,11 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
124
124
  backgroundColor={isSelected ? "cyan" : undefined}
125
125
  >
126
126
  {isSelected ? "▶ " : " "}
127
- {fileItem.path}
127
+ {fileItem.agentName ? `@${fileItem.agentName}` : fileItem.path}
128
128
  </Text>
129
+ {fileItem.agentDescription && (
130
+ <Text dimColor> {fileItem.agentDescription}</Text>
131
+ )}
129
132
  </Box>
130
133
  );
131
134
  })}
@@ -65,7 +65,7 @@ export const HelpView: React.FC<HelpViewProps> = ({
65
65
  });
66
66
 
67
67
  const helpItems = [
68
- { key: "@", description: "Reference files" },
68
+ { key: "@", description: "Reference files or subagents" },
69
69
  { key: "/", description: "Commands" },
70
70
  { key: "!", description: "Shell commands (e.g. !ls)" },
71
71
  { key: "Ctrl+R", description: "Search history" },
@@ -90,8 +90,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
90
90
  handleRewindSelect,
91
91
  resumeSession,
92
92
  backgroundCurrentTask,
93
- messages,
94
- getFullMessageThread,
93
+ getRewindCheckpoints,
95
94
  sessionId,
96
95
  workingDirectory,
97
96
  askBtw,
@@ -212,10 +211,10 @@ export const InputBox: React.FC<InputBoxProps> = ({
212
211
  onPlanCommand: handlePlanCommand,
213
212
  sessionId,
214
213
  workdir: workingDirectory,
215
- getFullMessageThread,
216
214
  hasQueuedMessages,
217
215
  isIdle,
218
216
  onRecallQueuedMessage,
217
+ subagentConfigurations: agentDefinitions,
219
218
  });
220
219
 
221
220
  // Keep setInputText ref updated for queue callbacks
@@ -402,10 +401,9 @@ export const InputBox: React.FC<InputBoxProps> = ({
402
401
 
403
402
  {showRewindManager && (
404
403
  <RewindCommand
405
- messages={messages}
406
404
  onSelect={handleRewindSelectWithClose}
407
405
  onCancel={handleRewindCancel}
408
- getFullMessageThread={getFullMessageThread}
406
+ getCheckpoints={getRewindCheckpoints}
409
407
  />
410
408
  )}
411
409
 
@@ -157,6 +157,26 @@ export const McpManager: React.FC<McpManagerProps> = ({
157
157
  </Text>
158
158
  </Box>
159
159
  )}
160
+ {/* Not dialed yet (lazy dial): the tool list comes from the local
161
+ snapshot of the last successful connection, and says so. */}
162
+ {selectedServer.status !== "connected" &&
163
+ selectedServer.toolsFromCache && (
164
+ <Box>
165
+ <Text>
166
+ <Text color="blue">Tools:</Text>{" "}
167
+ {selectedServer.toolsFromCache.length} tools{" "}
168
+ <Text color="gray" dimColor>
169
+ (cached
170
+ {selectedServer.toolsCachedAt
171
+ ? ` · last connected ${formatTime(
172
+ selectedServer.toolsCachedAt,
173
+ )}`
174
+ : ""}
175
+ )
176
+ </Text>
177
+ </Text>
178
+ </Box>
179
+ )}
160
180
  {selectedServer.capabilities && (
161
181
  <Box>
162
182
  <Text>
@@ -270,6 +290,12 @@ export const McpManager: React.FC<McpManagerProps> = ({
270
290
  {server.status === "connected" && server.toolCount && (
271
291
  <Text color="green"> · {server.toolCount} tools</Text>
272
292
  )}
293
+ {server.status !== "connected" && server.toolsFromCache && (
294
+ <Text color="gray" dimColor>
295
+ {" "}
296
+ · {server.toolsFromCache.length} tools (cached)
297
+ </Text>
298
+ )}
273
299
  </Text>
274
300
  {index === state.selectedIndex && (
275
301
  <Box marginLeft={4} flexDirection="column">
@@ -41,9 +41,21 @@ export const MessageBlockItem = ({
41
41
  </Box>
42
42
  )}
43
43
 
44
- {block.type === "error" && (
44
+ {/* A line for the human, never sent to the model (see NoticeBlock in the
45
+ SDK): a failed turn (`error`, red) or a hook's status line (`info`,
46
+ muted). */}
47
+ {block.type === "notice" && (
45
48
  <Box>
46
- <Text color="red">Error: {block.content}</Text>
49
+ {block.level === "error" ? (
50
+ <Text color="red">Error: {block.content}</Text>
51
+ ) : (
52
+ <Text
53
+ color={block.level === "warning" ? "yellow" : "gray"}
54
+ dimColor
55
+ >
56
+ {block.content}
57
+ </Text>
58
+ )}
47
59
  </Box>
48
60
  )}
49
61
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * RemoteControlView — `/remote-control` 的覆盖层(把这条前台会话交给手机)。
3
3
  *
4
- * 规格:docs/specs/ui/remote-control.md「前台 CLI 会话也能被手机接管」。
4
+ * 规格:docs/specs/remote/remote-web.md「前台 CLI 会话也能被手机接管」。
5
5
  *
6
6
  * **这个斜杠命令不带参数**:没有 `on` / `off` / `status` 子命令,也不收机器名。
7
7
  * 判据是「状态自己决定动作」——`off` / `error` 时唯一合法动作是开启,`connecting`
@@ -1,84 +1,78 @@
1
1
  import React, { useState, useReducer, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
- import type { Message } from "wave-agent-sdk";
4
- import { getMessageContent } from "wave-agent-sdk";
3
+ import type { RewindCheckpoint } from "wave-agent-sdk";
5
4
  import { rewindSelectorReducer } from "../reducers/rewindSelectorReducer.js";
6
- import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
5
+
6
+ /** 稳定的空表:加载期也要能渲染,但不能每帧造一个新数组(effect 依赖它)。 */
7
+ const NO_CHECKPOINTS: RewindCheckpoint[] = [];
7
8
 
8
9
  export interface RewindCommandProps {
9
- messages: Message[];
10
+ /**
11
+ * 取检查点表。SDK 扫转录当场投影,内存里不留整条线程——这一屏要的只是
12
+ * 「哪几条能回滚到」和它们在完整线程里的位置。
13
+ */
14
+ getCheckpoints: () => Promise<RewindCheckpoint[]>;
15
+ /** 参数是在完整线程里的绝对位置,直接喂 `truncateHistory(index)`。 */
10
16
  onSelect: (index: number) => void;
11
17
  onCancel: () => void;
12
- getFullMessageThread?: () => Promise<{
13
- messages: Message[];
14
- sessionIds: string[];
15
- }>;
16
18
  }
17
19
 
18
20
  export const RewindCommand: React.FC<RewindCommandProps> = ({
19
- messages: initialMessages,
21
+ getCheckpoints,
20
22
  onSelect,
21
23
  onCancel,
22
- getFullMessageThread,
23
24
  }) => {
24
- const [messages, setMessages] = useState<Message[]>(initialMessages);
25
- const [isLoading, setIsLoading] = useState(!!getFullMessageThread);
25
+ const [checkpoints, setCheckpoints] = useState<RewindCheckpoint[] | null>(
26
+ null,
27
+ );
28
+ const isLoading = checkpoints === null;
26
29
 
27
30
  useEffect(() => {
28
- if (getFullMessageThread) {
29
- getFullMessageThread().then(({ messages: fullMessages }) => {
30
- setMessages(fullMessages);
31
- setIsLoading(false);
32
- });
33
- }
34
- }, [getFullMessageThread]);
35
-
36
- // Filter user messages as checkpoints, excluding meta messages and
37
- // system-generated user-role messages (task notifications, hook injections).
38
- // Compaction is append-only: the same message id appears twice on the full
39
- // thread (pre-compact history + post-compact append), so dedupe by id and
40
- // keep the last occurrence (matching the folded view the user sees).
41
- const checkpointMap = new Map<string, { msg: Message; index: number }>();
42
- messages.forEach((msg, index) => {
43
- if (!isUserCheckpointMessage(msg)) return;
44
- checkpointMap.set(msg.id ?? `index:${index}`, { msg, index });
45
- });
46
- const checkpoints = Array.from(checkpointMap.values());
31
+ let cancelled = false;
32
+ getCheckpoints().then((loaded) => {
33
+ if (!cancelled) setCheckpoints(loaded);
34
+ });
35
+ return () => {
36
+ cancelled = true;
37
+ };
38
+ }, [getCheckpoints]);
39
+
40
+ const items = checkpoints ?? NO_CHECKPOINTS;
47
41
 
48
42
  const MAX_VISIBLE_ITEMS = 3;
49
43
 
50
44
  const [state, dispatch] = useReducer(rewindSelectorReducer, {
51
- selectedIndex: checkpoints.length - 1,
45
+ selectedIndex: items.length - 1,
52
46
  pendingDecision: null,
53
47
  });
54
48
 
55
49
  const { selectedIndex, pendingDecision } = state;
56
50
 
57
- // Update selectedIndex when checkpoints change (after loading full thread)
51
+ // Update selectedIndex when checkpoints arrive
58
52
  useEffect(() => {
59
- dispatch({ type: "RESET_INDEX", index: checkpoints.length - 1 });
60
- }, [checkpoints.length]);
53
+ dispatch({ type: "RESET_INDEX", index: items.length - 1 });
54
+ }, [items.length]);
61
55
 
62
56
  // Handle decisions from reducer
63
57
  useEffect(() => {
64
58
  if (!pendingDecision) return;
65
59
 
66
60
  if (pendingDecision === "select") {
67
- if (checkpoints.length > 0 && selectedIndex >= 0) {
68
- onSelect(checkpoints[selectedIndex].index);
61
+ if (items.length > 0 && selectedIndex >= 0) {
62
+ onSelect(items[selectedIndex].index);
69
63
  }
70
64
  } else if (pendingDecision === "cancel") {
71
65
  onCancel();
72
66
  }
73
67
 
74
68
  dispatch({ type: "CLEAR_DECISION" });
75
- }, [pendingDecision, selectedIndex, checkpoints, onSelect, onCancel]);
69
+ }, [pendingDecision, selectedIndex, items, onSelect, onCancel]);
76
70
 
77
71
  useInput((input, key) => {
78
72
  dispatch({
79
73
  type: "HANDLE_KEY",
80
74
  key,
81
- maxIndex: checkpoints.length - 1,
75
+ maxIndex: items.length - 1,
82
76
  });
83
77
  });
84
78
 
@@ -92,12 +86,12 @@ export const RewindCommand: React.FC<RewindCommandProps> = ({
92
86
  borderLeft={false}
93
87
  borderRight={false}
94
88
  >
95
- <Text color="cyan">Loading full message thread...</Text>
89
+ <Text color="cyan">Loading rewind checkpoints...</Text>
96
90
  </Box>
97
91
  );
98
92
  }
99
93
 
100
- if (checkpoints.length === 0) {
94
+ if (items.length === 0) {
101
95
  return (
102
96
  <Box
103
97
  flexDirection="column"
@@ -118,10 +112,10 @@ export const RewindCommand: React.FC<RewindCommandProps> = ({
118
112
  0,
119
113
  Math.min(
120
114
  selectedIndex - Math.floor(MAX_VISIBLE_ITEMS / 2),
121
- Math.max(0, checkpoints.length - MAX_VISIBLE_ITEMS),
115
+ Math.max(0, items.length - MAX_VISIBLE_ITEMS),
122
116
  ),
123
117
  );
124
- const visibleCheckpoints = checkpoints.slice(
118
+ const visibleCheckpoints = items.slice(
125
119
  startIndex,
126
120
  startIndex + MAX_VISIBLE_ITEMS,
127
121
  );
@@ -146,19 +140,19 @@ export const RewindCommand: React.FC<RewindCommandProps> = ({
146
140
  {visibleCheckpoints.map((checkpoint, index) => {
147
141
  const actualIndex = startIndex + index;
148
142
  const isSelected = actualIndex === selectedIndex;
149
- const content = getMessageContent(checkpoint.msg)
143
+ const content = checkpoint.content
150
144
  .replace(/\n/g, "\\n")
151
145
  .substring(0, 60);
152
146
 
153
147
  return (
154
- <Box key={checkpoint.index}>
148
+ <Box key={checkpoint.id}>
155
149
  <Text
156
150
  color={isSelected ? "black" : "white"}
157
151
  backgroundColor={isSelected ? "cyan" : undefined}
158
152
  >
159
153
  {isSelected ? "▶ " : " "}[{checkpoint.index}]{" "}
160
154
  {content || "(No text content)"}
161
- {actualIndex === checkpoints.length - 1 ? " (Latest)" : ""}
155
+ {actualIndex === items.length - 1 ? " (Latest)" : ""}
162
156
  </Text>
163
157
  </Box>
164
158
  );
@@ -14,6 +14,7 @@ import {
14
14
  } from "../hooks/useRemoteControl.js";
15
15
  import type {
16
16
  Message,
17
+ MessageBlock,
17
18
  McpServerStatus,
18
19
  BackgroundTask,
19
20
  Task,
@@ -26,6 +27,7 @@ import type {
26
27
  QueuedMessage,
27
28
  WorkflowRun,
28
29
  ToolBlockUpdateCallbackParams,
30
+ RewindCheckpoint,
29
31
  } from "wave-agent-sdk";
30
32
  import {
31
33
  Agent,
@@ -184,10 +186,8 @@ export interface ChatContextType {
184
186
  forceRemount: (options?: { preserveScrollback?: boolean }) => void;
185
187
  // Rewind functionality
186
188
  handleRewindSelect: (index: number) => Promise<void>;
187
- getFullMessageThread: () => Promise<{
188
- messages: Message[];
189
- sessionIds: string[];
190
- }>;
189
+ /** `/rewind` 的检查点表:SDK 扫转录投影,CLI 不留整条线程。 */
190
+ getRewindCheckpoints: () => Promise<RewindCheckpoint[]>;
191
191
  // Status metadata
192
192
  getGatewayConfig: () => import("wave-agent-sdk").GatewayConfig;
193
193
  getModelConfig: () => import("wave-agent-sdk").ModelConfig;
@@ -364,6 +364,38 @@ const applyToolBlockUpdate = (
364
364
  });
365
365
  };
366
366
 
367
+ /**
368
+ * Append a block to the last message — only when it is an assistant message
369
+ * (the current turn's in-flight reply). If the last message is a user message,
370
+ * the block belongs BELOW it: create a new assistant message instead of
371
+ * polluting the stale assistant from an earlier turn. Same rule the webview
372
+ * reducer applies (APPEND_NOTICE_BLOCK).
373
+ *
374
+ * Both the error and the system line use this: the SDK appends them to the
375
+ * same place (`appendBlockToLastAssistant`), so the CLI must not invent a
376
+ * second placement rule.
377
+ */
378
+ const appendBlockToLastAssistant = (
379
+ prev: Message[],
380
+ block: MessageBlock,
381
+ ): Message[] => {
382
+ const last = prev[prev.length - 1];
383
+ if (last && last.role === "assistant") {
384
+ return prev.map((m, idx) =>
385
+ idx === prev.length - 1 ? { ...m, blocks: [...m.blocks, block] } : m,
386
+ );
387
+ }
388
+ return [
389
+ ...prev,
390
+ {
391
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
392
+ role: "assistant",
393
+ timestamp: new Date().toISOString(),
394
+ blocks: [block],
395
+ },
396
+ ];
397
+ };
398
+
367
399
  export const ChatProvider: React.FC<ChatProviderProps> = ({
368
400
  children,
369
401
  bypassPermissions,
@@ -727,39 +759,19 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
727
759
  // `end` carries the authoritative final values.
728
760
  setMessages((prev) => applyToolBlockUpdate(prev, params));
729
761
  },
730
- onErrorBlockAdded: (error: string) => {
731
- remoteStream?.onErrorBlockAdded(error);
762
+ onNoticeAdded: ({ level, content }) => {
763
+ remoteStream?.onNoticeAdded({ level, content });
732
764
  if (isExpandedRef.current) return;
733
- // One-shot structural update — applied directly (errors must not be
734
- // delayed by the tool-running window).
735
- setMessages((prev) => {
736
- // Append to the LAST message only if it is an assistant message
737
- // (the current turn's in-flight reply). If the last message is a
738
- // user message, the error belongs BELOW it — create a new
739
- // assistant message instead of polluting the stale assistant from
740
- // an earlier turn (which surfaced the error above the latest
741
- // user message and accumulated it there).
742
- const last = prev[prev.length - 1];
743
- if (last && last.role === "assistant") {
744
- return prev.map((m, idx) =>
745
- idx === prev.length - 1
746
- ? {
747
- ...m,
748
- blocks: [...m.blocks, { type: "error", content: error }],
749
- }
750
- : m,
751
- );
752
- }
753
- return [
754
- ...prev,
755
- {
756
- id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
757
- role: "assistant",
758
- timestamp: new Date().toISOString(),
759
- blocks: [{ type: "error", content: error }],
760
- },
761
- ];
762
- });
765
+ // One-shot structural update — applied directly (a failure must not
766
+ // be delayed by the tool-running window; a hook's round line must
767
+ // not wait for the next `refreshMessages`).
768
+ setMessages((prev) =>
769
+ appendBlockToLastAssistant(prev, {
770
+ type: "notice",
771
+ level,
772
+ content,
773
+ }),
774
+ );
763
775
  },
764
776
  onLatestTotalTokensChange: (tokens) => {
765
777
  setLatestTotalTokens(tokens);
@@ -782,9 +794,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
782
794
  }
783
795
  },
784
796
  onCompactionContentUpdate: (content) => {
785
- // The SDK delivers the accumulated compaction text; the loading
786
- // indicator renders only its last 30 characters (streaming tail).
787
- setCompactionStream(content);
797
+ // Delta of newly produced compaction text — appended; the loading
798
+ // indicator renders only the last 30 characters (streaming tail).
799
+ setCompactionStream((prev) => prev + content);
788
800
  },
789
801
  onBackgroundTasksChange: async (tasks) => {
790
802
  setBackgroundTasks([...tasks]);
@@ -1214,7 +1226,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1214
1226
  timestamp: new Date().toISOString(),
1215
1227
  blocks: [
1216
1228
  {
1217
- type: "error",
1229
+ type: "notice",
1230
+ level: "error",
1218
1231
  content: `Failed to resume conversation: ${
1219
1232
  error instanceof Error ? error.message : String(error)
1220
1233
  }`,
@@ -1468,12 +1481,11 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1468
1481
  renameSession: (title) => agentRef.current?.renameSession(title),
1469
1482
  compact: (instructions) => compact(instructions),
1470
1483
  slashCommands: () => agentRef.current?.getSlashCommands() ?? [],
1471
- // 手机页取首屏尾段 / 补差都读 agent 手上那份列表(压缩后的上下文),不去
1472
- // 转录里找压缩前的历史。
1484
+ // 手机页取首屏尾段 / 补差都读 agent 手上那份列表(压缩后的上下文);它这一侧
1485
+ // 不提供上翻,所以不会带来 `before` 锚点。
1473
1486
  messagePage: (window) =>
1474
- Promise.resolve(
1475
- agentRef.current?.getMessagePage(window) ?? { messages: [] },
1476
- ),
1487
+ agentRef.current?.getMessagePage(window) ??
1488
+ Promise.resolve({ messages: [] }),
1477
1489
  currentThread: () =>
1478
1490
  Promise.resolve([...(agentRef.current?.messages ?? [])]),
1479
1491
  threadSummary: () =>
@@ -1505,11 +1517,11 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1505
1517
  [forceRemount, refreshMessages],
1506
1518
  );
1507
1519
 
1508
- const getFullMessageThread = useCallback(async () => {
1520
+ const getRewindCheckpoints = useCallback(async () => {
1509
1521
  if (agentRef.current) {
1510
- return await agentRef.current.getFullMessageThread();
1522
+ return await agentRef.current.getRewindCheckpoints();
1511
1523
  }
1512
- return { messages: [], sessionIds: [] };
1524
+ return [];
1513
1525
  }, []);
1514
1526
 
1515
1527
  const getGatewayConfig = useCallback(() => {
@@ -1630,7 +1642,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1630
1642
  remountKey,
1631
1643
  forceRemount,
1632
1644
  handleRewindSelect,
1633
- getFullMessageThread,
1645
+ getRewindCheckpoints,
1634
1646
 
1635
1647
  getGatewayConfig,
1636
1648
  getModelConfig,