# Agent Note：Web @ mention 的发现成本与行内容

Status: implemented

[English](2026-08-27-web-at-mention-discovery-and-row-content.md) | 中文

## Problem

在 Web composer 里 `@` 之后继续输入很慢，而被填满的菜单里塞着并不能区分候选的文字。背后是三个缺陷，一次击键就能全部触达。

会话发现会读取每个持久化会话的完整日志。`listCandidates` 只对空查询先截断到候选上限；非空查询对整个语料调用 `readTitleSnapshots`，而在那里折叠一个标题的代价是完整读一遍该会话的日志。`DEFAULT_PREPARED_SESSION_CACHE_SIZE` 是 5，因此任何真实语料的淘汰速度都快于填充速度，每次击键都重新付冷读的代价。在一个 342 会话的存储上实测：并发 4、页缓存已热的前提下，每次击键 1139 ms 的多帧 zstd 解压与 JSON 解析。这正是用户描述的形状——单独敲 `@` 因为先截断而尚可忍受（约 160 ms），多打一个字符就不行了。

文件索引截断了半个工作区。`WorkspaceFileSearch` 在 `maxEntries` 之下按广度优先填充，因此在第四、五层触顶就会丢弃更深的一切。本仓库有 19 764 个条目而上限是 10 000，其中 8 148 个（41%）是两个默认排除项（`.git`、`node_modules`）覆盖不到的 `lib/` 构建产物。`@AssistantMarkdown` 对一个真实存在的文件返回空；`@MenuView` 返回它的 spec 文件而不是 `MenuView.tsx`。另外，任意 `tool/result` 都会使整个索引失效，因此一个只读工具就会把一次完整遍历挡在下一个光标前面。

行内容自我重复。工作区根目录的文件渲染成 `reference.txt reference.txt`，因为 description 是完整路径而 name 是它的基名。会话行渲染标题、完整 session id、完整 cwd 和一个原始的 `toISOString()` 时间戳。下钻后的目录列表除了删字符没有回退方式，而且其中每一行都写着同一个父目录。

Web e2e 看不到这一切：它的 scaffold 固定使用只含两个会话的隔离 `DSH_HOME`。

## Decision

**发现用的标签只来自投影读，绝不读日志。** `SessionReferenceResolver` 向每个被列出的会话的投影索取标题，无人作答就用它的 id。是否挂载由会话存储在读取时决定，而不是由产生该记录的那次列举决定，因此在两者之间挂载上来的会话绝不会被一份其实时日志已经越过的 checkpoint 作答。已挂载的会话由 `ctx.sessionProjections.snapshot(session, ['title'])` 作答——那是随每个已提交事件推进的实时切面，事件本就在内存里。冷会话由 `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])` 作答，即它转冷时写下的持久化 checkpoint。两者都是同步的，都不碰日志。

从日志折叠一个标题的代价是整份日志，而这次调用位于 `@` 补全每一次击键之下，所以干脆不做。没有任何投影能作答的会话——早于缓存组合存在的、或被直接 seed 到磁盘的——用 id 作标签，且无法按标题搜到。这个状态会自愈：把该会话打开一次即挂载，销毁时就写下 checkpoint。

**失效的文件索引在替代品构建期间继续作答。** `invalidate()` 递增一个计数器而不是丢弃遍历。裸查询由已完成的条目作答，并启动一次后台重建、完成后原子替换；只有一个工作区的首次裸查询会等待。根目录不可读的遍历会失败而不是落定：不可读的分支只损失它自己的候选，而不可读的根意味着这次遍历什么都没学到，把它作为空索引发布会覆盖掉仍然有效的条目，且不留下任何可供重试的失效标记。失败的刷新保留陈旧条目与计数器，下一次查询因此重试。`DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` 从两个名字增至十五个——版本控制与依赖目录，加上没有任何生态用作源码目录的构建产物基名——`DEFAULT_FILE_SEARCH_MAX_ENTRIES` 提高到 50 000。两者仍是部署方可覆盖的 `excludedDirectories` 与 `maxEntries` 配置字段。

**每一行只承载能区分它的信息。** 文件显示其父目录，位于工作区根目录时不显示。下钻后的目录列表不显示父目录，因为面包屑已经在显示。会话仅在 `SessionReferenceCandidate.sameWorkspace` 为 false 时显示其工作区——由宿主计算，因为排序时它本就同时握有两个工作目录——并用宿主会话列表的 `updatedAt` 经该列表所用的相对时间分档标注时间，因此同一个会话在两处读到的时长一致。列表中没有的会话回落到候选自带的 `createdAt`。`relativeTime` 从 `ui-workspace` 的 `tree.ts` 移到 `ui-primitives`；按 locale-owned 文案的规则，词句仍留在各插件自己的字典里。session id 离开行内：它本就是无标题会话回落到的标签。

**下钻会发布面包屑，键入路径不会。** `InputTriggerSource` 增加可选的同步 `header(session, req)` 钩子返回面包屑，在每次命中时以实时查询与管线持有的 `drilled` 标记重新询问，该标记只在下钻的编辑真正落到草稿上时才置位——被拒绝的编辑保持清零，因此头部绝不会指向没人进去过的目录——并在菜单关闭前跨越后续键入。`CandidateRequest` 携带同一个标记。面包屑走菜单 store 之外的独立快照 store，冻结的菜单归约器因此对它一无所知；点击面包屑经 `onPick` 以 `action: 'drill'` 路由——「回到某一步」与「进入某一层」是同一个结果。`MenuView` 把头部渲染在其滚动视口之上，并把 `role="listbox"` 移到该视口上，因为面包屑不是选项，listbox 也不得承载它。

中文 composer placeholder 改为 `文件或对话`，与同一个菜单已经显示的 `对话` 分组标题一致。

## Alternatives considered

**从日志折叠缺失的标题，并按冷日志记忆化。** 先实现了，评审时移除。它会让缓存尚未覆盖的语料在首次过滤查询时读那些日志——在 342 会话的存储上约 190 份——只为救回早于缓存存在的会话。把该存储与缓存的上线时间对照后可以看出这笔买卖不划算：今天产品写出的每个会话都会在创建、`turn/end` 与销毁三处建立 checkpoint，而旧会话只要被打开一次就会补上。缺口是「一碰即愈」的存量数据，不是发现路径每次击键都该付的形状。

**通过 `sessionQuery.observeSession` 或 `persistence.readFrom` 读冷会话标题。** 否决：在随附后端上两者都消不掉这次读。`observeSession` 借的是完整的 `inspection.events`；而 `readFrom` 的文档写明顺序介质（JSONL 的两种编码）「仍会解析整个产物再向前跳过」——该原语约束的是返回与重折叠的范围，不是物理读。

**给候选拉取加防抖。** 否决。归约器在每次命中时已经把所有分组重置为 pending，因此尾部防抖会延长骨架状态，输入时读起来更慢。折叠成本移除后，往返时间不再值得一个定时器；在新 generation 下保留上一批行是另一个决定，带有误选后果，此处不做。

**读 `.gitignore` 来约束索引。** 暂时否决：这会给一条必须保持同步且廉价的路径引入 ignore 文件解析器与 git 依赖。基名列表本就是工作区可覆盖的配置字段。

**把 `lib` 和其余构建产物一起放进默认排除。** 否决：Ruby gem 与相当一部分 npm 包的源码就在那里，而这次缺失会是无声且彻底的，比本次改动所消除的部分截断更糟。本仓库构建进 `lib`，通过 `excludedDirectories` 自行加上；随附默认值只列没有任何生态用作源码目录的产物名。

**在宿主侧从 `sessionListMetadata` 投影读取会话最近活动时间。** 否决：该投影键由 `api-session-controller` 声明，读取它会让 `packages/context` 的能力依赖 BFF 装配层——本仓库没有这个方向的先例。客户端的 `ctx.sessions.list` 里本就有同一个数字，而这也正是让两处界面「由构造而非由巧合」保持一致的原因。

**让 `MenuView` 识别 `@` 触发符并自行绘制面包屑。** 否决：`MenuView` 与 `/` 共用，把文件引用语义硬编码进去，越过了 source 注册表本就用来守住的包边界。

**把 `drilled` 作为可选字段加进 `CandidateRequest`。** 否决：管线始终知道它，而可选字段会诱使 source 把「请求早于该字段」读成「未下钻」。改为必填并更新每一处调用点，符合预发布阶段的取舍。

## Consequences

未组合 `session-projection-cache` 的部署把每个冷会话都标成 id；连 `session-projections` 也没有时，所有会话都是 id。发现能力与它所读的投影一样完整，且绝不会比投影更慢。

存有「缓存上线之前的会话」的存储，会把那些会话显示成 id，直到各自被打开一次。在本次实测的机器上约为 342 个里的 190 个——老用户看得见，新用户看不见，且随使用递减。

文件索引落后一次失效：紧接工具结果之后的裸查询反映的是上一次遍历时的目录树，下一次查询才看到重建结果。把源码放在被排除基名下的工作区需要覆盖 `excludedDirectories`。

`aria` golden 的形状改变：listbox 角色现在落在内层元素上，且行内携带会随套件运行而推进的相对时间分档。`normalizeAria` 在 duration 规则之前把该词汇归一为 `{{age}}`，锚定在 aria 标签的右引号上。

引用行的内容现在派生自相邻 chrome 已经显示的信息——下钻列表的面包屑、会话的当前工作区。未来若有不带这些 chrome 的界面渲染同一批候选，它显示的信息会不足，必须向 source 索取另一种投影，而不是自行重新推导路径。

## Testing

包级测试覆盖：被改名的挂载会话在 checkpoint 仍是旧值时按新标题被搜到、冷会话由 checkpoint 标注、无投影可答的会话标成 id、完全没有投影面的组合、以上路径均未调用 `readTitleSnapshots`、经真实文件系统驱动的 stale-while-revalidate——根目录在活索引之下消失时仍继续作答，并在它回来后自动接上——不可读子目录只损失自身候选、`lib` 目录仍可搜索，以及面包屑契约的两端（含被拒绝的下钻编辑）。`reference-composer.e2e.ts` 覆盖随附组合：刷新后的菜单 golden 显示精简后的行，新增用例下钻进入文件夹、断言面包屑只在此时出现、并点击根节点回到裸 `@`。其中被 seed 的会话在那里显示为 id，因为 seed 落到磁盘的只有日志，而该 scaffold 在宿主已载入投影缓存表之后才 seed；把 seed 提前到 boot 之前会让应用启动时就带着一份会话列表，而四个场景共用的「连接新工作区」流程并不预期这一点。带标题的路径留在包级测试里，e2e 断言它真正产生的 id 标签，而不是这个 fixture 承载不了的标题。

1139 ms 是针对真实存储的服务端 I/O 实测下限，不是插桩得到的端到端 UI 延迟；web e2e scaffold 的隔离 `DSH_HOME` 无法复现产生该数字的语料。
