DSH / Atlas
2026-08-27implementedfeaturearchived 2026-09-04

Web @ mention discovery cost and row content

Web @ mention 的发现成本与行内容

Typing after `@` in the Web composer was slow, and the menu it filled was padded with text that distinguished nothing. Three defects sat behind that, all reachable from one keystroke. Session discovery read every persisted session's whole log. `listCandidates` sliced to the candidate limit only for an empty query; a non-empty one called `readTitleSnapshots` over the entire corpus, and folding a title there costs one

English

Problem

Typing after @ in the Web composer was slow, and the menu it filled was padded with text that distinguished nothing. Three defects sat behind that, all reachable from one keystroke.

Session discovery read every persisted session's whole log. listCandidates sliced to the candidate limit only for an empty query; a non-empty one called readTitleSnapshots over the entire corpus, and folding a title there costs one full log read per session. DEFAULT_PREPARED_SESSION_CACHE_SIZE is 5, so any real corpus evicts faster than it fills and every keystroke pays the cold price again. Measured against a 342-session store: 1139 ms of multi-frame zstd decompression and JSON parsing per keystroke, at concurrency 4 with a warm page cache. That is the shape users reported — @ alone was tolerable at roughly 160 ms because it sliced first; one typed character was not.

The file index was truncating half of a workspace. WorkspaceFileSearch fills breadth-first under maxEntries, so a cap reached at depth four or five drops everything deeper. This repository holds 19 764 entries against a 10 000 cap, of which 8 148 (41%) were lib/ build output that the two default exclusions (.git, node_modules) did not cover. @AssistantMarkdown returned nothing for a file that exists; @MenuView returned its spec file and not MenuView.tsx. Separately, any tool/result invalidated the whole index, so a read-only tool put a full traversal in front of the next caret.

Row content repeated itself. A workspace-root file rendered reference.txt reference.txt, because the description was the full path and the name was its basename. A session row rendered its title, its full session id, its full cwd, and a raw toISOString() timestamp. A drilled directory listing had no way back except deleting characters, and every row in it named the same parent.

Web e2e could not see any of this: its scaffold pins an isolated DSH_HOME holding two sessions.

Decision

A discovery label is a projection read, never a log read. SessionReferenceResolver asks each listed session's projections for its title and takes its id when none answers. Attachment is decided by the session store at read time, not by the listing that produced the record, so a session that attached in between is never answered from a checkpoint its live log has moved past. An attached session answers from ctx.sessionProjections.snapshot(session, ['title']) — the live cut, which advances with every committed event, over events already in memory. A cold one answers from ctx.sessionProjectionCache.cachedSnapshot(header, ['title']), the durable checkpoint written when it went cold. Both are synchronous and touch no log.

Folding a title from a log costs the whole log, and this call sits under every keystroke of @ completion, so it is not attempted at all. A session no projection answers for — one persisted before the cache was composed, or seeded straight to disk — is labeled by its id and cannot be found by its title. That state is self-healing: opening the session once attaches it, and disposal checkpoints it.

An invalidated file index keeps answering while its replacement builds. invalidate() bumps a counter instead of discarding the traversal. A bare query serves the settled entries and starts a background rebuild that swaps in atomically; only a workspace's first bare query ever waits. A traversal whose root is unreadable rejects rather than settling: an unreadable branch costs its own candidates, but an unreadable root learned nothing, and publishing that as an empty index would replace entries that are still good and leave no invalidation to retry from. A failed refresh leaves the stale entries and the counter behind, so the next query retries. DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES grows from two names to fifteen — version-control and dependency stores plus build-output basenames no ecosystem also uses for sources — and DEFAULT_FILE_SEARCH_MAX_ENTRIES rises to 50 000. Both remain excludedDirectories and maxEntries config fields a deployment overrides.

Rows carry only what distinguishes them. A file names its parent directory and nothing at the workspace root. A drilled directory listing names no parent, because its breadcrumb does. A session names its workspace only when SessionReferenceCandidate.sameWorkspace is false — the host computes that, since it already holds both working directories for ranking — and is dated from the Host session list's updatedAt through the relative-time bucket that list uses, so one session reads the same age on both surfaces. A session the list does not carry falls back to the candidate's createdAt. relativeTime moves from ui-workspace's tree.ts to ui-primitives; the words stay in each plugin's own dictionary, per locale-owned copy. The session id leaves the row: it is already the label a session without a title falls back to.

A drill publishes a breadcrumb; typing a path does not. InputTriggerSource gains an optional synchronous header(session, req) hook returning crumbs, re-polled on every hit with the live query and a pipeline-owned drilled flag. The flag is set only when the drill's edit actually reached the draft — a refused edit leaves it clear, so a header never names a directory nobody descended into — and it survives further typing until the menu closes. CandidateRequest carries the same flag. Crumbs ride their own snapshot store beside the menu store, so the frozen menu reducer stays unaware of them, and a crumb pick routes through onPick with action: 'drill' — returning to a step and descending into one are one outcome. MenuView renders the header above its scrolling viewport and moves role="listbox" onto that viewport, because a breadcrumb is not an option and a listbox may not carry one.

