本章总览
ResumeConversation.tsx(约 400 行)是 Claude Code 交互式 会话恢复 picker:扫描 ~/.claude 下 JSONL 日志,用 LogSelector 让用户选择后继续聊天。选中后它不定义新 Screen 类型,而是 unmount picker、mount REPL 并传入 initialMessages。本章覆盖渐进式日志加载、跨项目 resume、fork 模式与 launchResumeChooser 挂载路径。
学完本章你应该能
- 说明 launchResumeChooser 与 launchRepl 的并行 import 策略
- 解释 loadSameRepoMessageLogsProgressive / enrichLogs 分页加载
- 描述 onSelect 内 switchSession、restoreAgentFromSession、context collapse 恢复顺序
- 理解 crossProjectCommand 与 filterByPr 分支
- 掌握 resumeData → REPL 的状态交接字段清单
核心概念(先读懂这些)
ResumeConversation 是 REPL 的前置向导
与 Doctor(一次性诊断)不同,ResumeConversation 是过渡态组件:loading → LogSelector → resuming spinner → REPL。不存在持久的「resume screen」路由枚举;CLI claude --resume 与交互 picker 最终都 converge 到同一 REPL Props 形状。
渐进式日志加载控制内存
sessionLogResultRef 持有 SessionLogResult,enrichLogs 批量填充 LogOption 元数据。logCountRef 镜像 logs.length,保证 setLogs updater 纯函数。showAllProjects 切换时重新 loadAllProjectsMessageLogsProgressive,支持跨仓库浏览历史。
建议学习步骤
- 阅读 Props 与 export function ResumeConversation(源码块 A)
- 阅读 filteredLogs 与初始 loadEffect(源码块 B)
- 走读 onSelect 恢复链(源码块 C)
- 阅读条件 render:resumeData → REPL(源码块 D)
- 对照 dialogLaunchers launchResumeChooser(源码块 E)
常见误区
注意
forkSession 时不 switchSession,但可能 recordContentReplacement
注意
COORDINATOR_MODE 下 matchSessionMode 可能注入 warning system message
注意
crossProject 同 repo worktree 可继续;纯跨目录则只复制命令不 mount REPL
挂载路径:launchResumeChooser
main.tsx 在用户未指定 session id 且需交互选择时调用 launchResumeChooser(dialogLaunchers.tsx ~117 行)。
与 launchRepl 对比:
| 项 | launchRepl | launchResumeChooser |
|---|---|---|
| 根组件 | REPL | ResumeConversation |
| KeybindingSetup | 无(App 内处理) | 包裹 ResumeConversation |
| 并行 Promise.all | App + REPL | worktreePaths + ResumeConversation + App |
| render 方式 | renderAndRun | renderAndRun(非 showSetupDialog) |
worktreePathsPromise 与组件 import 并行,避免串行等待 git worktree 探测拖慢首屏。
源码引用: src/dialogLaunchers.tsx · 第 112–132 行(共 202 行)
112| })
113| const resultPromise = showSetupDialog<string | null>(root, done => (
114| <NewInstallWizard
115| defaultDir={defaultDir}
116| onInstalled={dir => done(dir)}
117| onCancel={() => done(null)}
118| onError={message =>
119| rejectWithError(new Error(`Installation failed: ${message}`))
120| }
121| />
122| ))
123| return Promise.race([resultPromise, errorPromise])
124| }
125|
126| /**
127| * Site ~4549: TeleportResumeWrapper (interactive teleport session picker).
128| * Original callback wiring: onComplete={done}, onCancel={() => done(null)}, source="cliArg".
129| */
130| export async function launchTeleportResumeWrapper(
131| root: Root,
132| ): Promise<TeleportRemoteResponse | null> {
源码引用: src/dialogLaunchers.tsx · 第 21–23 行(共 202 行)
21| // Type-only access to ResumeConversation's Props via the module type.
22| // No runtime cost - erased at compile time.
23| type ResumeConversationProps = React.ComponentProps<
Props 与组件入口
ResumeConversation Props 与 REPL 大量重叠(commands、initialTools、mcpClients、thinkingConfig),额外字段:
worktreePaths— launcher 注入,用于同 repo 日志过滤forkSession— fork 时不 adopt session filefilterByPr— true / number / string(GitHub PR URL)过滤initialSearchQuery— LogSelector 预填搜索onTurnComplete— 传递给下游 REPL
组件内 state:logs、loading、resuming、showAllProjects、resumeData、crossProjectCommand。
源码引用: src/screens/ResumeConversation.tsx · 第 47–86 行(共 469 行)
47| recordContentReplacement,
48| resetSessionFilePointer,
49| restoreSessionMetadata,
50| type SessionLogResult,
51| } from '../utils/sessionStorage.js'
52| import type { ThinkingConfig } from '../utils/thinking.js'
53| import type { ContentReplacementRecord } from '../utils/toolResultStorage.js'
54| import { REPL } from './REPL.js'
55|
56| function parsePrIdentifier(value: string): number | null {
57| const directNumber = parseInt(value, 10)
58| if (!isNaN(directNumber) && directNumber > 0) {
59| return directNumber
60| }
61| const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/)
62| if (urlMatch?.[1]) {
63| return parseInt(urlMatch[1], 10)
64| }
65| return null
66| }
67|
68| type Props = {
69| commands: Command[]
70| worktreePaths: string[]
71| initialTools: Tool[]
72| mcpClients?: MCPServerConnection[]
73| dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>
74| debug: boolean
75| mainThreadAgentDefinition?: AgentDefinition
76| autoConnectIdeFlag?: boolean
77| strictMcpConfig?: boolean
78| systemPrompt?: string
79| appendSystemPrompt?: string
80| initialSearchQuery?: string
81| disableSlashCommands?: boolean
82| forkSession?: boolean
83| taskListId?: string
84| filterByPr?: boolean | number | string
85| thinkingConfig: ThinkingConfig
86| onTurnComplete?: (messages: Message[]) => void | Promise<void>
源码引用: src/screens/ResumeConversation.tsx · 第 92–104 行(共 469 行)
92| initialTools,
93| mcpClients,
94| dynamicMcpConfig,
95| debug,
96| mainThreadAgentDefinition,
97| autoConnectIdeFlag,
98| strictMcpConfig = false,
99| systemPrompt,
100| appendSystemPrompt,
101| initialSearchQuery,
102| disableSlashCommands = false,
103| forkSession,
104| taskListId,
日志加载与 filterByPr
filteredLogs useMemo:
- 排除 sidechain 日志(
!l.isSidechain) - 按 filterByPr 过滤:true = 任意 PR;number = 精确 PR;string = parsePrIdentifier(支持 github.com/.../pull/N URL)
loadSameRepoMessageLogsProgressive(worktreePaths) 在 mount useEffect 触发,完成后 setLoading(false)。
loadMoreLogs 调用 enrichLogs 追加批次;若 enrich 返回空但仍有 stat logs,递归 loadMoreLogs 跳过损坏条目。
handleToggleAllProjects 切换 loadAllProjectsMessageLogsProgressive,允许浏览其他仓库会话(仍受 crossProject 规则约束)。
源码引用: src/screens/ResumeConversation.tsx · 第 109–136 行(共 469 行)
109| const { rows } = useTerminalSize()
110| const agentDefinitions = useAppState(s => s.agentDefinitions)
111| const setAppState = useSetAppState()
112| const [logs, setLogs] = React.useState<LogOption[]>([])
113| const [loading, setLoading] = React.useState(true)
114| const [resuming, setResuming] = React.useState(false)
115| const [showAllProjects, setShowAllProjects] = React.useState(false)
116| const [resumeData, setResumeData] = React.useState<{
117| messages: Message[]
118| fileHistorySnapshots?: FileHistorySnapshot[]
119| contentReplacements?: ContentReplacementRecord[]
120| agentName?: string
121| agentColor?: AgentColorName
122| mainThreadAgentDefinition?: AgentDefinition
123| } | null>(null)
124| const [crossProjectCommand, setCrossProjectCommand] = React.useState<
125| string | null
126| >(null)
127| const sessionLogResultRef = React.useRef<SessionLogResult | null>(null)
128| // Mirror of logs.length so loadMoreLogs can compute value indices outside
129| // the setLogs updater (keeping it pure per React's contract).
130| const logCountRef = React.useRef(0)
131|
132| const filteredLogs = React.useMemo(() => {
133| let result = logs.filter(l => !l.isSidechain)
134| if (filterByPr !== undefined) {
135| if (filterByPr === true) {
136| result = result.filter(l => l.prNumber !== undefined)
源码引用: src/screens/ResumeConversation.tsx · 第 137–173 行(共 469 行)
137| } else if (typeof filterByPr === 'number') {
138| result = result.filter(l => l.prNumber === filterByPr)
139| } else if (typeof filterByPr === 'string') {
140| const prNumber = parsePrIdentifier(filterByPr)
141| if (prNumber !== null) {
142| result = result.filter(l => l.prNumber === prNumber)
143| }
144| }
145| }
146| return result
147| }, [logs, filterByPr])
148| const isResumeWithRenameEnabled = isCustomTitleEnabled()
149|
150| React.useEffect(() => {
151| loadSameRepoMessageLogsProgressive(worktreePaths)
152| .then(result => {
153| sessionLogResultRef.current = result
154| logCountRef.current = result.logs.length
155| setLogs(result.logs)
156| setLoading(false)
157| })
158| .catch(error => {
159| logError(error)
160| setLoading(false)
161| })
162| }, [worktreePaths])
163|
164| const loadMoreLogs = React.useCallback((count: number) => {
165| const ref = sessionLogResultRef.current
166| if (!ref || ref.nextIndex >= ref.allStatLogs.length) return
167|
168| void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result => {
169| ref.nextIndex = result.nextIndex
170| if (result.logs.length > 0) {
171| // enrichLogs returns fresh unshared objects — safe to mutate in place.
172| // Offset comes from logCountRef so the setLogs updater stays pure.
173| const offset = logCountRef.current
源码引用: src/screens/ResumeConversation.tsx · 第 36–46 行(共 469 行)
36| import {
37| computeStandaloneAgentContext,
38| restoreAgentFromSession,
39| restoreWorktreeForResume,
40| } from '../utils/sessionRestore.js'
41| import {
42| adoptResumedSessionFile,
43| enrichLogs,
44| isCustomTitleEnabled,
45| loadAllProjectsMessageLogsProgressive,
46| loadSameRepoMessageLogsProgressive,
onSelect:会话恢复 orchestration
用户选中 LogOption 后 onSelect 执行长链恢复(async):
checkCrossProjectResume → 可能 CrossProjectMessage
loadConversationForResume(log)
→ coordinator mode warning(可选)
→ switchSession + renameRecording + resetSessionFilePointer(非 fork)
→ restoreCostStateForSession
→ restoreAgentFromSession → setAppState agent
→ restoreSessionMetadata + restoreWorktreeForResume
→ adoptResumedSessionFile
→ context collapse restore(feature gate)
→ logEvent tengu_session_resumed
→ setResumeData({ messages, fileHistorySnapshots, ... })
失败时 logEvent success:false 并 rethrow。性能指标 resume_duration_ms 写入 analytics。
agenticSessionSearch 作为 LogSelector 的 onAgenticSearch prop,支持自然语言搜会话。
源码引用: src/screens/ResumeConversation.tsx · 第 178–220 行(共 469 行)
178| logCountRef.current += result.logs.length
179| } else if (ref.nextIndex < ref.allStatLogs.length) {
180| loadMoreLogs(count)
181| }
182| })
183| }, [])
184|
185| const loadLogs = React.useCallback(
186| (allProjects: boolean) => {
187| setLoading(true)
188| const promise = allProjects
189| ? loadAllProjectsMessageLogsProgressive()
190| : loadSameRepoMessageLogsProgressive(worktreePaths)
191| promise
192| .then(result => {
193| sessionLogResultRef.current = result
194| logCountRef.current = result.logs.length
195| setLogs(result.logs)
196| })
197| .catch(error => {
198| logError(error)
199| })
200| .finally(() => {
201| setLoading(false)
202| })
203| },
204| [worktreePaths],
205| )
206|
207| const handleToggleAllProjects = React.useCallback(() => {
208| const newValue = !showAllProjects
209| setShowAllProjects(newValue)
210| loadLogs(newValue)
211| }, [showAllProjects, loadLogs])
212|
213| function onCancel() {
214| // eslint-disable-next-line custom-rules/no-process-exit
215| process.exit(1)
216| }
217|
218| async function onSelect(log: LogOption) {
219| setResuming(true)
220| const resumeStart = performance.now()
源码引用: src/screens/ResumeConversation.tsx · 第 220–292 行(共 469 行)
220| const resumeStart = performance.now()
221|
222| const crossProjectCheck = checkCrossProjectResume(
223| log,
224| showAllProjects,
225| worktreePaths,
226| )
227| if (crossProjectCheck.isCrossProject) {
228| if (!crossProjectCheck.isSameRepoWorktree) {
229| const raw = await setClipboard(crossProjectCheck.command)
230| if (raw) process.stdout.write(raw)
231| setCrossProjectCommand(crossProjectCheck.command)
232| return
233| }
234| }
235|
236| try {
237| const result = await loadConversationForResume(log, undefined)
238| if (!result) {
239| throw new Error('Failed to load conversation')
240| }
241|
242| if (feature('COORDINATOR_MODE')) {
243| /* eslint-disable @typescript-eslint/no-require-imports */
244| const coordinatorModule =
245| require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js')
246| /* eslint-enable @typescript-eslint/no-require-imports */
247| const warning = coordinatorModule.matchSessionMode(result.mode)
248| if (warning) {
249| /* eslint-disable @typescript-eslint/no-require-imports */
250| const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } =
251| require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js')
252| /* eslint-enable @typescript-eslint/no-require-imports */
253| getAgentDefinitionsWithOverrides.cache.clear?.()
254| const freshAgentDefs = await getAgentDefinitionsWithOverrides(
255| getOriginalCwd(),
256| )
257| setAppState(prev => ({
258| ...prev,
259| agentDefinitions: {
260| ...freshAgentDefs,
261| allAgents: freshAgentDefs.allAgents,
262| activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents),
263| },
264| }))
265| result.messages.push(createSystemMessage(warning, 'warning'))
266| }
267| }
268|
269| if (result.sessionId && !forkSession) {
270| switchSession(
271| asSessionId(result.sessionId),
272| log.fullPath ? dirname(log.fullPath) : null,
273| )
274| await renameRecordingForSession()
275| await resetSessionFilePointer()
276| restoreCostStateForSession(result.sessionId)
277| } else if (forkSession && result.contentReplacements?.length) {
278| await recordContentReplacement(result.contentReplacements)
279| }
280|
281| const { agentDefinition: resolvedAgentDef } = restoreAgentFromSession(
282| result.agentSetting,
283| mainThreadAgentDefinition,
284| agentDefinitions,
285| )
286| setAppState(prev => ({ ...prev, agent: resolvedAgentDef?.agentType }))
287|
288| if (feature('COORDINATOR_MODE')) {
289| /* eslint-disable @typescript-eslint/no-require-imports */
290| const { saveMode } = require('../utils/sessionStorage.js')
291| const { isCoordinatorMode } =
292| require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js')
条件渲染:handoff 到 REPL
render 优先级(短路顺序):
crossProjectCommand→ CrossProjectMessage(复制 cd 命令)resumeData→<REPL ... initialMessages={resumeData.messages} />loading→ Spinner "Loading conversations…"resuming→ Spinner "Resuming conversation…"filteredLogs.length === 0→ NoConversationsMessage(Ctrl+C exit)- 默认 → LogSelector
LogSelector 接收 maxHeight={rows}(useTerminalSize)、onLoadMore、showAllProjects 切换、isResumeWithRenameEnabled 时 onLogsChanged 刷新列表。
NoConversationsMessage 绑定 app:interrupt → process.exit(1)。
源码引用: src/screens/ResumeConversation.tsx · 第 293–315 行(共 469 行)
293| /* eslint-enable @typescript-eslint/no-require-imports */
294| saveMode(isCoordinatorMode() ? 'coordinator' : 'normal')
295| }
296|
297| const standaloneAgentContext = computeStandaloneAgentContext(
298| result.agentName,
299| result.agentColor,
300| )
301| if (standaloneAgentContext) {
302| setAppState(prev => ({ ...prev, standaloneAgentContext }))
303| }
304| void updateSessionName(result.agentName)
305|
306| restoreSessionMetadata(
307| forkSession ? { ...result, worktreeSession: undefined } : result,
308| )
309|
310| if (!forkSession) {
311| restoreWorktreeForResume(result.worktreeSession)
312| if (result.sessionId) {
313| adoptResumedSessionFile()
314| }
315| }
源码引用: src/screens/ResumeConversation.tsx · 第 316–336 行(共 469 行)
316|
317| if (feature('CONTEXT_COLLAPSE')) {
318| /* eslint-disable @typescript-eslint/no-require-imports */
319| ;(
320| require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js')
321| ).restoreFromEntries(
322| result.contextCollapseCommits ?? [],
323| result.contextCollapseSnapshot,
324| )
325| /* eslint-enable @typescript-eslint/no-require-imports */
326| }
327|
328| logEvent('tengu_session_resumed', {
329| entrypoint:
330| 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
331| success: true,
332| resume_duration_ms: Math.round(performance.now() - resumeStart),
333| })
334|
335| setLogs([])
336| setResumeData({
CrossProjectMessage 与剪贴板
跨目录 resume 时 setClipboard(crossProjectCheck.command) 写入 OSC 52 序列,stdout 可能写入 raw escape。UI 展示:
- "This conversation is from a different directory."
- 建议用户在目标目录运行显示的 claude 命令
同 repo 的 worktree 路径例外:checkCrossProjectResume 允许继续 onSelect,无需 CrossProjectMessage。
这与 screens 层「路由」概念相关:某些 resume 场景 拒绝 在当前 cwd mount REPL,强制用户切换到正确目录——安全与路径一致性考虑。
源码引用: src/screens/ResumeConversation.tsx · 第 340–370 行(共 469 行)
340| agentName: result.agentName,
341| agentColor: (result.agentColor === 'default'
342| ? undefined
343| : result.agentColor) as AgentColorName | undefined,
344| mainThreadAgentDefinition: resolvedAgentDef,
345| })
346| } catch (e) {
347| logEvent('tengu_session_resumed', {
348| entrypoint:
349| 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
350| success: false,
351| })
352| logError(e as Error)
353| throw e
354| }
355| }
356|
357| if (crossProjectCommand) {
358| return <CrossProjectMessage command={crossProjectCommand} />
359| }
360|
361| if (resumeData) {
362| return (
363| <REPL
364| debug={debug}
365| commands={commands}
366| initialTools={initialTools}
367| initialMessages={resumeData.messages}
368| initialFileHistorySnapshots={resumeData.fileHistorySnapshots}
369| initialContentReplacements={resumeData.contentReplacements}
370| initialAgentName={resumeData.agentName}
源码引用: src/screens/ResumeConversation.tsx · 第 181–189 行(共 469 行)
181| }
182| })
183| }, [])
184|
185| const loadLogs = React.useCallback(
186| (allProjects: boolean) => {
187| setLoading(true)
188| const promise = allProjects
189| ? loadAllProjectsMessageLogsProgressive()
LogSelector 交互与 agentic 搜索
LogSelector 组件(components/LogSelector.js)接收 ResumeConversation 传入的 props:
maxHeight={rows}— 终端高度自适应列表onLoadMore={loadMoreLogs}— 滚动到底加载 enrichLogs 批次onAgenticSearch={agenticSessionSearch}— 自然语言搜索会话,与手动 filter 互补initialSearchQuery— CLI--resume-search等入口预填showAllProjects/onToggleAllProjects— 跨项目浏览开关
isResumeWithRenameEnabled() 为 true 时注册 onLogsChanged 回调,用户重命名 session 后 refresh 列表而不重启 picker。
onCancel 绑定 process.exit(1),与 NoConversationsMessage 的 app:interrupt 一致——Resume picker 无「返回空 REPL」选项,取消即退出进程。
forkSession 与 contentReplacements
forkSession Prop 改变恢复语义:
| 行为 | forkSession=false | forkSession=true |
|---|---|---|
| switchSession | 执行 | 跳过 |
| adoptResumedSessionFile | 执行 | 跳过 |
| recordContentReplacement | 跳过 | 可能执行 |
| restoreSessionMetadata | 含 worktreeSession | 清除 worktreeSession |
fork 用于「从旧会话分支新会话」场景:保留 messages 快照但不接管原 session id 文件指针,避免覆盖原 JSONL。
contentReplacements 来自 tool result 外置存储恢复,REPL 通过 initialContentReplacements 传入以还原大 payload 引用。
源码引用: src/screens/ResumeConversation.tsx · 第 220–227 行(共 469 行)
220| const resumeStart = performance.now()
221|
222| const crossProjectCheck = checkCrossProjectResume(
223| log,
224| showAllProjects,
225| worktreePaths,
226| )
227| if (crossProjectCheck.isCrossProject) {
本章小结与延伸
ResumeConversation = 日志 picker + 会话恢复 orchestration + REPL handoff。REPL Screen 类型见 repl-screen 章。 继续学习: