Onyx 移动端 Agent 推理时间轴(Agentic Reasoning Timeline):从 9b.1 到 9b.7 的七 PR 分层落地路线图
本文基于 docs/mobile-chat/9b-timeline/05-pr-roadmap.md 展开。该文档是 Onyx(danswer 仓库,开源 AI 平台)移动端
mobile-chat子阶段 9b 的 PR 交付路线图:把 Web 端「Agent 推理时间轴」(agent timeline)外壳 1:1 移植到 React Native + Expo 移动应用,并按 7 个可独立合并的 PR 分层落地。读完本文,你将掌握这套 3.7k–4.3k LOC 的移植工作如何被切分成 9b.1 引擎 → … → 9b.7 组合点亮的序列,理解每个 PR 的目标、文件清单、测试与漂移检查点,以及 9b.7 上线时与 Web 实现的全部有意分歧。
一、背景:为什么一个功能要拆成 7 个 PR
Web 端在助手回答上方展示了一条完整的 agent timeline:一条垂直的步骤轨道(rail),包含推理 "Thinking" 步骤、搜索/工具步骤,每个步骤有图标、状态头、连接线和可折叠/展开的正文;流式期间显示闪烁的 "Thinking… (12s)" 头,答案一旦开始便自动折叠成 "Thought for 12s · 3 steps" 胶囊。而移动端聊天目前对助手的思考/工具过程没有任何可见性——AgentTimeline 只是一个 steps prop 恒为 [] 的桩(stub)。
把 Web 的时间轴外壳"忠实移植"(Approach C——Faithful Shell First,Owner 明确要求 "match web, no refactor later, want everything")是一块约 3.7–4.3k LOC 的工作,因此被切片为 7 个分层、可独立合并的 PR。整个系列的发布策略是 pre-production → 无功能开关:
- 各分层以 "dark"(暗态) 方式落地:编译通过 + 单元测试通过,但尚未挂到屏幕上;
- 直到组合 PR 9b.7 才把时间轴"点亮"(lights up);
- 序列中唯一的中途行为变更是 9b.4(最终答案路径迁移到共享契约,行为保持一致);
- 每个 PR 都必须让
main保持可构建,tsc/ lint / jest 全绿,应用保持可用。
该子阶段的完整上下文见 docs/mobile-chat/9b-timeline/00-index.md(需求与研究)、docs/mobile-chat/9b-timeline/02-high-level-design.md(端到端设计与决策)、docs/mobile-chat/9b-timeline/03-detailed-design.md(精确契约与逐文件职责)以及 docs/mobile-chat/9b-timeline/04-implementation-plan.md(实现计划与计划挑战结果)。
二、总体概览:7 个 PR 一览与依赖序列
| PR | 标题 | 预估 LOC | 依赖 | 关键交付物 |
|---|---|---|---|---|
| 9b.1 | feat(mobile): timeline grouping engine + packet contracts |
~700 | — | 纯分组 reducer(turn/tab + section_end 合成)+ 完整 PacketType 枚举 + 纯步骤 helper;单元测试覆盖。Dark。 |
| 9b.2 | feat(mobile): timeline processor + pacing hooks |
~590 | 9b.1 | usePacketProcessor(lint 安全重算)+ usePacedTurnGroups(200ms 揭示),两个重构后的 hooks;单元测试覆盖。Dark。 |
| 9b.3 | feat(mobile): timeline state hooks |
~550 | 9b.1 | useTimelineUIState / Expansion / Header / StreamingDuration / Metrics / StepState;单元测试覆盖。Dark。 |
| 9b.4 | feat(mobile): render-prop renderer contract + answer migration |
~510 | 9b.1 | RendererResult / MessageRenderer 契约 + findRenderer + RendererComponent;将最终答案 MessageTextRenderer 迁移到该契约。Live(答案路径)。 |
| 9b.5 | feat(mobile): timeline primitives + StepContainer |
~650 | 9b.4 | 轨道/表面/内容原语 + StepContainer + TimelineRendererComponent + 新图标/Button(ASK)+ StreamingMarkdown muted 变体。Dark。 |
| 9b.6 | feat(mobile): reasoning renderer |
~450 | 9b.4, 9b.5 | ReasoningRenderer + reasoningState + ReasoningTextWindow;注册进 findRenderer;单元测试覆盖。Dark。 |
| 9b.7 | feat(mobile): agent timeline composition |
~700 | 9b.2, 9b.3, 9b.5, 9b.6 | AgentTimeline 外壳 + 头部 + Expanded/Collapsed 内容 + MessageRow 接线 + streamingStartedAt。点亮时间轴。设备门禁。 |
依赖序列如下:
9b.1 engine+contracts+helpers ─┬─► 9b.2 processor+pacing hooks ──┐
(dark, unit-tested) ├─► 9b.3 state hooks ─────────────┤
└─► 9b.4 contract + answer path ──┼─► 9b.5 primitives+StepContainer ─► 9b.6 reasoning ─┐
(live: answer) │ (dark) (dark) │
└──────────────────────────────────────────────────►┴─► 9b.7 composition
(LIGHTS UP + device gate)
要点:9b.1 是根,9b.2 / 9b.3 / 9b.4 从它扇出(可并行);9b.5→9b.6 挂在共享契约之上;9b.7 依赖 hooks(9b.2/9b.3)、UI 原语(9b.5)和推理渲染器(9b.6)——它是唯一把时间轴真正画上屏的 PR。
三、PR 9b.1:分组引擎 + 报文契约 + 纯 helper(Dark,单元测试)
- 目标:纯的、可单测的数据核心——把报文(packets)分组为与 Web 完全一致的时间轴步骤。
- 范围(in):完整的 Web
PacketType枚举 + 引擎/外壳 obj 接口(见 03-detailed-design.md §1);把messageProcessor扩展成忠实的packetProcessor移植(分组映射、turn 切换 + stop 时的section_end合成、工具/展示分类、finalAnswerComing、buildGroupsFromKeys、hasContentPackets);纯步骤 helper。 - 范围外(out):所有 hooks、所有 UI、推理渲染器。没有任何东西渲染得不同。
- 文件:
| 文件 | 新建/修改 | 切片 |
|---|---|---|
mobile/src/chat/streamingModels.ts |
修改 | 枚举值 + reasoning/branch/tool-arg 接口 + ObjTypes |
mobile/src/chat/messageProcessor.ts |
修改 | 分组字段 + section_end 合成 + 分类(保留 9a citations/docs) |
mobile/src/chat/timeline/transformers.ts |
新建 | TransformedStep / TurnGroup / groupStepsByTurn |
mobile/src/chat/timeline/packetUtils.ts |
新建 | isToolPacket / isDisplayPacket / getTextContent / … |
mobile/src/chat/timeline/packetHelpers.ts |
新建 | 各报文族谓词 + 折叠流式集合 |
mobile/src/chat/timeline/toolDisplay.ts |
新建 | parseToolKey / getToolName / isToolComplete / hasToolError |
mobile/src/chat/timeline/__tests__/* |
新建 | 分组键、3 个 section_end 触发、分类、并行检测、model_index 容忍、历史重置 |
- 预估规模:~700 LOC(引擎 + helper + 测试是紧密耦合的一个整体,把 helper 拆开会导致引擎无法编译)。
- 依赖:无。功能开关状态:N/A(上线前;纯增量;dark)。
- 合并时测试:Jest 单元——分组 +
section_end合成 + transformers 全量覆盖;messageProcessor仍通过 9a 的 citation/document 测试。 - 漂移检查点:确认后端推理报文形状(
reasoning_start/delta/done)与Placement字段仍与 03/deepread 记录一致;重新核对web/.../packetProcessor.ts自提取以来未变。
源码佐证:契约与引擎的真实形态
这部分规划在仓库中已有相当基础。mobile/src/chat/streamingModels.ts 中 PacketType 枚举已包含 REASONING_START = "reasoning_start"、REASONING_DELTA = "reasoning_delta"、REASONING_DONE = "reasoning_done"、TOP_LEVEL_BRANCHING = "top_level_branching" 等全部 Web 线类型(streamingModels.ts),并定义了 ReasoningStart / ReasoningDelta(携带 reasoning: string)/ ReasoningDone 接口,以及 Placement 的全部四个字段(turn_index / tab_index / sub_turn_index / model_index,见 streamingModels.ts)——这正是分组键 "{turn_index}-{tab_index}" 的数据基础。CODE_INTERPRETER_TOOL_TYPES = { PYTHON: "python" } 也已存在。
mobile/src/chat/messageProcessor.ts 的 ProcessedMessageState 已携带 Web 的分组字段:groupedPacketsMap、seenGroupKeys、groupKeysWithSectionEnd、expectedBranches、toolGroupKeys、displayGroupKeys、finalAnswerComing、stopPacketSeen、toolGroups、potentialDisplayGroups 等(messageProcessor.ts),并实现了 getGroupKey(${turn_index}-${tab_index ?? 0},messageProcessor.ts)与幂等的 injectSectionEnd——合成 SECTION_END 时刻意不携带 sub_turn_index,因为 research/coding 的父级完成判定依赖这一点(messageProcessor.ts)。
section_end 合成是正确性核心,三个触发条件必须原样移植:
- 触发 1:真实
SECTION_END/ERROR报文; - 触发 2(turn 切换):新
turn_index的第一个报文向所有尚未结束的seenGroupKey注入section_end; - 触发 3(stop):第一个
stop向所有打开中的分组注入。
已见过的 turn 内出现新 tab_index 不会触发触发 2。后端极少发送 section_end,步骤"完成"几乎全部由客户端合成。
四、PR 9b.2:处理器 + 节奏(pacing)hooks(Dark)
- 目标:托管 reducer 并驱动 200ms 错峰揭示——这是两个被 lint 强制重构的位置。
- 范围(in):
usePacketProcessor(useMemo全量重算 + 派生toolTurnGroups/displayGroups/isComplete);usePacedTurnGroups(useState+ effect 驱动的揭示、答案门控、历史旁路;删除prevPacedRef)。 - 范围外:状态/派生 hooks(9b.3)、所有 UI。
- 文件:
| 文件 | 新建/修改 | 切片 |
|---|---|---|
mobile/src/hooks/timeline/usePacketProcessor.ts |
新建 | 重算 + 分组派生 + renderComplete 门 |
mobile/src/hooks/timeline/usePacedTurnGroups.ts |
新建 | 200ms 揭示 + 门控(重构后) |
mobile/src/hooks/timeline/__tests__/* |
新建 | fake-timer 节奏测试(首步立即 / 200ms / stop 冲刷 / 旁路)+ 处理器派生 |
- 预估规模:~590 LOC。依赖:9b.1。开关状态:N/A(dark)。
- 合并时测试:Jest 单元 + fake timers——揭示节奏、stop 冲刷、历史旁路、答案在节奏完成前被扣留;处理器重算正确性。
- 漂移检查点:在真机确认重算 + effect 驱动揭示与 Web 体感一致(03 §1 提到的"一帧延迟"风险);若首包渲染闪烁,需在 9b.7 接线前重新审视。
为什么必须重构:Web 的 usePacketProcessor 在渲染期间改写 state ref,usePacedTurnGroups 在渲染期间读取节奏 refs——两者在移动端 react-hooks/refs lint 下都非法。移动端改用 useMemo 全量重算(9a 已验证的模式,O(n)/次 flush,聊天规模下无压力)+ effect 写 useState(timer handle 只存 ref、仅 effect 中使用)。这是行为保持的重构:分组输出相同、200ms 节奏相同;渲染出的时间轴与 Web 完全一致。
五、PR 9b.3:时间轴状态 hooks(Dark)
- 目标:纯 UI 状态派生——7 态状态机、展开、头部文本、实时计时、指标。
- 范围(in):
useTimelineUIState(7 态 + 布尔值)、useTimelineExpansion(自动折叠 +userHasToggled)、useTimelineHeader(文本映射;搜索子标签暂用通用文案)、useStreamingDuration(实时计时 + 后端冻结)、useTimelineMetrics、useTimelineStepState(memory,暂休眠)。 - 范围外:所有 UI;搜索专属头部子标签(搜索阶段再做)。
- 文件:
mobile/src/hooks/timeline/下的useTimelineUIState.ts(状态机 + show/style 布尔)、useTimelineExpansion.ts(折叠 + 自动折叠)、useTimelineHeader.ts(头部文本映射)、useStreamingDuration.ts(实时流逝计时)、useTimelineMetrics.ts(步骤数 + 末步标志)、useTimelineStepState.ts(memory 提取,休眠)+ 对应__tests__/*。 - 预估规模:~550 LOC。依赖:9b.1(类型 + 谓词)。开关状态:N/A(dark)。
- 合并时测试:Jest 单元——每个
TimelineUIState分支 + 派生布尔;展开的自动折叠与userHasToggled;计时 tick/冻结。
7 态状态机(来自 Web,顺序即优先级):EMPTY → DISPLAY_CONTENT_ONLY → STREAMING_PARALLEL / STREAMING_SEQUENTIAL → STOPPED → COMPLETED_EXPANDED → COMPLETED_COLLAPSED,外加 showTintedBackground、showRoundedBottom、showDoneStep、showStoppedStep、hasDoneIndicator、showParallelTabs、showCollapsedCompact、showCollapsedParallel、isStreaming、isCompleted、isActivelyExecuting 等派生布尔。
六、PR 9b.4:render-prop 渲染器契约 + 最终答案迁移(Live)
- 目标:采纳 Web 的 render-prop 契约并把最终答案路由到它——这就是"以后不重构"的那一步。
- 范围(in):
timelineContract.ts(RenderType/RendererResult/MessageRenderer/FullChatState/TimelineRendererResult);findRenderer.ts(完整 13 槽优先级链——chat 已接线,reasoning 槽存在但为 null,其余标注// PR 9x);RendererComponent.tsx(最终答案在FULL渲染,混合 chat+image 桩 = 9e);把MessageTextRenderer迁移到契约;从registry.ts重新导出;把MessageRow的答案渲染指向RendererComponent(时间轴仍是桩)。 - 范围外:时间轴 UI、推理渲染器。
- 文件:
mobile/src/components/chat/renderers/下的timelineContract.ts(契约类型)、findRenderer.ts(优先级分发)、RendererComponent.tsx(最终答案分发)、MessageTextRenderer.tsx(修改,迁移到 render-prop 契约)、registry.ts(修改,重新导出findRenderer);mobile/src/components/chat/MessageRow.tsx(修改,答案 →RendererComponent,时间轴仍为桩);mobile/src/hooks/usePacketDisplay.ts(修改,保持暴露processed供CitedSources);__tests__/*(findRenderer 分发顺序、MessageTextRenderer render-prop、RendererComponent)。 - 预估规模:~510 LOC。依赖:9b.1。开关状态:N/A——live 行为变更(答案路径),渲染必须完全一致。
- 合并时测试:Jest——分发顺序(chat 优先、reasoning 最后);MessageTextRenderer 发出
children([result]);RendererComponent 渲染答案。手动:确认流式 markdown 答案 + 9a 内联引用 + Sources 栏不变。 - 漂移检查点:本 PR 触及已上线的 PR-3/9a 代码——开工前重新确认 9a 引用链接与
CitedSources行为,确保迁移精确保留。
render-prop 纪律(贯穿后续所有渲染器):渲染器必须调用 children(results)——绝不能直接返回自己的 View 树,否则 StepContainer 无法包裹它。RenderType 策略:最终答案路径恒为 FULL;时间轴步骤为 override ?? (isExpanded ? FULL : COMPACT),两个入口都要保留。
七、PR 9b.5:时间轴原语 + StepContainer(Dark)
- 目标:可视步骤框架——轨道、连接线、表面、头部、折叠——加上新图标/原语。
- 范围(in):
primitives/*(timelineTokenspx 常量、TimelineRoot/HeaderRow/Row/IconColumn/Surface/StepContent,去掉 hover);StepContainer;TimelineRendererComponent(逐步展开 +renderType派生);新图标circle/fold/expand/check-circle(owner-ASK);确认/移植三级图标Button(owner-ASK);StreamingMarkdownmuted变体。 - 范围外:推理渲染器、
AgentTimeline组合、头部。 - 文件:
mobile/src/components/chat/timeline/primitives/*(新建:tokens + 6 个轨道/表面/内容原语)、StepContainer.tsx(把原语组合成步骤框架)、TimelineRendererComponent.tsx(逐步展开 + renderType)、mobile/src/icons/{circle,fold,expand,check-circle}.tsx(新建,ASK owner)、StreamingMarkdown.tsx(修改,muted/compact变体)、__tests__/*(StepContainer 折叠门控、末步连接线、错误表面;renderType 派生)。 - 预估规模:~650 LOC。依赖:9b.4(契约)。开关状态:N/A(dark——尚未挂载)。
- 合并时测试:RN Testing Library——StepContainer 按
renderType渲染头/体/折叠;图标轨道几何;错误表面。 - 漂移检查点:Owner-ASK 门——UI 编码前先解决新图标移植 + 三级 Button(遵循 web-parity 原则);若 Web tokens 变化,按 03 重新核对 px 值。
几何细节(03 记录,供实现参考):轨道为 1px 线、顶部 8px 连接(首步隐藏)、20px 节点包裹、12px 图标、flex-fill(末步隐藏);StepContainer 头行高 32,含 status 文本与由 supportsCollapsible 门控的折叠控件;正文 pl-1 pb-1、右槽 34px(除非 noPaddingRight)。移动端间距为像素值(Web rem×16、Tailwind 步 ×4 烘焙为 px),所有 hover 分支删除(静止色:bg-border-01、stroke-text-02、bg-background-tint-00)。
八、PR 9b.6:推理渲染器(Dark)
- 目标:唯一接线的步骤渲染器——流式推理 + 标题 + 500ms 最短思考时间 + markdown 正文。
- 范围(in):
reasoningState.ts(纯函数extractFirstParagraph+constructCurrentReasoningState);ReasoningRenderer.tsx(render-prop;500ms 门;circle图标;noPaddingRight;不设supportsCollapsible——owner 拍板,严格 web parity);ReasoningTextWindow.tsx(固定高度自动跟随ScrollView);ReasoningTextSheet.tsx(全文 + 复制,经expo-clipboard);在findRenderer注册 reasoning,并在它前面恢复 11 个未接线的工具谓词,使最末槽不能吞掉所有已关闭的工具组。 - 范围外:
AgentTimeline组合(9b.7);Web 的 Download 按钮(移动端无对应物)。 - 文件:
mobile/src/chat/timeline/reasoningState.ts(标题提取 + delta 累积,纯)、mobile/src/components/chat/renderers/ReasoningRenderer.tsx(render-prop 渲染器 + 500ms 门)、mobile/src/components/chat/timeline/ReasoningTextWindow.tsx(maxHeight markdown 窗口)、findRenderer.ts(接线 reasoning 槽)、__tests__/*(标题提取规则、delta 累积、500ms 门 fake timers、空/前开始分支)。 - 预估规模:~450 LOC。依赖:9b.4(契约)、9b.5(
circle图标、StreamingMarkdownmuted、StepContainer 冒烟测试)。 - 开关状态:N/A(dark——只有 9b.7 挂载时间轴后才可达)。
- 合并时测试:Jest——
reasoningState标题 + 累积;ReasoningRenderer500ms 门 + 分支;一个 StepContainer 包裹的冒烟渲染。 - 漂移检查点:确认
react-native-streamdown对muted变体的渲染可接受(颜色/边距)——接近设备问题;由 owner 的 9b.7 设备门禁覆盖。
实现要点:constructCurrentReasoningState(packets) 判断 hasStart/hasEnd 并把 deltas join 成内容;extractFirstParagraph(content) 用 markdown 标题规则提取步骤标题(≤60 字符);500ms 最短思考时间用 useState(start) + effect 中的 timer ref 实现,animate 门控下限;渲染结果 { icon: SvgCircle, status: title || "Thinking", content: <ReasoningTextWindow/>, noPaddingRight: true }。ReasoningTextWindow 是 192px 高(8×24)的 ScrollView,包裹 StreamingMarkdown variant="muted",流式时 onContentSizeChange → scrollToEnd 自动跟随,关闭后才允许用户滚动,内容溢出时显示 "View full text" 按钮(裁剪 View 在此不可行——见 03 §8(c) 的 Yoga AtMost 约束解释)。
九、PR 9b.7:Agent 时间轴组合(点亮 + 设备门禁)
- 目标:组装外壳并把时间轴渲染上屏——"行走骨架"完成。
- 范围(in):把
AgentTimeline重写为外壳(运行状态 hooks + 头部切换 + 正文);ExpandedTimelineContent+CollapsedStreamingContent+TimelineStep;头部(StreamingHeader/CompletedHeader/StoppedHeader;ParallelStreamingHeader休眠桩);ParallelTimelineTabs休眠(线性化)+toolIcons;Done/Stopped 终止步骤;把MessageRow.AssistantMessage接成 AgentMessage 模拟体(usePacketProcessor+usePacedTurnGroups→ 上方AgentTimeline+ 下方RendererComponent答案 +CitedSources);在 PR-3 流控制器/store 中捕获streamingStartedAt。 - 范围外:任何工具渲染器(后续阶段);真正的并行 tab 标签页。
- 文件:
| 文件 | 新建/修改 | 切片 |
|---|---|---|
mobile/src/components/chat/AgentTimeline.tsx |
修改 | 桩 → 完整外壳 |
mobile/src/components/chat/timeline/ExpandedTimelineContent.tsx |
新建 | TurnGroup[] → 步骤 + Done/Stopped |
mobile/src/components/chat/timeline/CollapsedStreamingContent.tsx |
新建 | 流式实时预览 |
mobile/src/components/chat/timeline/TimelineStep.tsx |
新建 | 步骤合成器(children → StepContainer) |
mobile/src/components/chat/timeline/headers/* |
新建 | Streaming/Completed/Stopped(+Parallel 休眠) |
mobile/src/components/chat/timeline/ParallelTimelineTabs.tsx |
新建 | 休眠桩(线性化) |
mobile/src/components/chat/timeline/toolIcons.ts |
新建 | 报文类型 → 图标映射 |
mobile/src/components/chat/MessageRow.tsx |
修改 | AssistantMessage → AgentMessage 模拟体 |
流控制器 / chatSessionStore |
修改 | 每节点 streamingStartedAt |
__tests__/* |
新建 | 模拟推理流 → "Thinking" 步骤 → 折叠为 "Thought for Ns";点击展开;USER_CANCELLED → Stopped |
- 预估规模:~700 LOC。依赖:9b.2、9b.3、9b.5、9b.6。开关状态:N/A——时间轴正式上线。
- 合并时测试:RN Testing Library——模拟推理报文流渲染流式头部 → 步骤 → 自动折叠 → 点击展开 → Done;Stopped 路径。
- HARD 设备门禁(owner 执行):dev 构建 + 推理模型——流式微光 + 实时计时、答案开始时自动折叠、点击展开、Done 步骤、hydrated(重开)渲染;迁移后的答案 + 9a Sources 完好;流式重渲染保持流畅(性能隔离见 04)。
- 漂移检查点:重新确认
unify-chat-input的ChatSurface组合(PR6 重构后)是MessageRow/AgentTimeline的挂载点,且按会话草稿/重置规则不干扰按节点的时间轴状态。
9b.7 按建成记录(2026-08-03,"parallel tabs pulled forward"——编码前 owner 门禁)
四个 AskUserQuestion 门。 最大的一扇:owner 检查 docs/mobile-chat/ 下是否有任何地方规划了并行标签页——没有(只有下方 "After 9b" 未排期的行)——于是拍板现在就建:ParallelTimelineTabs 和 ParallelStreamingHeader 以 LIVE 而非休眠状态上线,03 §8(d) 的线性化分歧废弃。这要求一个全新的移动端原语:components/ui/tabs.tsx——Opal 的 pill Tabs 的 RN 移植(Root context + List + Trigger),owner 批准用它而不是手搓一个相似物。其余门:从 Opal 1:1 移植 stop-circle(沿用 9b.5 图标先例);把 usePacketDisplay 折叠进 usePacketProcessor(暴露 processed,删除该 hook 及其测试),而不是每次 flush 把每个报文处理两遍;全部作为一个 PR 上线。
新增文件:components/chat/timeline/{ExpandedTimelineContent,CollapsedStreamingContent,TimelineStep,TimelineStepComposer,ParallelTimelineTabs,ShimmerText}.tsx + toolIcons.ts、timeline/headers/{Streaming,Completed,Stopped,ParallelStreaming}Header.tsx、timeline/primitives/TimelineTopSpacer.tsx、components/ui/tabs.tsx,以及 6 个 1:1 Opal 图标移植(stop-circle、branch、user、code、book-open、slow-time)。修改:AgentTimeline.tsx(桩 → 完整外壳)、MessageRow.tsx(→ AgentMessage 模拟体)、hooks/timeline/usePacketProcessor.ts(+processed)、chat/interfaces.ts + chat/chatHistory.ts(+streamingStartedAt、+processingDurationSeconds)、hooks/useChatController.ts + hooks/useChatSessionController.ts(两个流开始时间戳)、lib/time.ts(+formatDurationSeconds,移植自 Opal time.ts)。约 1480 LOC——约为 500–700 区间的两倍,折叠 tabs 时 owner 已接受。
与 Web 的九项有意分歧(在 03 §8 之上的 9b.7 自身分歧)
(a) 任何地方都没有 hover——Web 的 headerIsInteractive 只切换 hover class,移动端无对应物,每个 header 自己持有 Pressable;(b) 外壳不调用 useTimelineStepState(Web 中它只被 memory tag 和那个 hover 标志消费),CompletedHeader 去掉 Web 的 memory tag/tooltip/MemoriesModal——memory 渲染器是后续阶段;(c) shimmer = 透明度脉冲(ShimmerText,RN Animated,保证时间轴可 jest 渲染),而非 Web 的 background-clip:text 渐变;(d) 展开正文无入场动画(Web 的 animate-in fade-in slide-in-from-top-2)——300ms 的花哨不值得把 reanimated 放回测试路径;(e) streamingStartTime 是每节点 prop(而非 Web 的按会话 store 读取),恢复运行从恢复点重启计时(此客户端从未观察过该轮真正开始时间;Web 在那里根本不显示计时);(f) 移植的 Tabs 只带 pill 变体(无 contained/underline——没有消费者)并去掉滚动箭头(触摸列表靠滑动而非箭头步进);(g) TimelineStep 拆成独立模块——Web 将其私有在 ExpandedTimelineContent 内部,在移动端会形成 ExpandedTimelineContent → ParallelTimelineTabs → TimelineStep 循环;(h) getToolIcon 使用 Onyx 字形,而 Web 按自己的图标规则回退到 react-icons(FiTool → cpu,对齐 chat/tools.ts 的自定义工具回退;FiList → text-lines-small)。
一并修复的问题
hydrated(历史重开)的 turn 完全没有时长,导致每次重开会话的头部都会显示 "Thought for some time"——现在把 processing_duration_seconds(后端响应里已有,models.py:232)通过 chatHistory 映射进来。
对抗性评审(4 透镜 × 3 名先反驳的怀疑者——8 项提出、6 项修复)
- (1) HIGH——Stopped 路径在生产中不可达。 用户 stop 会在后端
stop报文到达之前中止 reader,stopPacketSeen永远不会翻转:该 turn 一直停留在 STREAMING ui-state(useStreamingDuration的 250ms 间隔持续计时)直到会话重载。修复:在runChatStream的finally中于中止时合成USER_CANCELLEDstop 报文(buildUserCancelledStopPacket);resume 路径已自愈(它从快照重新 hydrated,后端会重放OverallStop)。该合成函数在仓库中真实存在:streamingModels.ts——buildUserCancelledStopPacket(packets)读取末包 placement 生成{ type: STOP, stop_reason: USER_CANCELLED }。 - (2) MEDIUM——FlashList 回收 React keys:
RenderStackManager.syncItem把池化 key 交给不同的stableId,导致滚动离屏的 turn 的组件状态(时间轴展开、活跃分支 tab、打开的 sources 表)被无关消息继承——且因userHasToggled锁存,该 turn 永远无法自动折叠。keyExtractor只提供 stableId 而非 React key;修复:在renderItem内用nodeId作为MessageRow的 key。 - (3) LOW ×3——
chatStatememo 的计数代理可能在移动端钉住过期 map(Web 靠原地修改侥幸通过)、Tabs baseline 覆盖了滑动指示器、Opal 的pt-1属于它的无滚动箭头分支。 - (4) 5 个文件未做 prettier 格式化。按原样接受:
CitedSources门保留 9a 的processed.isComplete(答案收回期间 Sources 栏短暂提前出现,不值得为它改变已上线的 9a 语义)。
验证结果
tsc 干净、lint 干净(2 个既有的 _layout 警告)、prettier 干净、76 个套件 596 个 jest 用例通过(+40 个新增:强制的组合冒烟测试——推理流 → "Thinking" 步骤 → 自动折叠为 "Thought for Xs · 1 step" → 点击展开 → Done;USER_CANCELLED → Interrupted Thinking → Stopped;并行 turn → 流式头部和展开正文中各分支一个 tab,含分支切换——外加 Tabs 原语、fake timers 下 StreamingHeader 计时/冻结、getToolIcon、formatDurationSeconds、chatHistory 时长映射、streamingStartedAt 时间戳、中止时合成 stop 的回归)。
设备门禁仍欠(owner 运行):对推理模型跑 dev 构建——流式微光 + 实时计时、答案开始自动折叠、点击展开、Done 步骤、hydrated(重开)渲染显示真实 "Thought for Xs"、迁移后的答案 + 9a Sources 完好、流式重渲染保持流畅。并行标签页还需多分支运行才能看到 pill 条与滑动指示器。
十、9b 之后:后续渲染器阶段(此处不做设计)
每个后续渲染器都是零重构接缝上的独立 grill-first PR:加上该工具的 obj 接口 + 一次 findRenderer 谓词接线 + 一个返回 RendererResult 的 render-prop 渲染器(search/fetch 复用 9a 的 SourceRow)。按产品价值排序(owner 拍板):search → fetch → python/code → custom-tool → deep-research(+嵌套)→ memory,外加 parallel-tab 标签页(纯 UI,解除 ParallelTimelineTabs 休眠)以及 9c/9d/9e 各自独立。这套接缝的保证在于 9b.1 就落地了完整 PacketType 枚举(引擎/helper/集合零未来枚举改动即可编译),每个工具渲染器只加自己的 obj 接口 + 一行谓词接线——这正是"以后零重构"承诺的实现机制。
十一、贯穿全程的质量保障机制
这套 PR 路线图把质量保证内建到每一个分层里,值得作为大型跨端移植的参考范式:
- 逐 PR 测试矩阵:纯逻辑(分组、section_end 三触发、transformers、reasoningState、状态机)走 Jest 单元;计时与节奏走 fake timers;组件走 RN Testing Library;且所有纯逻辑必须放在 reanimated-free 模块(
mobile/src/chat/timeline/**),规避 "Worklets not initialized" 崩溃。 - 漂移检查点(drift checkpoint):每个 PR 都标出需向 Web/后端源头重新确认的假设(报文形状、
packetProcessor.ts是否变化、9a 行为、token 值),防止移植"追着移动靶"。 - Owner-ASK 门:新图标、新原语(三级 Button、Tabs)等一切可能偏离 Web 的决策,都明确要求先征询 owner,而不是手搓相似物。
- HARD 设备门禁:自动化无法覆盖的流式体验(微光、计时、自动折叠、hydrated 渲染、性能)由 owner 在真机 + 推理模型上验收。
- 对抗性评审:9b.7 上线前用 4 透镜 × 3 怀疑者推演,8 项问题中 6 项在合并前修复(含两个 HIGH/MEDIUM 级别的生产路径缺陷)。
对于任何"把 Web 组件体系 1:1 移植到 React Native 且不许后续重构"的工程任务,这套 7-PR 分层序列——先纯引擎、再 hooks、再契约迁移、再原语、再单渲染器、最后组合点亮——是一个经过实战验证、有明确验收口径与质量门禁的可执行蓝本。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00