The zh composer placeholder says 文件或对话, matching the 对话 section title the same menu already shows.

Alternatives considered

Fold the missing titles from their logs, memoized per cold log. Implemented first, then removed in review. It made the first filtered query over a corpus the cache had not covered read those logs — on a 342-session store, roughly 190 of them — to rescue sessions that predate the cache. Correlating that store against the cache's arrival showed why the trade is bad: every session the product writes today gets a checkpoint at creation, turn/end, and disposal, and an old session acquires one the first time it is opened. The gap is legacy data that heals on contact, not a shape discovery has to pay for on every keystroke.

Read a cold session's title through sessionQuery.observeSession or a persistence read handle. Rejected: neither removes the read on the shipped backend. observeSession borrows the whole inspection.events, and readFrom documents that sequential media — JSONL, both encodings — "still parse the whole artifact and skip forward"; the primitive bounds what is returned and refolded, not the physical read.

Debounce the candidate fetch. Rejected. The reducer already resets every group to pending on each hit, so a trailing debounce extends the skeleton state and reads as slower while typing. With the fold removed, the round trip no longer justifies the timer; keeping the previous rows visible under a new generation is a separate decision with pick-safety consequences, and is not taken here.

Read .gitignore to bound the index. Rejected for now: it adds an ignore-file parser and a git dependency to a path that must stay synchronous and cheap. A basename list stays a config field a workspace overrides.

Exclude lib by default with the other build outputs. Rejected: Ruby gems and many npm packages keep their sources there, and the miss would be silent and total rather than the partial truncation this change removes. This repository builds into lib and adds it through excludedDirectories; the shipped default names only outputs no ecosystem also uses for sources.

Read the session's last activity on the host, from the sessionListMetadata projection. Rejected: that projection key is declared by api-session-controller, so reading it would make a packages/context capability depend on the BFF assembly — a direction with no precedent in this repository. The client already holds the same number in ctx.sessions.list, which is also what makes the two surfaces agree by construction rather than by coincidence.

Let MenuView recognize the @ trigger and draw the breadcrumb itself. Rejected: MenuView is shared with /, and hardcoding file-reference semantics there crosses the package boundary the source registry exists to hold.

Add a drilled flag to CandidateRequest as optional. Rejected: the pipeline always knows it, and an optional field invites a source to read undefined as "not drilled" for a request that simply predates the field. Required, with every call site updated, matches the pre-release stance.

Consequences

A deployment without session-projection-cache composed labels every cold session by its id; without session-projections too, every session. Discovery is as complete as the projections it reads, and never slower than them.

A store carrying sessions from before the cache shipped shows those sessions by id until each is opened once. On the machine this change was measured against that is roughly 190 of 342 — visible to a long-time user, invisible to a new one, and shrinking with use.

The file index is one invalidation stale: a bare query answered immediately after a tool result reflects the tree as of the previous traversal, and the following query sees the rebuild. Sources kept under an excluded basename need an excludedDirectories override.

aria goldens change shape: the listbox role now sits on an inner element, and rows carry a relative-time bucket that advances while a suite runs. normalizeAria collapses that vocabulary to {{age}} before the duration rules, anchored on an aria label's closing quote.

The reference row content is now derived from what the neighbouring chrome already shows — the breadcrumb for a drilled listing, the current workspace for a session. A future surface that renders these candidates without that chrome would show less than it should, and must ask the source for a different projection rather than re-deriving paths.

Testing

Package tests cover a renamed attached session found by its new title while its checkpoint still holds the old one, a cold session labeled from its checkpoint, an unprojected session labeled by its id, a composition with no projection face at all, readTitleSnapshots never called on any of those paths, stale-while-revalidate driven through the real filesystem — a root that vanishes under a live index keeps answering and picks the workspace back up when it returns — an unreadable subtree costing only its own candidates, a lib tree that stays searchable, and the breadcrumb contract from both ends including a refused drill edit. reference-composer.e2e.ts covers the shipped composition: the refreshed menu golden shows the trimmed rows, and a new case drills into a folder, asserts the breadcrumb appears only then, and clicks the root crumb back to a bare @. Its seeded sessions appear there as ids, because a seed reaches disk as a log alone and this scaffold seeds after the host has already loaded its projection-cache table; seeding before boot would give the app a populated session list at startup, which the fresh-workspace flow four scenarios share does not expect. The titled paths stay in the package suite, and the e2e asserts the id labels it actually produces rather than a title the fixture cannot carry.

The 1139 ms figure is a measured floor for the server-side I/O against a real store, not an instrumented end-to-end UI latency; the web e2e scaffold's isolated DSH_HOME cannot reproduce the corpus that produces it.

中文

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 或持久化读句柄读冷会话标题。 否决:在随附后端上两者都消不掉这次读。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 无法复现产生该数字的语料。