mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #3182 from deepseek-harness/worktree/web-mention-ux-polish-1bf698
feat(web): trim @ mention rows and cut their discovery cost
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md
|
||||
2026-08-26-web-trigger-menu-presentation-polish.md: 62582794cf46719a4ede3627164b4dda8c1d372d
|
||||
2026-08-26-web-trigger-menu-presentation-polish.zh.md: 627f0fb2ce152bf3a505cea892eeda484fcd29e4
|
||||
2026-08-26-web-trigger-menu-presentation-polish.md: 07417b29e3bd520fe488fd463a44c373f694f383
|
||||
2026-08-26-web-trigger-menu-presentation-polish.zh.md: dc84c5396451d9ac504e360e8aa8e4fe858297ef
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ The drill affordance on the highlighted folder row is the library `IconChevronRi
|
||||
|
||||
A token still carrying its trigger character is editable text, not a settled chip: the text-ref decoration colors it and nothing more, and the domain icon belongs exclusively to the settled `ReferenceChipNode`. The former appearance channel (scan `appearance` field, `TextRefNode.__appearance`, `data-ref-appearance` DOM attribute, CSS `::before` icon) is deleted end to end.
|
||||
|
||||
Composer placeholders advertise both triggers (`描述你想要构建的内容… / 调用指令 @ 文件或会话` / `Describe what you want to build... / commands, @ files or sessions`), and the zh copy for commands is unified from 命令 to 指令 across `ui-chat`, `ui-conversation`, `ui-goal`, and `ui-input-trigger`.
|
||||
Composer placeholders advertise both triggers (`描述你想要构建的内容… / 调用指令 @ 文件或对话` / `Describe what you want to build... / commands, @ files or sessions`), and the zh copy for commands is unified from 命令 to 指令 across `ui-chat`, `ui-conversation`, `ui-goal`, and `ui-input-trigger`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -32,4 +32,4 @@ Composer placeholders advertise both triggers (`描述你想要构建的内容
|
||||
|
||||
## Consequences
|
||||
|
||||
The kind information every row used to spell in text now rides the icon and section title; a future candidate kind must extend `InputTriggerCandidateIcon` and pick an icon rather than pass an arbitrary string. Pointer motion round-trips through the reducer (`hover` is a no-op for the already-highlighted row, so mousemove storms do not churn state). Drill discoverability rests on the highlight: an idle folder row shows only its chevron until hovered or reached by keys. Deferred follow-ups — settle-on-space for exact-match tokens, candidate description content, back navigation after a drill, `name` vs `name/` labels, and reference search latency — are tracked in [#3154](https://github.com/deepseek-harness/deepseek-harness/issues/3154).
|
||||
The kind information every row used to spell in text now rides the icon and section title; a future candidate kind must extend `InputTriggerCandidateIcon` and pick an icon rather than pass an arbitrary string. Pointer motion round-trips through the reducer (`hover` is a no-op for the already-highlighted row, so mousemove storms do not churn state). Drill discoverability rests on the highlight: an idle folder row shows only its chevron until hovered or reached by keys. Deferred follow-ups — settle-on-space for exact-match tokens and `name` vs `name/` labels — remain tracked in [#3154](https://github.com/deepseek-harness/deepseek-harness/issues/3154); candidate description content, back navigation after a drill, and reference search latency are settled by the [@ mention discovery and row content note](2026-08-27-web-at-mention-discovery-and-row-content.md).
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ Web composer 的 `/` 与 `@` 触发菜单存在多处呈现缺陷,使引用流
|
||||
|
||||
仍带触发符的 token 是可编辑文本而非 settled chip:text-ref 装饰只做染色,领域图标专属于 settled 的 `ReferenceChipNode`。原有的 appearance 通道(扫描的 `appearance` 字段、`TextRefNode.__appearance`、`data-ref-appearance` DOM 属性、CSS `::before` 图标)端到端删除。
|
||||
|
||||
composer placeholder 同时提示两个触发符(`描述你想要构建的内容… / 调用指令 @ 文件或会话` / `Describe what you want to build... / commands, @ files or sessions`),并将 `ui-chat`、`ui-conversation`、`ui-goal`、`ui-input-trigger` 中命令的中文文案统一为"指令"。
|
||||
composer placeholder 同时提示两个触发符(`描述你想要构建的内容… / 调用指令 @ 文件或对话` / `Describe what you want to build... / commands, @ files or sessions`),并将 `ui-chat`、`ui-conversation`、`ui-goal`、`ui-input-trigger` 中命令的中文文案统一为"指令"。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -32,4 +32,4 @@ composer placeholder 同时提示两个触发符(`描述你想要构建的内
|
||||
|
||||
## Consequences
|
||||
|
||||
过去每行用文字拼写的类型信息现在由图标和 section 标题承载;未来新增候选类型必须扩展 `InputTriggerCandidateIcon` 并选定图标,而非传任意字符串。指针移动经 reducer 往返(`hover` 对已高亮行是 no-op,mousemove 风暴不会搅动状态)。下钻的可发现性依赖高亮:空闲文件夹行在被悬停或键盘到达前只显示 chevron。延后的跟进项——精确匹配 token 的空格 settle、候选 description 内容、下钻后的回退导航、`name` 与 `name/` 标签、引用搜索延迟——记录在 [#3154](https://github.com/deepseek-harness/deepseek-harness/issues/3154)。
|
||||
过去每行用文字拼写的类型信息现在由图标和 section 标题承载;未来新增候选类型必须扩展 `InputTriggerCandidateIcon` 并选定图标,而非传任意字符串。指针移动经 reducer 往返(`hover` 对已高亮行是 no-op,mousemove 风暴不会搅动状态)。下钻的可发现性依赖高亮:空闲文件夹行在被悬停或键盘到达前只显示 chevron。延后的跟进项——精确匹配 token 的空格 settle、`name` 与 `name/` 标签——仍记录在 [#3154](https://github.com/deepseek-harness/deepseek-harness/issues/3154);候选 description 内容、下钻后的回退导航与引用搜索延迟由 [@ mention 发现与行内容笔记](2026-08-27-web-at-mention-discovery-and-row-content.zh.md) 结清。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md
|
||||
2026-08-27-web-at-mention-discovery-and-row-content.md: ae27f98b07d7a837e095f95129b355770fc8ac02
|
||||
2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 8569473d1dec28f371ae8cc12ed50081d2642ab4
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
# Agent Note: Web @ mention discovery cost and row content
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-27-web-at-mention-discovery-and-row-content.zh.md)
|
||||
|
||||
## 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 `persistence.readFrom`.** 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.
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
# 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` 无法复现产生该数字的语料。
|
||||
@@ -1,10 +1,10 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: Files & folders
|
||||
- option "folderx/ folderx Browse folder" [selected]:
|
||||
- text: folderx/ folderx
|
||||
- option "folderx/ Browse folder" [selected]:
|
||||
- text: folderx/
|
||||
- button "Browse folder":
|
||||
- img
|
||||
- option "reference.txt reference.txt"
|
||||
- option "reference.txt"
|
||||
- text: Sessions
|
||||
- option "Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}"
|
||||
- option "Research notes reference-source-session · {{cwd}} · {{timestamp}}"
|
||||
- option "reference-order-target-session {{cwd}} · {{age}}"
|
||||
- option "reference-source-session {{cwd}} · {{age}}"
|
||||
|
||||
@@ -147,13 +147,22 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
|
||||
await input.fill('@')
|
||||
await expect.poll(() => menu.getByRole('option').count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2)
|
||||
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
||||
// Session rows are dated from the live Host list, so their age bucket
|
||||
// advances while the suite runs.
|
||||
const snapshot = await captureStableAria(
|
||||
page, '[role="listbox"]', scaffold.workspaceCwd, { normalizeAge: true },
|
||||
)
|
||||
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot).toContain('Files & folders')
|
||||
expect(snapshot).toContain('Sessions')
|
||||
expect(snapshot).not.toContain('text: reference Files & folders')
|
||||
expect(snapshot).toContain('reference.txt')
|
||||
expect(snapshot).toContain('Research notes')
|
||||
// A seed reaches disk as a log alone, and the Host labels a session from
|
||||
// its projections: no checkpoint, so the row is its id. The fixture's own
|
||||
// title (`Research notes`) is unreachable here by construction, and the
|
||||
// package suite owns the titled paths.
|
||||
expect(snapshot).toContain(SOURCE_SESSION_ID)
|
||||
expect(snapshot).not.toContain('Research notes')
|
||||
expect(snapshot).not.toContain('text: Subagents')
|
||||
|
||||
await input.fill('@reference')
|
||||
@@ -166,12 +175,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
await expect.poll(() => fileReference.locator('svg').count()).toBe(1)
|
||||
await expect.poll(() => input.textContent()).toBe('reference.txt ')
|
||||
|
||||
await input.fill('@Research')
|
||||
await menu.getByRole('option', { name: /Research notes/ }).click()
|
||||
await input.fill('@reference-source')
|
||||
await menu.getByRole('option', { name: new RegExp(SOURCE_SESSION_ID) }).click()
|
||||
const sessionReference = page.locator('[data-composer-chip]').last()
|
||||
await expect.poll(() => sessionReference.textContent()).toBe('Research notes')
|
||||
await expect.poll(() => sessionReference.textContent()).toBe(SOURCE_SESSION_ID)
|
||||
await expect.poll(() => sessionReference.locator('svg').count()).toBe(1)
|
||||
await expect.poll(() => input.textContent()).toBe('Research notes ')
|
||||
await expect.poll(() => input.textContent()).toBe(`${SOURCE_SESSION_ID} `)
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
@@ -191,16 +200,16 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
await input.click()
|
||||
await page.keyboard.press('ControlOrMeta+A')
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await page.keyboard.type('@Research')
|
||||
await menu.getByRole('option', { name: /Research notes/ }).click()
|
||||
await page.keyboard.type('@reference-source')
|
||||
await menu.getByRole('option', { name: new RegExp(SOURCE_SESSION_ID) }).click()
|
||||
|
||||
// Both chips survive the boundary insert: the session chip lands ahead of
|
||||
// the intact file chip.
|
||||
const chips = input.locator('[data-composer-chip]')
|
||||
await expect.poll(() => chips.count()).toBe(2)
|
||||
await expect.poll(() => chips.first().textContent()).toBe('Research notes')
|
||||
await expect.poll(() => chips.first().textContent()).toBe(SOURCE_SESSION_ID)
|
||||
await expect.poll(() => chips.last().textContent()).toBe('reference.txt')
|
||||
await expect.poll(() => input.textContent()).toBe('Research notes reference.txt ')
|
||||
await expect.poll(() => input.textContent()).toBe(`${SOURCE_SESSION_ID} reference.txt `)
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
@@ -278,6 +287,42 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('a drilled listing carries a breadcrumb back to the workspace root', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-breadcrumb'))
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
const crumbs = page.getByRole('navigation', { name: 'Folder navigation' })
|
||||
|
||||
// A path the user typed carries its own context: no header.
|
||||
await writeComposerDraft(page, input, '@folderx/')
|
||||
await menu.getByRole('option', { name: /child\.txt/ }).waitFor({ timeout: 60_000 })
|
||||
await expect.poll(() => crumbs.count()).toBe(0)
|
||||
|
||||
// The same listing reached by drilling owes the user the way back.
|
||||
await writeComposerDraft(page, input, '@folderx')
|
||||
await menu.getByRole('option', { name: /^folderx\// }).waitFor()
|
||||
await page.keyboard.press('Tab')
|
||||
await menu.getByRole('option', { name: /child\.txt/ }).waitFor()
|
||||
await crumbs.waitFor()
|
||||
await expect.poll(() => crumbs.getByRole('button').allTextContents())
|
||||
.toEqual(['Workspace', 'folderx'])
|
||||
// The listed folder is where the menu already is: its crumb is inert, and
|
||||
// the rows drop the location the header now carries.
|
||||
await expect.poll(() => crumbs.getByRole('button', { name: 'folderx' }).isDisabled()).toBe(true)
|
||||
await expect.poll(() => menu.getByRole('option', { name: /child\.txt/ }).textContent())
|
||||
.toBe('child.txt')
|
||||
|
||||
// Clicking the root crumb rewrites the token back to a bare trigger.
|
||||
await crumbs.getByRole('button', { name: 'Workspace' }).click()
|
||||
await expect.poll(() => input.textContent()).toBe('@')
|
||||
await expect.poll(() => crumbs.count()).toBe(0)
|
||||
await menu.getByRole('option', { name: /^folderx\// }).waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('renders the durable direct-message then recall order', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order'))
|
||||
const group = page.getByRole('treeitem', { name: /Ungrouped/ })
|
||||
|
||||
@@ -1096,11 +1096,23 @@ async function persistSeedSession(
|
||||
* on one machine (measured 69 → 70 tok/s) and swings wildly on a fast replay
|
||||
* (26333 tok/s for a 3 ms stream).
|
||||
*/
|
||||
function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
/**
|
||||
* Relative-time buckets rendered by a dated row, in both dictionaries.
|
||||
*
|
||||
* Opt-in per capture: a session-tree golden asserts its own literal age (a
|
||||
* fresh row reads `now`, an older one does not), so collapsing the vocabulary
|
||||
* everywhere would delete that assertion. A region whose rows are dated from
|
||||
* live wall-clock state asks for it instead. Anchored on an aria label's
|
||||
* closing quote, where the bucket is always last.
|
||||
*/
|
||||
const ARIA_AGE =
|
||||
/(?:now|\d+min|\d+h|\d+d|\d+mo|\d+y|刚刚|\d+分钟|\d+小时|\d+天|\d+个月|\d+年)(?=")/g
|
||||
|
||||
function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): string {
|
||||
// The session heading renders the workspace's basename, not the full
|
||||
// path, so both spellings must collapse to the token.
|
||||
const base = workspaceCwd.split('/').pop()!
|
||||
return snapshot
|
||||
return (age ? snapshot.replace(ARIA_AGE, '{{age}}') : snapshot)
|
||||
.split(workspaceCwd).join('{{cwd}}')
|
||||
.split(base).join('{{workspace}}')
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
|
||||
@@ -1135,13 +1147,21 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
* @param page - the page under test.
|
||||
* @param selector - the region locator selector.
|
||||
* @param workspaceCwd - normalization input.
|
||||
* @param options - `normalizeAge` collapses relative-time buckets to `{{age}}`
|
||||
* for a region whose rows are dated from live wall-clock state.
|
||||
* @returns the stable normalized snapshot.
|
||||
*/
|
||||
export async function captureStableAria(page: Page, selector: string, workspaceCwd: string): Promise<string> {
|
||||
export async function captureStableAria(
|
||||
page: Page,
|
||||
selector: string,
|
||||
workspaceCwd: string,
|
||||
options: { normalizeAge?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const region = page.locator(selector).first()
|
||||
let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd)
|
||||
const age = options.normalizeAge === true
|
||||
let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age)
|
||||
await expect.poll(async () => {
|
||||
const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd)
|
||||
const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age)
|
||||
const stable = current === previous
|
||||
previous = current
|
||||
return stable
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = '
|
||||
await pathInput.fill(join(root, name))
|
||||
await pathInput.press('Enter')
|
||||
await dialog.getByRole('button', { name: '打开', exact: true }).click()
|
||||
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容… / 调用指令 @ 文件或会话"]')
|
||||
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容… / 调用指令 @ 文件或对话"]')
|
||||
.waitFor({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: 28885c4bde3215075f213135ad64db8feadb4a9a
|
||||
module-graph.zh.md: 68d22f46c645dd62426f893bec4a0b5399d5ef6e
|
||||
module-graph.md: e6013b2915121444f9f1e5ccc172190b8fccc650
|
||||
module-graph.zh.md: 6b491805dbbf7733cbda881552a2b8319d81ecc4
|
||||
|
||||
@@ -1111,7 +1111,10 @@ flowchart TD
|
||||
pkg_session_reference --> pkg_llm
|
||||
pkg_session_reference --> pkg_output_retention
|
||||
pkg_session_reference --> pkg_session
|
||||
pkg_session_reference --> pkg_session_projection
|
||||
pkg_session_reference --> pkg_session_projection_cache
|
||||
pkg_session_reference --> pkg_session_query
|
||||
pkg_session_reference --> pkg_session_title
|
||||
pkg_session_reference --> pkg_typert_protocol
|
||||
pkg_agent_spine_demo --> pkg_agent
|
||||
pkg_agent_spine_demo --> pkg_agent_instructions
|
||||
@@ -1544,12 +1547,15 @@ flowchart TD
|
||||
pkg_client_ui_commands --> pkg_invariants
|
||||
pkg_client_ui_commands --> pkg_session
|
||||
pkg_client_ui_reference --> pkg_api_remotes
|
||||
pkg_client_ui_reference --> pkg_api_session_controller
|
||||
pkg_client_ui_reference --> pkg_client_connection
|
||||
pkg_client_ui_reference --> pkg_client_locale
|
||||
pkg_client_ui_reference --> pkg_client_ui_input_trigger
|
||||
pkg_client_ui_reference --> pkg_file_reference
|
||||
pkg_client_ui_reference --> pkg_invariants
|
||||
pkg_client_ui_reference --> pkg_session_reference
|
||||
pkg_client_ui_reference --> pkg_typert_protocol
|
||||
pkg_client_ui_reference --> pkg_util_workspace_path
|
||||
pkg_client_ui_subagent --> pkg_api_session_controller
|
||||
pkg_client_ui_subagent --> pkg_client_connection
|
||||
pkg_client_ui_subagent --> pkg_client_locale
|
||||
@@ -1869,7 +1875,7 @@ flowchart TD
|
||||
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) |
|
||||
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
|
||||
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
|
||||
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
|
||||
@@ -1922,7 +1928,7 @@ flowchart TD
|
||||
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
|
||||
| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
|
||||
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
|
||||
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
|
||||
@@ -1113,7 +1113,10 @@ flowchart TD
|
||||
pkg_session_reference --> pkg_llm
|
||||
pkg_session_reference --> pkg_output_retention
|
||||
pkg_session_reference --> pkg_session
|
||||
pkg_session_reference --> pkg_session_projection
|
||||
pkg_session_reference --> pkg_session_projection_cache
|
||||
pkg_session_reference --> pkg_session_query
|
||||
pkg_session_reference --> pkg_session_title
|
||||
pkg_session_reference --> pkg_typert_protocol
|
||||
pkg_agent_spine_demo --> pkg_agent
|
||||
pkg_agent_spine_demo --> pkg_agent_instructions
|
||||
@@ -1546,12 +1549,15 @@ flowchart TD
|
||||
pkg_client_ui_commands --> pkg_invariants
|
||||
pkg_client_ui_commands --> pkg_session
|
||||
pkg_client_ui_reference --> pkg_api_remotes
|
||||
pkg_client_ui_reference --> pkg_api_session_controller
|
||||
pkg_client_ui_reference --> pkg_client_connection
|
||||
pkg_client_ui_reference --> pkg_client_locale
|
||||
pkg_client_ui_reference --> pkg_client_ui_input_trigger
|
||||
pkg_client_ui_reference --> pkg_file_reference
|
||||
pkg_client_ui_reference --> pkg_invariants
|
||||
pkg_client_ui_reference --> pkg_session_reference
|
||||
pkg_client_ui_reference --> pkg_typert_protocol
|
||||
pkg_client_ui_reference --> pkg_util_workspace_path
|
||||
pkg_client_ui_subagent --> pkg_api_session_controller
|
||||
pkg_client_ui_subagent --> pkg_client_connection
|
||||
pkg_client_ui_subagent --> pkg_client_locale
|
||||
@@ -1690,7 +1696,7 @@ flowchart TD
|
||||
pkg_client_ui_cordis --> pkg_invariants
|
||||
```
|
||||
|
||||
| 包 | 分组 | 依赖 |
|
||||
| Package | Group | Depends on |
|
||||
| --- | --- | --- |
|
||||
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
|
||||
| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
@@ -1871,7 +1877,7 @@ flowchart TD
|
||||
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) |
|
||||
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
|
||||
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
|
||||
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
|
||||
@@ -1924,7 +1930,7 @@ flowchart TD
|
||||
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
|
||||
| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
|
||||
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
|
||||
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/session-reference.md
|
||||
session-reference.md: 17c0d14485bd5a0eea97781c8f5fe1c4b3bda886
|
||||
session-reference.zh.md: 31a94bf94442a3778763457869bf916e18964a5c
|
||||
session-reference.md: 429459f37132a379d18251af646181bc29d97d40
|
||||
session-reference.zh.md: b505498e560c2c5b8f211be7650cc39a16b578c3
|
||||
|
||||
@@ -34,7 +34,7 @@ interface SessionReferenceInput {
|
||||
}
|
||||
```
|
||||
|
||||
`SessionReferenceCandidate` is host-facing discovery output. Its label uses the latest session title when present, while filtering still searches only session id and cwd and never transcript text.
|
||||
`SessionReferenceCandidate` is host-facing discovery output. Its label uses the latest session title when present, and filtering searches that label alongside session id and cwd, never transcript text.
|
||||
|
||||
```ts type-equiv
|
||||
/** One host-facing candidate from exact session metadata. */
|
||||
@@ -45,6 +45,12 @@ interface SessionReferenceCandidate {
|
||||
label: string
|
||||
/** Source session working directory, when recorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
* True when {@link SessionReferenceCandidate.cwd} is recorded and equals the
|
||||
* requesting agent's. Hosts that only surface a distinguishing location
|
||||
* read this instead of comparing paths they never received.
|
||||
*/
|
||||
sameWorkspace: boolean
|
||||
/** Source session creation time in Unix epoch milliseconds. */
|
||||
createdAt: number
|
||||
}
|
||||
@@ -138,6 +144,10 @@ Exact-read consumer that prepares immutable cross-session message context.
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* List reference candidates, ranked by working-directory affinity.
|
||||
*
|
||||
* Discovery runs at keystroke rate, so a title only ever comes from a
|
||||
* projection read: see {@link SessionReferenceResolver.projectedTitle} for
|
||||
* which sessions can answer one and which fall back to their id.
|
||||
* @param agent - target agent; self is excluded and its cwd drives ranking.
|
||||
* @param query - optional case-insensitive session-id/cwd/title substring.
|
||||
* @param limit - optional positive result cap.
|
||||
|
||||
@@ -34,7 +34,7 @@ interface SessionReferenceInput {
|
||||
}
|
||||
```
|
||||
|
||||
`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选仍只搜索 session id 和 cwd,绝不搜索 transcript(文本记录)。
|
||||
`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选搜索该 label 以及 session id 和 cwd,绝不搜索 transcript(文本记录)。
|
||||
|
||||
```ts type-equiv
|
||||
/** One host-facing candidate from exact session metadata. */
|
||||
@@ -45,6 +45,12 @@ interface SessionReferenceCandidate {
|
||||
label: string
|
||||
/** Source session working directory, when recorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
* True when {@link SessionReferenceCandidate.cwd} is recorded and equals the
|
||||
* requesting agent's. Hosts that only surface a distinguishing location
|
||||
* read this instead of comparing paths they never received.
|
||||
*/
|
||||
sameWorkspace: boolean
|
||||
/** Source session creation time in Unix epoch milliseconds. */
|
||||
createdAt: number
|
||||
}
|
||||
@@ -138,6 +144,10 @@ Exact-read consumer that prepares immutable cross-session message context.
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* List reference candidates, ranked by working-directory affinity.
|
||||
*
|
||||
* Discovery runs at keystroke rate, so a title only ever comes from a
|
||||
* projection read: see {@link SessionReferenceResolver.projectedTitle} for
|
||||
* which sessions can answer one and which fall back to their id.
|
||||
* @param agent - target agent; self is excluded and its cwd drives ranking.
|
||||
* @param query - optional case-insensitive session-id/cwd/title substring.
|
||||
* @param limit - optional positive result cap.
|
||||
|
||||
@@ -142,7 +142,7 @@ async function bench(opts: BenchOptions = {}) {
|
||||
}
|
||||
/** Warm one session's catalog through the source's own candidate pull. */
|
||||
const warm = async (session: ClientSessionContext) => {
|
||||
await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal })
|
||||
await source.candidates(session, { query: '', position: 'leading', drilled: false, signal: new AbortController().signal })
|
||||
}
|
||||
return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, remote }
|
||||
}
|
||||
@@ -175,7 +175,7 @@ const themeContribution = (over: Partial<CommandContribution> = {}): CommandCont
|
||||
})
|
||||
|
||||
const req = (query: string, position: 'leading' | 'inline' = 'leading') =>
|
||||
({ query, position, signal: new AbortController().signal })
|
||||
({ query, position, drilled: false, signal: new AbortController().signal })
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => {
|
||||
|
||||
@@ -13,10 +13,10 @@ export const zh = {
|
||||
'hint.goal': '输入目标,智能体将持续执行',
|
||||
'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
|
||||
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
|
||||
'placeholder.default': '发消息或做任务… / 调用指令 @ 文件或会话',
|
||||
'placeholder.default': '发消息或做任务… / 调用指令 @ 文件或对话',
|
||||
'placeholder.unavailable': '会话不可用',
|
||||
'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行',
|
||||
'placeholder.hero': '描述你想要构建的内容… / 调用指令 @ 文件或会话',
|
||||
'placeholder.hero': '描述你想要构建的内容… / 调用指令 @ 文件或对话',
|
||||
'placeholder.workspace': '选择一个工作区开始',
|
||||
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
|
||||
'input.commands': '指令',
|
||||
|
||||
@@ -444,9 +444,9 @@ describe('Enter semantics', () => {
|
||||
})
|
||||
|
||||
it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
|
||||
expect(bench({ running: true }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话')
|
||||
expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话')
|
||||
expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话')
|
||||
expect(bench({ running: true }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
|
||||
expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
|
||||
expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
|
||||
expect(bench({
|
||||
running: true,
|
||||
queue: [row('q-1')],
|
||||
@@ -454,7 +454,7 @@ describe('Enter semantics', () => {
|
||||
address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
},
|
||||
}).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话')
|
||||
}).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
|
||||
expect(bench({
|
||||
running: true,
|
||||
queue: [row('q-1')],
|
||||
@@ -466,7 +466,7 @@ describe('Enter semantics', () => {
|
||||
running: true,
|
||||
queue: [row('q-1')],
|
||||
commandMenuOpen: true,
|
||||
}).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话')
|
||||
}).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
|
||||
// The steer hint intentionally outranks the plan placeholder: while it
|
||||
// shows, the whole-queue gesture is genuinely available in plan mode.
|
||||
expect(bench({
|
||||
@@ -962,7 +962,7 @@ describe('running and lock semantics', () => {
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
expect(bench({ disabled: true }).placeholder).toBe('会话不可用')
|
||||
const live = bench()
|
||||
expect(live.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话')
|
||||
expect(live.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
|
||||
const custom = bench({ placeholder: 'Custom placeholder' })
|
||||
expect(custom.placeholder).toBe('Custom placeholder')
|
||||
})
|
||||
@@ -1009,7 +1009,7 @@ describe('running and lock semantics', () => {
|
||||
expect(entering.placeholder).toBe('描述你的任务以生成计划')
|
||||
// Pending exit: target is default again.
|
||||
const leaving = bench({ plan: { active: true, pending: true } })
|
||||
expect(leaving.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话')
|
||||
expect(leaving.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
|
||||
// Owner placeholder outranks the plan swap.
|
||||
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
|
||||
expect(custom.placeholder).toBe('Custom placeholder')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-input-trigger/README.md
|
||||
README.md: 1760881ba84492d967d405a43da179f5032763e1
|
||||
README.zh.md: 7a6e127c12a0f2f1f0d9c95021bb0d41ddd17e7b
|
||||
README.md: 1f316229327d33f48e0950a2c27e143c6c991f99
|
||||
README.zh.md: 324541120406eac8677afcfda0967540dd74848e
|
||||
|
||||
@@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation`; the menu then appears in the inpu
|
||||
|
||||
### Keyboard and mouse
|
||||
|
||||
The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. A candidate declaring `drill: true` carries a second verb beside the settling pick: its trailing chevron and the Tab key route the same row through `onPick` with `action: 'drill'` (every other path reports `'pick'`), and Tab passes untouched on rows without the flag so native focus traversal survives.
|
||||
The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. A candidate declaring `drill: true` carries a second verb beside the settling pick: its trailing chevron and the Tab key route the same row through `onPick` with `action: 'drill'` (every other path reports `'pick'`), and Tab passes untouched on rows without the flag so native focus traversal survives. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
|
||||
|
||||
-----
|
||||
|
||||
@@ -39,7 +39,7 @@ The composer surface keeps focus while the menu is open: rows pick on mousedown,
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
`src/core/` is the pure core — trigger detection, menu reduction, and exact match, with zero React/DOM/cordis — while `src/client/service.ts` wires the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources dropping silently with a console record), and the pick paths. One `InputTriggerController` resolves per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. A source is warmed into every session controller it can reach; sources whose `lexicon` rolls change after warm implement `subscribeLexicon` and the controller re-polls on each notification. `MenuView` self-registers into `conversation.input.overlay` (list kind, session scope) and renders null while closed. The overlay SlotMap merge lives here because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import.
|
||||
`src/core/` is the pure core — trigger detection, menu reduction, and exact match, with zero React/DOM/cordis — while `src/client/service.ts` wires the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources dropping silently with a console record), and the pick paths. One `InputTriggerController` resolves per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. A source is warmed into every session controller it can reach; sources whose `lexicon` rolls change after warm implement `subscribeLexicon` and the controller re-polls on each notification. `MenuView` self-registers into `conversation.input.overlay` (list kind, session scope) and renders null while closed. The `listbox` role sits on its scrolling viewport rather than the bounded shell, because a breadcrumb header is not an option and a listbox may not carry one; crumbs ride their own snapshot store beside the menu store, so the frozen reducer stays unaware of them. The overlay SlotMap merge lives here because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 键盘与鼠标
|
||||
|
||||
菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。声明 `drill: true` 的候选行在选定 pick 之外携带第二个动词:行尾的 chevron 与 Tab 键把同一行以 `action: 'drill'` 送入 `onPick`(其余路径一律报告 `'pick'`);未声明该标记的行上 Tab 原样放行,原生焦点遍历不受影响。
|
||||
菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。声明 `drill: true` 的候选行在选定 pick 之外携带第二个动词:行尾的 chevron 与 Tab 键把同一行以 `action: 'drill'` 送入 `onPick`(其余路径一律报告 `'pick'`);未声明该标记的行上 Tab 原样放行,原生焦点遍历不受影响。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:管线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
|
||||
|
||||
-----
|
||||
|
||||
@@ -39,7 +39,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
`src/core/` 是纯内核——触发器检测、菜单归约与精确匹配,零 React/DOM/cordis——而 `src/client/service.ts` 把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代、失败的 source 静默丢弃并留一条 console 记录)与 pick 路径上。每个会话 scope 各解析一个 `InputTriggerController`(`sessionOf`);对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 会被预热进它能触达的每个会话 controller;`lexicon` 名录在预热后变化的 source 实现 `subscribeLexicon`,controller 每收到通知就重拉。`MenuView` 自注册进 `conversation.input.overlay`(列表类,会话 scope),菜单关闭期间渲染 null。overlay 的 SlotMap 合并放在本包,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。
|
||||
`src/core/` 是纯内核——触发器检测、菜单归约与精确匹配,零 React/DOM/cordis——而 `src/client/service.ts` 把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代、失败的 source 静默丢弃并留一条 console 记录)与 pick 路径上。每个会话 scope 各解析一个 `InputTriggerController`(`sessionOf`);对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 会被预热进它能触达的每个会话 controller;`lexicon` 名录在预热后变化的 source 实现 `subscribeLexicon`,controller 每收到通知就重拉。`MenuView` 自注册进 `conversation.input.overlay`(列表类,会话 scope),菜单关闭期间渲染 null。`listbox` 角色落在其滚动视口而非有界外壳上,因为面包屑头部不是选项,listbox 也不得承载它;面包屑走菜单 store 之外的独立快照 store,冻结的归约器因此对它一无所知。overlay 的 SlotMap 合并放在本包,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -183,3 +183,50 @@
|
||||
40% { opacity: 0.6; }
|
||||
80%, 100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Breadcrumb header of a drilled source: pinned outside .viewport so the
|
||||
descent stays reversible while the candidate list scrolls under it. */
|
||||
.crumbs {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 4px 6px 6px;
|
||||
margin-bottom: 2px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-inverted);
|
||||
}
|
||||
/* Crumbs sit outside the listbox and take no keyboard highlight, so unlike
|
||||
.item they carry their own :hover tint with nothing to compete with. */
|
||||
.crumb {
|
||||
flex: 0 1 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.crumb:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
/* The step the list is already showing reads as a label, not an action. */
|
||||
.crumbCurrent,
|
||||
.crumbCurrent:hover {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: default;
|
||||
}
|
||||
.crumbSeparator {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* rows, pending groups as two skeleton rows; pointer picks route back through
|
||||
* the service (combobox pattern — focus never leaves the textarea, so rows
|
||||
* are mousedown-handled and the highlight is exposed via
|
||||
* aria-activedescendant on the listbox).
|
||||
* aria-activedescendant on the listbox). A source publishing crumbs gets a
|
||||
* breadcrumb header pinned above the scrolling list.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -31,11 +32,15 @@ function optionId(source: string, index: number): string {
|
||||
* @param props - injected face (the menu store and the pick route); `t` rides the standard locale seat.
|
||||
* @returns the dropdown while open; null while closed.
|
||||
*/
|
||||
export function MenuView({ menu, onPick, onHover, onDismiss, t }: MenuViewProps) {
|
||||
export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t }: MenuViewProps) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => menu.subscribe(fn),
|
||||
() => menu.getSnapshot(),
|
||||
)
|
||||
const crumbs = useSyncExternalStore(
|
||||
fn => headers.subscribe(fn),
|
||||
() => headers.getSnapshot(),
|
||||
)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// The list is bottom-anchored above the composer; clamp the design cap to
|
||||
// the space above it, re-measured on every store update (the anchor moves
|
||||
@@ -65,15 +70,40 @@ export function MenuView({ menu, onPick, onHover, onDismiss, t }: MenuViewProps)
|
||||
}, [state.open, onDismiss])
|
||||
if (!state.open) return null
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={css.menu}
|
||||
style={{ maxHeight }}
|
||||
role="listbox"
|
||||
aria-label={t('suggestions.aria')}
|
||||
aria-activedescendant={highlight !== null ? optionId(highlight.source, highlight.index) : undefined}
|
||||
>
|
||||
<div className={css.viewport}>
|
||||
// The listbox role sits on the scrolling viewport, not this shell: a
|
||||
// breadcrumb header is not an option, and a listbox may not carry one.
|
||||
<div ref={listRef} className={css.menu} style={{ maxHeight }} data-trigger-menu="">
|
||||
{state.groups.map((group) => {
|
||||
const trail = crumbs.get(group.source)
|
||||
return trail === undefined ? null : (
|
||||
<nav key={group.source} className={css.crumbs} aria-label={t('crumbs.aria')}>
|
||||
{trail.map((crumb, index) => (
|
||||
<Fragment key={`${String(index)}-${crumb.value}`}>
|
||||
{index > 0 && <span className={css.crumbSeparator} aria-hidden><IconChevronRightOutline14 /></span>}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.crumb, crumb.current === true && css.crumbCurrent)}
|
||||
aria-current={crumb.current === true ? 'location' : undefined}
|
||||
disabled={crumb.current === true}
|
||||
// mousedown, not click: the composer keeps focus, same as a row.
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault()
|
||||
onCrumb(group.source, index)
|
||||
}}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
</Fragment>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className={css.viewport}
|
||||
role="listbox"
|
||||
aria-label={t('suggestions.aria')}
|
||||
aria-activedescendant={highlight !== null ? optionId(highlight.source, highlight.index) : undefined}
|
||||
>
|
||||
{state.groups.map(group => (group.status === 'ready' && group.items.length === 0)
|
||||
? null
|
||||
: (
|
||||
|
||||
@@ -17,7 +17,8 @@ import { detectTrigger } from '../core/detect.ts'
|
||||
import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts'
|
||||
import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts'
|
||||
import type {
|
||||
ClientSessionContext, InputTriggerSource, PickAction, SubmitEnvelope, TriggerChar, TriggerGuard,
|
||||
ClientSessionContext, InputTriggerCandidate, InputTriggerCrumb, InputTriggerSource, PickAction,
|
||||
SubmitEnvelope, TriggerChar, TriggerGuard,
|
||||
} from '../types.ts'
|
||||
|
||||
/** Roster access the controller borrows from the root service (registration order preserved). */
|
||||
@@ -50,6 +51,14 @@ export class InputTriggerController {
|
||||
* for the launcher's expanded state without owning a second menu model.
|
||||
*/
|
||||
readonly launcher: SnapshotStore<string | null> = createSnapshotStore<string | null>(null)
|
||||
/**
|
||||
* Crumbs published by each header-bearing source for the open menu, keyed
|
||||
* by source name. A snapshot store like {@link InputTriggerController.launcher}:
|
||||
* the answer changes with every hit, and render-side consumers subscribe
|
||||
* instead of re-polling sources during a render.
|
||||
*/
|
||||
readonly headers: SnapshotStore<ReadonlyMap<string, readonly InputTriggerCrumb[]>> =
|
||||
createSnapshotStore<ReadonlyMap<string, readonly InputTriggerCrumb[]>>(new Map())
|
||||
/**
|
||||
* Aggregated hot reference lexicon, grouped by trigger (plain-text-reference decision;
|
||||
* see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
|
||||
@@ -65,6 +74,8 @@ export class InputTriggerController {
|
||||
|
||||
/** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
|
||||
private hit: TriggerHit | null = null
|
||||
/** Whether the open menu was reached by a drill pick; cleared with the menu. */
|
||||
private drilled = false
|
||||
private fetch: AbortController | null = null
|
||||
private disposed = false
|
||||
/** Per-source lexicon unsubscribers (sources without the hook never enter). */
|
||||
@@ -119,6 +130,7 @@ export class InputTriggerController {
|
||||
this.menu.set(seedGroups(this.menu.getSnapshot(), roster))
|
||||
}
|
||||
this.reduce({ type: 'hit', hit })
|
||||
this.refreshHeaders(hit, roster)
|
||||
this.fetchCandidates(hit, roster)
|
||||
}
|
||||
|
||||
@@ -146,6 +158,7 @@ export class InputTriggerController {
|
||||
this.launcher.set(source)
|
||||
this.menu.set(seedGroups(this.menu.getSnapshot(), [match]))
|
||||
this.reduce({ type: 'hit', hit })
|
||||
this.refreshHeaders(hit, [match])
|
||||
this.fetchCandidates(hit, [match])
|
||||
}
|
||||
|
||||
@@ -165,17 +178,24 @@ export class InputTriggerController {
|
||||
if (candidate === undefined) return
|
||||
const src = this.deps.roster.sources(hit.trigger).find(s => s.name === source)
|
||||
if (src === undefined) return
|
||||
const outcome = src.onPick({
|
||||
candidate,
|
||||
session: this.project(),
|
||||
position: hit.position,
|
||||
via: 'menu',
|
||||
action,
|
||||
span: hit.span,
|
||||
})
|
||||
this.stopFetch()
|
||||
this.reduce({ type: 'close' })
|
||||
this.execute(outcome, hit.span)
|
||||
this.settle(src, candidate, hit, action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer pick on one crumb of a source's menu header: route it through the
|
||||
* same drill path a folder row takes, so returning to a step and descending
|
||||
* into one share one outcome.
|
||||
* @param source - source (group) name.
|
||||
* @param index - crumb index within that source's published header.
|
||||
*/
|
||||
pickCrumb(source: string, index: number): void {
|
||||
const hit = this.hit
|
||||
if (this.disposed || !this.menu.getSnapshot().open || hit === null) return
|
||||
const crumb = this.headers.getSnapshot().get(source)?.[index]
|
||||
if (crumb === undefined || crumb.current === true) return
|
||||
const src = this.deps.roster.sources(hit.trigger).find(s => s.name === source)
|
||||
if (src === undefined) return
|
||||
this.settle(src, { name: crumb.label, value: crumb.value }, hit, 'drill')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -416,6 +436,7 @@ export class InputTriggerController {
|
||||
query: hit.query,
|
||||
quoted: hit.quoted,
|
||||
position: hit.position,
|
||||
drilled: this.drilled,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(
|
||||
@@ -437,6 +458,69 @@ export class InputTriggerController {
|
||||
this.fetch = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one candidate (or crumb) through its source and apply the outcome.
|
||||
*
|
||||
* A drill is the one pick that leaves the menu open, so it is also the one
|
||||
* that records how the next query was reached; every other pick closes the
|
||||
* menu, which clears that record.
|
||||
* @param src - the owning source.
|
||||
* @param candidate - the picked candidate, or a crumb projected as one.
|
||||
* @param hit - the authoritative hit supplying position and span CAS.
|
||||
* @param action - settling pick or drill.
|
||||
*/
|
||||
private settle(
|
||||
src: InputTriggerSource,
|
||||
candidate: InputTriggerCandidate,
|
||||
hit: TriggerHit,
|
||||
action: PickAction,
|
||||
): void {
|
||||
const outcome = src.onPick({
|
||||
candidate,
|
||||
session: this.project(),
|
||||
position: hit.position,
|
||||
via: 'menu',
|
||||
action,
|
||||
span: hit.span,
|
||||
})
|
||||
this.stopFetch()
|
||||
this.reduce({ type: 'close' })
|
||||
const applied = this.execute(outcome, hit.span)
|
||||
// Set after the close above, so the reducer's own teardown cannot clear
|
||||
// it, and only when the descent text actually landed: a refused edit
|
||||
// (stale draft revision, or no listener) leaves the draft where it was,
|
||||
// and a header over that draft would name a directory nobody descended
|
||||
// into while hiding the locations its rows still need.
|
||||
this.drilled = action === 'drill' && applied
|
||||
}
|
||||
|
||||
/** Re-poll every header-bearing source in the hit roster and publish their crumbs. */
|
||||
private refreshHeaders(hit: TriggerHit, roster: readonly InputTriggerSource[]): void {
|
||||
const projection = this.project()
|
||||
const crumbs = new Map<string, readonly InputTriggerCrumb[]>()
|
||||
for (const src of roster) {
|
||||
if (src.header === undefined) continue
|
||||
let published: readonly InputTriggerCrumb[] | undefined
|
||||
try {
|
||||
published = src.header(projection, { query: hit.query, quoted: hit.quoted, drilled: this.drilled })
|
||||
} catch (error) {
|
||||
// A faulty source drops silently with a console record (the
|
||||
// candidate-fetch failure policy); a header is decoration and must
|
||||
// not take down the menu that carries the candidates.
|
||||
console.error(`[ui-input-trigger] source "${src.name}" header failed:`, error)
|
||||
continue
|
||||
}
|
||||
if (published === undefined || published.length === 0) continue
|
||||
crumbs.set(src.name, published)
|
||||
}
|
||||
this.setHeaders(crumbs)
|
||||
}
|
||||
|
||||
private setHeaders(next: ReadonlyMap<string, readonly InputTriggerCrumb[]>): void {
|
||||
if (this.headers.getSnapshot().size === 0 && next.size === 0) return
|
||||
this.headers.set(next)
|
||||
}
|
||||
|
||||
private clearLauncher(): void {
|
||||
if (this.launcher.getSnapshot() !== null) this.launcher.set(null)
|
||||
}
|
||||
@@ -445,6 +529,9 @@ export class InputTriggerController {
|
||||
const cur = this.menu.getSnapshot()
|
||||
const next = menuReduce(cur, ev)
|
||||
if (next !== cur) this.menu.set(next)
|
||||
if (!next.open) this.clearLauncher()
|
||||
if (next.open) return
|
||||
this.clearLauncher()
|
||||
this.drilled = false
|
||||
this.setHeaders(new Map())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,10 @@ export type { MenuViewProps } from './MenuView.tsx'
|
||||
export type { MenuKey } from './locales.ts'
|
||||
export type {
|
||||
ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext,
|
||||
CommandClaim, ConsumeTokenRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec,
|
||||
ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SubmitEnvelope,
|
||||
SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition,
|
||||
CommandClaim, ConsumeTokenRequest, HeaderRequest, InsertReferenceRequest, PickOutcome, PickVia,
|
||||
ReferenceCodec, ReferenceInsert, InputTriggerCandidate, InputTriggerCrumb, InputTriggerPick,
|
||||
InputTriggerSource, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar,
|
||||
TriggerGuard, TriggerPosition,
|
||||
} from '../types.ts'
|
||||
export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts'
|
||||
export type { InputTriggerServiceContract } from './contract.ts'
|
||||
@@ -74,7 +75,9 @@ export function apply(ctx: ClientContext): void {
|
||||
const controller = inputTriggers.sessionOf(actx)
|
||||
return {
|
||||
menu: controller.menu,
|
||||
headers: controller.headers,
|
||||
onPick: (source, index, action) => { controller.pick(source, index, action) },
|
||||
onCrumb: (source, index) => { controller.pickCrumb(source, index) },
|
||||
onHover: (source, index) => { controller.hover(source, index) },
|
||||
onDismiss: () => { controller.dismiss() },
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* `slash.menu` namespace dictionaries: group titles keyed by source name
|
||||
* (the lookup chain returns the key itself, so an unknown source shows its
|
||||
* raw name), the pending row, and the listbox aria label.
|
||||
* raw name), the pending row, and the listbox and header aria labels.
|
||||
*/
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
@@ -13,6 +13,7 @@ export const zh = {
|
||||
'drill.aria': '进入目录',
|
||||
'drill.hint': '进入目录',
|
||||
'drill.key': 'Tab',
|
||||
'crumbs.aria': '目录导航',
|
||||
'suggestions.aria': '触发候选建议',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
@@ -28,5 +29,6 @@ export const en = {
|
||||
'drill.aria': 'Browse folder',
|
||||
'drill.hint': 'Browse folder',
|
||||
'drill.key': 'Tab',
|
||||
'crumbs.aria': 'Folder navigation',
|
||||
'suggestions.aria': 'Trigger suggestions',
|
||||
} satisfies Record<MenuKey, string>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Slash-menu props for the Conversation-owned input overlay. */
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { PickAction } from '../types.ts'
|
||||
import type { InputTriggerCrumb, PickAction } from '../types.ts'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { MenuState } from '../core/contract.ts'
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { MenuState } from '../core/contract.ts'
|
||||
export interface MenuViewInjected {
|
||||
/** The service's menu state store (read-only here; MenuView subscribes). */
|
||||
menu: SnapshotStore<MenuState>
|
||||
/** Crumbs published per source for the open menu; sources without a header never appear. */
|
||||
headers: SnapshotStore<ReadonlyMap<string, readonly InputTriggerCrumb[]>>
|
||||
/**
|
||||
* Pointer pick routed back through the service pipeline.
|
||||
* @param source - source (group) name.
|
||||
@@ -22,6 +24,12 @@ export interface MenuViewInjected {
|
||||
* @param index - candidate index within the group.
|
||||
*/
|
||||
onHover: (source: string, index: number) => void
|
||||
/**
|
||||
* Pointer pick on one header crumb, routed back through the source's drill path.
|
||||
* @param source - source (group) name.
|
||||
* @param index - crumb index within that source's published header.
|
||||
*/
|
||||
onCrumb: (source: string, index: number) => void
|
||||
/** Dismiss the menu (external pointer outside the composer area). */
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
@@ -61,6 +61,34 @@ export interface InputTriggerCandidate {
|
||||
readonly drill?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One crumb of a source's menu header. The pipeline treats `value` as opaque
|
||||
* and hands it straight back on pick, so a source names its own destinations.
|
||||
*/
|
||||
export interface InputTriggerCrumb {
|
||||
/** Rendered text of this step. */
|
||||
readonly label: string
|
||||
/** Opaque source-owned pick payload, returned through `onPick`. */
|
||||
readonly value: string
|
||||
/** The step the menu is currently showing; rendered as the trailing, unclickable crumb. */
|
||||
readonly current?: boolean
|
||||
}
|
||||
|
||||
/** What a source needs to decide the header of the open menu. */
|
||||
export interface HeaderRequest {
|
||||
/** Text between the trigger char and the caret, live-filtered. */
|
||||
readonly query: string
|
||||
/** Whether the active @file token is an open quoted path. */
|
||||
readonly quoted?: boolean
|
||||
/**
|
||||
* True while this menu was opened or last re-scoped by a drill pick. It
|
||||
* survives further typing and clears when the menu closes, so a query typed
|
||||
* after a drill still reads as drilled. The pipeline owns the fact; what it
|
||||
* means for a header is the source's to decide.
|
||||
*/
|
||||
readonly drilled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-text composer submission state visible to enter adjudication. The
|
||||
* composer owns the actual attachment payloads; adjudication only needs their
|
||||
@@ -77,6 +105,8 @@ export interface CandidateRequest {
|
||||
/** Whether the active @file token is an open quoted path. */
|
||||
readonly quoted?: boolean
|
||||
readonly position: TriggerPosition
|
||||
/** Whether this menu was opened or last re-scoped by a drill pick; see {@link HeaderRequest.drilled}. */
|
||||
readonly drilled: boolean
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
@@ -125,6 +155,18 @@ export interface InputTriggerSource {
|
||||
/** Whether the menu renders the source-title row; defaults to true. */
|
||||
readonly showGroupTitle?: boolean
|
||||
candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly InputTriggerCandidate[]>
|
||||
/**
|
||||
* Synchronous breadcrumb rendered above this source's group, re-polled on
|
||||
* every hit. Implementing IS the participation claim; `undefined` means
|
||||
* this request needs no header. A crumb pick routes back through
|
||||
* {@link InputTriggerSource.onPick} with `action: 'drill'` and the crumb's
|
||||
* `value` as the candidate value, so returning to a step and descending
|
||||
* into one are the same outcome.
|
||||
* @param session - stable session projection.
|
||||
* @param req - the live query and how the menu reached it.
|
||||
* @returns the crumbs to render, or undefined for no header.
|
||||
*/
|
||||
header?(session: ClientSessionContext, req: HeaderRequest): readonly InputTriggerCrumb[] | undefined
|
||||
/** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */
|
||||
onPick(pick: InputTriggerPick): PickOutcome
|
||||
/** Synchronous space-time adjudication over hot state only. `token` is the just-completed leading token (e.g. '/goal'). */
|
||||
|
||||
@@ -81,9 +81,13 @@ describe('apply', () => {
|
||||
ctx.sessions.scope(sid('a'))!,
|
||||
)
|
||||
expect(injected.menu).toBe(controller.menu)
|
||||
expect(injected.headers).toBe(controller.headers)
|
||||
// The pick face routes into the controller pipeline (closed menu → no-op).
|
||||
injected.onPick('command', 0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
// The crumb face routes into the controller too (closed menu → no-op).
|
||||
injected.onCrumb('command', 0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
// The hover face routes into the controller too (closed menu → no-op).
|
||||
injected.onHover('command', 0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
|
||||
@@ -13,7 +13,9 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type {
|
||||
InputTriggerCrumb, MenuState, TriggerHit,
|
||||
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import { MenuView } from '../src/client/MenuView.tsx'
|
||||
|
||||
const hit: TriggerHit = {
|
||||
@@ -57,13 +59,32 @@ afterEach(() => {
|
||||
// unknown source comes back verbatim (its raw name).
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
function mount(state: MenuState) {
|
||||
function mount(state: MenuState, crumbs: ReadonlyMap<string, readonly InputTriggerCrumb[]> = new Map()) {
|
||||
const menu = createSnapshotStore<MenuState>(state)
|
||||
const headers = createSnapshotStore<ReadonlyMap<string, readonly InputTriggerCrumb[]>>(crumbs)
|
||||
const onPick = vi.fn()
|
||||
const onCrumb = vi.fn()
|
||||
const onHover = vi.fn()
|
||||
const onDismiss = vi.fn()
|
||||
const view = render(<MenuView menu={menu} onPick={onPick} onHover={onHover} onDismiss={onDismiss} t={t} />)
|
||||
return { menu, onPick, onHover, onDismiss, view }
|
||||
const view = render(
|
||||
<MenuView
|
||||
menu={menu}
|
||||
headers={headers}
|
||||
onPick={onPick}
|
||||
onCrumb={onCrumb}
|
||||
onHover={onHover}
|
||||
onDismiss={onDismiss}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
return { menu, headers, onPick, onCrumb, onHover, onDismiss, view }
|
||||
}
|
||||
|
||||
/** The bounded menu shell: it owns the height clamp, the listbox scrolls inside it. */
|
||||
function menuShell(): HTMLElement {
|
||||
const shell = document.querySelector('[data-trigger-menu]')
|
||||
if (!(shell instanceof HTMLElement)) throw new Error('menu shell is not rendered')
|
||||
return shell
|
||||
}
|
||||
|
||||
/** The non-interactive group title rows (role=presentation), in document order. */
|
||||
@@ -188,23 +209,23 @@ describe('MenuView', () => {
|
||||
it('caps the list height at the design maximum when the composer sits low enough', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
|
||||
expect(menuShell().style.maxHeight).toBe('320px')
|
||||
})
|
||||
|
||||
it('clamps the list height to the space above the composer minus the safe margin', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('188px')
|
||||
expect(menuShell().style.maxHeight).toBe('188px')
|
||||
})
|
||||
|
||||
it('re-fits the height when the window resizes', () => {
|
||||
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
rect.mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
|
||||
expect(menuShell().style.maxHeight).toBe('320px')
|
||||
rect.mockReturnValue({ bottom: 100 } as DOMRect)
|
||||
act(() => { window.dispatchEvent(new Event('resize')) })
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('88px')
|
||||
expect(menuShell().style.maxHeight).toBe('88px')
|
||||
})
|
||||
|
||||
it('pointerdown outside the menu (no composer card ancestor) dismisses', () => {
|
||||
@@ -224,7 +245,15 @@ describe('MenuView', () => {
|
||||
const onDismiss = vi.fn()
|
||||
render(
|
||||
<div data-composer-card="">
|
||||
<MenuView menu={menu} onPick={vi.fn()} onHover={vi.fn()} onDismiss={onDismiss} t={t} />
|
||||
<MenuView
|
||||
menu={menu}
|
||||
headers={createSnapshotStore<ReadonlyMap<string, readonly InputTriggerCrumb[]>>(new Map())}
|
||||
onPick={vi.fn()}
|
||||
onCrumb={vi.fn()}
|
||||
onHover={vi.fn()}
|
||||
onDismiss={onDismiss}
|
||||
t={t}
|
||||
/>
|
||||
<button type="button" data-testid="composer-button" />
|
||||
</div>,
|
||||
)
|
||||
@@ -268,4 +297,36 @@ describe('MenuView', () => {
|
||||
fireEvent.mouseMove(options[0]!)
|
||||
expect(onHover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a source header as a breadcrumb above the list, current step last', () => {
|
||||
mount(openState(), new Map([['command', [
|
||||
{ label: 'Workspace', value: 'root' },
|
||||
{ label: 'src', value: 'src' },
|
||||
{ label: 'module1', value: 'module1', current: true },
|
||||
]]]))
|
||||
const nav = screen.getByRole('navigation', { name: '目录导航' })
|
||||
expect([...nav.querySelectorAll('button')].map(button => button.textContent))
|
||||
.toEqual(['Workspace', 'src', 'module1'])
|
||||
// The listbox holds options alone; the header is its sibling, not a row.
|
||||
expect(screen.getByRole('listbox').contains(nav)).toBe(false)
|
||||
})
|
||||
|
||||
it('mousedown on a crumb routes (source, index) without stealing focus; the current step is inert', () => {
|
||||
const { onCrumb } = mount(openState(), new Map([['command', [
|
||||
{ label: 'Workspace', value: 'root' },
|
||||
{ label: 'src', value: 'src', current: true },
|
||||
]]]))
|
||||
const crumbs = screen.getByRole('navigation', { name: '目录导航' }).querySelectorAll('button')
|
||||
expect(fireEvent.mouseDown(crumbs[0]!)).toBe(false)
|
||||
expect(onCrumb).toHaveBeenCalledWith('command', 0)
|
||||
onCrumb.mockClear()
|
||||
expect(crumbs[1]!.disabled).toBe(true)
|
||||
fireEvent.mouseDown(crumbs[1]!)
|
||||
expect(onCrumb).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders no header for a source that published no crumbs', () => {
|
||||
mount(openState())
|
||||
expect(screen.queryByRole('navigation')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -572,6 +572,127 @@ describe('pick / scoped input events', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('header / drilled descent', () => {
|
||||
/** A source that publishes one crumb per path segment of a drilled query. */
|
||||
function crumbSource() {
|
||||
const requests: Array<{ query: string; quoted?: boolean; drilled: boolean }> = []
|
||||
const picks: InputTriggerPick[] = []
|
||||
const source: InputTriggerSource = {
|
||||
trigger: '@',
|
||||
name: 'reference',
|
||||
candidates: () => Promise.resolve([{ name: 'src', drill: true, value: 'src' }]),
|
||||
header: (_session, req) => {
|
||||
requests.push({ ...req })
|
||||
if (!req.drilled || !req.query.includes('/')) return undefined
|
||||
return req.query.split('/').filter(Boolean).map(label => ({ label, value: label }))
|
||||
},
|
||||
onPick: (pick) => {
|
||||
picks.push(pick)
|
||||
return pick.action === 'drill' ? { text: `@${String(pick.candidate.value)}/`, continue: true } : undefined
|
||||
},
|
||||
}
|
||||
return { source, requests, picks }
|
||||
}
|
||||
|
||||
it('publishes no crumbs for a typed path and asks every source how the menu was reached', async () => {
|
||||
const { source, requests } = crumbSource()
|
||||
const { controller } = controllerBench([source])
|
||||
controller.track('@src/', 5, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
expect(requests).toEqual([{ query: 'src/', drilled: false, quoted: false }])
|
||||
expect(controller.headers.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('publishes crumbs once a drill produced the query, and drops them when the menu closes', async () => {
|
||||
const { source, picks } = crumbSource()
|
||||
const { controller, actx } = controllerBench([source])
|
||||
const texts: string[] = []
|
||||
actx.on('slash/input-insert-text', (req) => {
|
||||
texts.push(req.text)
|
||||
return true
|
||||
})
|
||||
controller.track('@sr', 3, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
controller.pick('reference', 0, 'drill')
|
||||
expect(texts).toEqual(['@src/'])
|
||||
expect(picks[0]?.action).toBe('drill')
|
||||
// The drilled text lands as the next tracked draft.
|
||||
controller.track('@src/', 5, { tier: 'plain' }, 2)
|
||||
await tick()
|
||||
expect(controller.headers.getSnapshot().get('reference')).toEqual([{ label: 'src', value: 'src' }])
|
||||
controller.dismiss()
|
||||
expect(controller.headers.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('routes a crumb through the source drill path and refuses the current step', async () => {
|
||||
const { source, picks } = crumbSource()
|
||||
const { controller, actx } = controllerBench([source])
|
||||
actx.on('slash/input-insert-text', () => true)
|
||||
controller.track('@sr', 3, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
controller.pick('reference', 0, 'drill')
|
||||
controller.track('@src/lib/', 9, { tier: 'plain' }, 2)
|
||||
await tick()
|
||||
const trail = controller.headers.getSnapshot().get('reference')
|
||||
expect(trail?.map(crumb => crumb.label)).toEqual(['src', 'lib'])
|
||||
picks.length = 0
|
||||
controller.pickCrumb('reference', 0)
|
||||
expect(picks).toHaveLength(1)
|
||||
expect(picks[0]).toMatchObject({ candidate: { name: 'src', value: 'src' }, action: 'drill', via: 'menu' })
|
||||
})
|
||||
|
||||
it('publishes no crumbs when the input refused the drill edit', async () => {
|
||||
const { source } = crumbSource()
|
||||
const { controller } = controllerBench([source])
|
||||
// No listener accepts the insert, so the descent text never landed.
|
||||
controller.track('@sr', 3, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
controller.pick('reference', 0, 'drill')
|
||||
controller.track('@src/', 5, { tier: 'plain' }, 2)
|
||||
await tick()
|
||||
expect(controller.headers.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('drops a source whose header throws and keeps the rest of the menu', async () => {
|
||||
const failing: InputTriggerSource = {
|
||||
trigger: '@',
|
||||
name: 'broken',
|
||||
candidates: () => Promise.resolve([{ name: 'x' }]),
|
||||
header: () => { throw new Error('header boom') },
|
||||
onPick: () => undefined,
|
||||
}
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const { controller } = controllerBench([failing])
|
||||
controller.track('@x', 2, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
expect(controller.headers.getSnapshot().size).toBe(0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
expect(spy).toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('tells candidate fetches how the menu was reached', async () => {
|
||||
const seen: boolean[] = []
|
||||
const source: InputTriggerSource = {
|
||||
trigger: '@',
|
||||
name: 'reference',
|
||||
candidates: (_session, req) => {
|
||||
seen.push(req.drilled)
|
||||
return Promise.resolve([{ name: 'src', drill: true, value: 'src' }])
|
||||
},
|
||||
onPick: pick => (pick.action === 'drill' ? { text: '@src/', continue: true } : undefined),
|
||||
}
|
||||
const { controller, actx } = controllerBench([source])
|
||||
actx.on('slash/input-insert-text', () => true)
|
||||
controller.track('@sr', 3, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
controller.pick('reference', 0, 'drill')
|
||||
controller.track('@src/', 5, { tier: 'plain' }, 2)
|
||||
await tick()
|
||||
expect(seen).toEqual([false, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('lexicon', () => {
|
||||
function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): InputTriggerSource {
|
||||
return {
|
||||
|
||||
@@ -32,6 +32,8 @@ export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { Toast } from './Toast.tsx'
|
||||
export { writeClipboard } from './clipboard.ts'
|
||||
export { relativeTime } from './relative-time.ts'
|
||||
export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts'
|
||||
export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Compact relative-time bucketing shared by every surface that dates a
|
||||
* session. Bucketing is here so two surfaces naming the same session agree;
|
||||
* the words stay in each plugin's own dictionary, per locale-owned copy.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-client-ui-primitives/relative-time
|
||||
*/
|
||||
|
||||
/** Relative-time bucket of a dated row's trailing label. */
|
||||
export type RelativeTimeUnit = 'now' | 'minutes' | 'hours' | 'days' | 'months' | 'years'
|
||||
|
||||
/** Structured relative time: the bucket plus its magnitude (0 for 'now'). */
|
||||
export interface RelativeTime {
|
||||
unit: RelativeTimeUnit
|
||||
n: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative time, as a structured bucket the renderer localizes
|
||||
* ("now"/"5min"/"3h"/"2d"/"4mo"/"1y" in en).
|
||||
* @param at - epoch ms of the dated moment.
|
||||
* @param now - current epoch ms (injected for pure rendering).
|
||||
* @returns the row's trailing time bucket and magnitude.
|
||||
*/
|
||||
export function relativeTime(at: number, now: number): RelativeTime {
|
||||
const MIN = 60_000
|
||||
const HOUR = 3_600_000
|
||||
const DAY = 86_400_000
|
||||
const diff = Math.max(0, now - at)
|
||||
if (diff < MIN) return { unit: 'now', n: 0 }
|
||||
if (diff < HOUR) return { unit: 'minutes', n: Math.floor(diff / MIN) }
|
||||
if (diff < DAY) return { unit: 'hours', n: Math.floor(diff / HOUR) }
|
||||
if (diff < 30 * DAY) return { unit: 'days', n: Math.floor(diff / DAY) }
|
||||
if (diff < 365 * DAY) return { unit: 'months', n: Math.floor(diff / (30 * DAY)) }
|
||||
return { unit: 'years', n: Math.floor(diff / (365 * DAY)) }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
const MIN = 60_000
|
||||
const HOUR = 3_600_000
|
||||
const DAY = 86_400_000
|
||||
const now = 1_800_000_000_000
|
||||
|
||||
describe('relativeTime', () => {
|
||||
it('buckets each distance and reports its magnitude', () => {
|
||||
expect(relativeTime(now, now)).toEqual({ unit: 'now', n: 0 })
|
||||
expect(relativeTime(now - 5 * MIN, now)).toEqual({ unit: 'minutes', n: 5 })
|
||||
expect(relativeTime(now - 3 * HOUR, now)).toEqual({ unit: 'hours', n: 3 })
|
||||
expect(relativeTime(now - 2 * DAY, now)).toEqual({ unit: 'days', n: 2 })
|
||||
expect(relativeTime(now - 60 * DAY, now)).toEqual({ unit: 'months', n: 2 })
|
||||
expect(relativeTime(now - 400 * DAY, now)).toEqual({ unit: 'years', n: 1 })
|
||||
})
|
||||
|
||||
it('reports the coarser bucket at each boundary', () => {
|
||||
expect(relativeTime(now - MIN, now)).toEqual({ unit: 'minutes', n: 1 })
|
||||
expect(relativeTime(now - HOUR, now)).toEqual({ unit: 'hours', n: 1 })
|
||||
expect(relativeTime(now - DAY, now)).toEqual({ unit: 'days', n: 1 })
|
||||
expect(relativeTime(now - 30 * DAY, now)).toEqual({ unit: 'months', n: 1 })
|
||||
expect(relativeTime(now - 365 * DAY, now)).toEqual({ unit: 'years', n: 1 })
|
||||
})
|
||||
|
||||
it('reads a future moment as the present rather than a negative distance', () => {
|
||||
expect(relativeTime(now + DAY, now)).toEqual({ unit: 'now', n: 0 })
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-reference/README.md
|
||||
README.md: 7980a68632b8dba45e5a1b9d13b7d4380bec6c22
|
||||
README.zh.md: bd9aad7855cc6fd1bbf9bae05cd0521bafaab7fd
|
||||
README.md: 1f82f4bb30f2ad5d194a70279f9e80885d6a048e
|
||||
README.zh.md: 03a79ec4ff34431a63d8a7b0de115e3ec63b9392
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-client-ui-reference` is the unified Web `@file` and `@session` reference source: it registers the `reference` entry in the composer's inline-suggestion machinery so a user typing `@` sees file and session candidates in one list. Files order before sessions, sections are labelled with locale-registered terms, and either candidate domain can fail independently without blocking the other. A pick inserts an atomic inline reference — file, folder, and session alike — whose hidden serialized and clipboard form is the natural text the shared `@path` grammar defines; a directory row additionally carries a drill verb (Tab or the row's chevron) that keeps plain editable path text and the menu active at its trailing slash so the user can descend another level. Selecting a session routes through the session-reference service, which validates the mention and captures model context at the pre-step boundary; this package itself registers no prompt or tool.
|
||||
`dsh-client-ui-reference` is the unified Web `@file` and `@session` reference source: it registers the `reference` entry in the composer's inline-suggestion machinery so a user typing `@` sees file and session candidates in one list. Files order before sessions, sections are labelled with locale-registered terms, and either candidate domain can fail independently without blocking the other. Each row carries only what distinguishes it: a file names its parent directory and nothing at the workspace root, a session names its workspace only when that workspace is not the current one, and a drilled directory listing names none because its breadcrumb already does. A pick inserts an atomic inline reference — file, folder, and session alike — whose hidden serialized and clipboard form is the natural text the shared `@path` grammar defines; a directory row additionally carries a drill verb (Tab or the row's chevron) that keeps plain editable path text and the menu active at its trailing slash so the user can descend another level. Selecting a session routes through the session-reference service, which validates the mention and captures model context at the pre-step boundary; this package itself registers no prompt or tool.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -49,7 +49,7 @@ The source keeps candidate encoding internal to the registration effect: the `/c
|
||||
|
||||
### Candidate flow
|
||||
|
||||
For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title.
|
||||
For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same 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 creation time. A drilled query publishes a breadcrumb from the workspace root to the directory being listed; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome.
|
||||
|
||||
### Serialization
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-client-ui-reference` 是统一的 Web `@file` 与 `@session` 引用 source:它把 `reference` 条目注册进编辑器的行内建议机制,让用户在输入 `@` 时于同一个列表中看到文件与会话候选。文件排在会话之前,分组标题使用注册在 locale 字典中的标签,任一候选领域失败都会独立降级、不阻塞另一领域。选择一项会插入原子行内引用——文件、文件夹与会话皆然——其隐藏的序列化与剪贴板形式就是共享 `@path` 语法所定义的自然文本;目录行额外携带一个钻取动词(Tab 或行尾 chevron),保持可编辑的路径纯文本并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。选择会话会经 session-reference 服务路由,该服务校验 mention 并在 pre-step 边界捕获模型上下文;本包自身不注册任何提示词或工具。
|
||||
`dsh-client-ui-reference` 是统一的 Web `@file` 与 `@session` 引用 source:它把 `reference` 条目注册进编辑器的行内建议机制,让用户在输入 `@` 时于同一个列表中看到文件与会话候选。文件排在会话之前,分组标题使用注册在 locale 字典中的标签,任一候选领域失败都会独立降级、不阻塞另一领域。每一行只承载能区分它的信息:文件显示其父目录、位于工作区根目录时不显示;会话仅在其工作区不是当前工作区时显示该工作区;下钻后的目录列表不显示位置,因为面包屑已经承载了它。选择一项会插入原子行内引用——文件、文件夹与会话皆然——其隐藏的序列化与剪贴板形式就是共享 `@path` 语法所定义的自然文本;目录行额外携带一个钻取动词(Tab 或行尾 chevron),保持可编辑的路径纯文本并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。选择会话会经 session-reference 服务路由,该服务校验 mention 并在 pre-step 边界捕获模型上下文;本包自身不注册任何提示词或工具。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -49,7 +49,7 @@ kind: "package-reference"
|
||||
|
||||
### 候选流程
|
||||
|
||||
对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。
|
||||
对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前所列目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。
|
||||
|
||||
### 序列化
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-api-session-controller",
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-input-trigger"
|
||||
],
|
||||
@@ -46,23 +48,30 @@
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
* the cancellable generated Remote namespaces in parallel with deterministic
|
||||
* ordering and labels.
|
||||
*
|
||||
* Rows carry only what distinguishes them: a file names its parent directory
|
||||
* (nothing at the workspace root), a directory listing names none because its
|
||||
* breadcrumb already does, and a session names its workspace only when that
|
||||
* workspace is not the current one. A session is dated from the Host session
|
||||
* list, so the `@` menu and the session list never disagree about its age.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-client-ui-reference/client
|
||||
*/
|
||||
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
|
||||
@@ -10,17 +16,22 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
ClientSessionContext, InputTriggerServiceContract, InputTriggerSource,
|
||||
ClientSessionContext, InputTriggerCrumb, InputTriggerServiceContract, InputTriggerSource,
|
||||
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar'
|
||||
import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types'
|
||||
import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types'
|
||||
import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { en, NS, zh, type ReferenceKey } from './locales.ts'
|
||||
|
||||
/** Required services: the trigger registry, the Remote namespaces, and the copy. */
|
||||
export const inject = [
|
||||
'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver',
|
||||
'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences',
|
||||
'remote.sessionReferenceResolver',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -30,34 +41,52 @@ export const inject = [
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries')
|
||||
const t = ctx.locale.bind(NS)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = ctx.get('sessions') as ISessions
|
||||
const source: InputTriggerSource = {
|
||||
trigger: '@',
|
||||
name: 'reference',
|
||||
showGroupTitle: false,
|
||||
async candidates(session: ClientSessionContext, { query, quoted, signal }) {
|
||||
const files = ctx.remote.fileReferences.list(session.sessionId, query, signal).then(
|
||||
async candidates(session: ClientSessionContext, { query, quoted, drilled, signal }) {
|
||||
const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal).then(
|
||||
result => result.ok ? result.value : [],
|
||||
() => [],
|
||||
)
|
||||
const sessions = quoted === true
|
||||
const sessionLookup = quoted === true
|
||||
? Promise.resolve([] as SessionReferenceMentionCandidate[])
|
||||
: ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then(
|
||||
result => result.ok ? result.value : [],
|
||||
() => [],
|
||||
)
|
||||
const [fileItems, sessionItems] = await Promise.all([files, sessions])
|
||||
const [fileItems, sessionItems] = await Promise.all([fileLookup, sessionLookup])
|
||||
if (signal.aborted) return []
|
||||
// The header already names the directory being listed; rows repeat it only
|
||||
// when there is no header to carry it.
|
||||
const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined
|
||||
const now = Date.now()
|
||||
const home = connection.hostDescription.getSnapshot()?.home
|
||||
const listed = sessions.list.getSnapshot().byId
|
||||
return [
|
||||
...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, t)),
|
||||
...sessionItems.map(candidate => sessionCandidate(candidate, t)),
|
||||
...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)),
|
||||
...sessionItems.map(candidate => sessionCandidate(
|
||||
candidate,
|
||||
listed[candidate.sessionId]?.updatedAt ?? candidate.createdAt,
|
||||
now,
|
||||
home,
|
||||
t,
|
||||
)),
|
||||
]
|
||||
},
|
||||
header(_session: ClientSessionContext, req) {
|
||||
return crumbsFor(req.query, req.quoted === true, req.drilled, t)
|
||||
},
|
||||
onPick({ candidate, action }) {
|
||||
const value = parseCandidate(candidate.value)
|
||||
if (value?.kind === 'file') {
|
||||
// A directory row carries two verbs: the settling pick resolves the
|
||||
// folder itself as an atomic reference, while the drill action (Tab /
|
||||
// row chevron) keeps the literal descent text and the open menu.
|
||||
// row chevron / a header crumb) keeps the literal descent text and
|
||||
// the open menu.
|
||||
if (value.fileKind === 'directory' && action === 'drill') {
|
||||
return { text: value.mention, continue: true }
|
||||
}
|
||||
@@ -93,16 +122,71 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => inputTriggers.registerSource(source), 'ui-reference: @ source')
|
||||
}
|
||||
|
||||
type Translate = (key: ReferenceKey) => string
|
||||
type Translate = (key: ReferenceKey, params?: Record<string, unknown>) => string
|
||||
|
||||
type ReferenceCandidateValue =
|
||||
| { kind: 'file'; fileKind: FileReferenceCandidate['kind']; label: string; mention: string }
|
||||
| { kind: 'session'; label: string; mention: string }
|
||||
|
||||
function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean, t: Translate) {
|
||||
/**
|
||||
* The breadcrumb of a drilled directory listing, from the workspace root down
|
||||
* to the directory being listed.
|
||||
*
|
||||
* Only a drill produces one: a path the user typed carries its own context in
|
||||
* the draft, while a drill replaced the text they were reading with a deeper
|
||||
* one and owes them the way back.
|
||||
* @param query - the live query, path text following `@` or `@"`.
|
||||
* @param quoted - whether the active token is an open quoted path.
|
||||
* @param drilled - whether a drill pick, rather than typing, produced the query.
|
||||
* @param t - the reference dictionary.
|
||||
* @returns the crumbs, or undefined when this listing needs no header.
|
||||
*/
|
||||
function crumbsFor(
|
||||
query: string,
|
||||
quoted: boolean,
|
||||
drilled: boolean,
|
||||
t: Translate,
|
||||
): readonly InputTriggerCrumb[] | undefined {
|
||||
if (!drilled) return undefined
|
||||
const slash = query.lastIndexOf('/')
|
||||
if (slash < 0) return undefined
|
||||
const segments = query.slice(0, slash).split('/').filter(segment => segment !== '')
|
||||
const crumbs: InputTriggerCrumb[] = [{
|
||||
label: t('crumb.root'),
|
||||
value: directoryValue(t('crumb.root'), quoted ? '@"' : '@'),
|
||||
}]
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
const path = segments.slice(0, index + 1).join('/')
|
||||
const mention = formatFileMention({ path, kind: 'directory' }, quoted)
|
||||
// A trail whose steps cannot all be written back as mention text would
|
||||
// send the user somewhere they did not click; show no header instead.
|
||||
if (mention === undefined) return undefined
|
||||
crumbs.push({
|
||||
label: segment,
|
||||
value: directoryValue(segment, mention),
|
||||
...(index === segments.length - 1 ? { current: true } : {}),
|
||||
})
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
|
||||
/** Project one directory destination as the drill payload `onPick` already understands. */
|
||||
function directoryValue(label: string, mention: string): string {
|
||||
const value: ReferenceCandidateValue = { kind: 'file', fileKind: 'directory', label, mention }
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function fileCandidate(
|
||||
candidate: FileReferenceCandidate,
|
||||
preserveQuote: boolean,
|
||||
withLocation: boolean,
|
||||
t: Translate,
|
||||
) {
|
||||
const mention = formatFileMention(candidate, preserveQuote)
|
||||
if (mention === undefined) return []
|
||||
const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1)
|
||||
const slash = candidate.path.lastIndexOf('/')
|
||||
const name = candidate.path.slice(slash + 1)
|
||||
const parent = slash < 0 ? '' : candidate.path.slice(0, slash)
|
||||
const directory = candidate.kind === 'directory'
|
||||
const value: ReferenceCandidateValue = {
|
||||
kind: 'file',
|
||||
@@ -112,7 +196,9 @@ function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean
|
||||
}
|
||||
return [{
|
||||
name: `${name}${directory ? '/' : ''}`,
|
||||
description: candidate.path,
|
||||
// The location is the parent alone: repeating the name the row already
|
||||
// shows says nothing, and a workspace-root entry has no parent to name.
|
||||
...(withLocation && parent !== '' ? { description: parent } : {}),
|
||||
icon: directory ? 'folder' as const : 'file' as const,
|
||||
section: t('section.files'),
|
||||
value: JSON.stringify(value),
|
||||
@@ -120,9 +206,20 @@ function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean
|
||||
}]
|
||||
}
|
||||
|
||||
function sessionCandidate(candidate: SessionReferenceMentionCandidate, t: Translate) {
|
||||
const location = candidate.cwd ?? t('candidate.noCwd')
|
||||
const description = `${candidate.label === candidate.sessionId ? '' : `${candidate.sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}`
|
||||
function sessionCandidate(
|
||||
candidate: SessionReferenceMentionCandidate,
|
||||
updatedAt: number,
|
||||
now: number,
|
||||
home: string | undefined,
|
||||
t: Translate,
|
||||
) {
|
||||
const { unit, n } = relativeTime(updatedAt, now)
|
||||
const age = unit === 'now' ? t('time.now') : t(`time.${unit}`, { n })
|
||||
// Candidates are ranked by workspace affinity, so the location only tells
|
||||
// the user something when it is not the workspace they are already in.
|
||||
const location = candidate.sameWorkspace
|
||||
? undefined
|
||||
: candidate.cwd === undefined ? t('candidate.noCwd') : abbreviateHomePath(candidate.cwd, home)
|
||||
const value: ReferenceCandidateValue = {
|
||||
kind: 'session',
|
||||
label: candidate.label,
|
||||
@@ -130,7 +227,7 @@ function sessionCandidate(candidate: SessionReferenceMentionCandidate, t: Transl
|
||||
}
|
||||
return {
|
||||
name: candidate.label,
|
||||
description,
|
||||
description: location === undefined ? age : `${location} · ${age}`,
|
||||
icon: 'session' as const,
|
||||
section: t('section.sessions'),
|
||||
value: JSON.stringify(value),
|
||||
|
||||
@@ -5,11 +5,24 @@ import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'reference'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
/**
|
||||
* Simplified Chinese dictionary (the key-set source of truth).
|
||||
*
|
||||
* The `time.*` bucket words are this namespace's own copy of the session-row
|
||||
* vocabulary: locale-owned copy keeps the words per plugin, while the
|
||||
* bucketing they name is the one shared {@link relativeTime} in ui-primitives.
|
||||
*/
|
||||
export const zh = {
|
||||
'section.files': '文件与文件夹',
|
||||
'section.sessions': '对话',
|
||||
'candidate.noCwd': '(无工作目录)',
|
||||
'crumb.root': '工作区',
|
||||
'time.now': '刚刚',
|
||||
'time.minutes': '{n}分钟',
|
||||
'time.hours': '{n}小时',
|
||||
'time.days': '{n}天',
|
||||
'time.months': '{n}个月',
|
||||
'time.years': '{n}年',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The reference namespace key union. */
|
||||
@@ -27,4 +40,11 @@ export const en = {
|
||||
'section.files': 'Files & folders',
|
||||
'section.sessions': 'Sessions',
|
||||
'candidate.noCwd': '(no cwd)',
|
||||
'crumb.root': 'Workspace',
|
||||
'time.now': 'now',
|
||||
'time.minutes': '{n}min',
|
||||
'time.hours': '{n}h',
|
||||
'time.days': '{n}d',
|
||||
'time.months': '{n}mo',
|
||||
'time.years': '{n}y',
|
||||
} satisfies Record<ReferenceKey, string>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* round-trip, and registration lifecycle.
|
||||
*/
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
@@ -17,6 +17,19 @@ import { apply as nodeApply } from '../src/index.ts'
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
const session: ClientSessionContext = { sessionId: sid('target') }
|
||||
/** The target session's own workspace: candidates in it are the `sameWorkspace` rows. */
|
||||
const HOME = '/Users/dev'
|
||||
const CREATED_AT = 1_700_000_000_000
|
||||
/** Three days after every fixture's createdAt, so age copy is one fixed bucket. */
|
||||
const NOW = CREATED_AT + 3 * 86_400_000
|
||||
/** The Host session list dates a row; only a session missing from it falls back to createdAt. */
|
||||
const UPDATED_AT = NOW - 3_600_000
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
type RemoteEnvelope<T> =
|
||||
| { ok: true; value: T }
|
||||
@@ -36,6 +49,7 @@ function request(
|
||||
query,
|
||||
quoted: options.quoted ?? false,
|
||||
position: 'inline',
|
||||
drilled: false,
|
||||
signal: options.signal ?? new AbortController().signal,
|
||||
}
|
||||
}
|
||||
@@ -53,11 +67,13 @@ async function bench(
|
||||
value: [{
|
||||
sessionId: sid('source'),
|
||||
label: 'Research',
|
||||
cwd: '/project',
|
||||
createdAt: 1_700_000_000_000,
|
||||
cwd: `${HOME}/project`,
|
||||
sameWorkspace: false,
|
||||
createdAt: CREATED_AT,
|
||||
mention: '@[Research](dsh-session:InNvdXJjZSI)',
|
||||
}],
|
||||
})),
|
||||
listed: Record<string, { updatedAt: number }> = {},
|
||||
): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']>; source: InputTriggerSource }> {
|
||||
const ctx = new Context()
|
||||
let source: InputTriggerSource | undefined
|
||||
@@ -76,6 +92,8 @@ async function bench(
|
||||
ctx.provide('remote.fileReferences', { list: files })
|
||||
ctx.provide('remote.sessionReferenceResolver', { candidates: sessions })
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
ctx.provide('connection', { hostDescription: { getSnapshot: () => ({ home: HOME }) } })
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
if (source === undefined) throw new Error('reference source was not registered')
|
||||
@@ -85,7 +103,8 @@ async function bench(
|
||||
describe('apply', () => {
|
||||
it('declares its services and releases the @ reference registration on disposal', async () => {
|
||||
expect(inject).toEqual([
|
||||
'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver',
|
||||
'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences',
|
||||
'remote.sessionReferenceResolver',
|
||||
])
|
||||
const { fiber } = await bench()
|
||||
let registered: InputTriggerSource | undefined
|
||||
@@ -105,6 +124,8 @@ describe('apply', () => {
|
||||
ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) })
|
||||
ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) })
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined } })
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } })
|
||||
const ownFiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await ownFiber.await()
|
||||
expect(registered).toMatchObject({ trigger: '@', name: 'reference', showGroupTitle: false })
|
||||
@@ -142,6 +163,7 @@ describe('candidates', () => {
|
||||
sessionId: SessionId
|
||||
label: string
|
||||
cwd: string
|
||||
sameWorkspace: boolean
|
||||
createdAt: number
|
||||
mention: string
|
||||
}[]
|
||||
@@ -152,35 +174,37 @@ describe('candidates', () => {
|
||||
value: [{
|
||||
sessionId: sid('source'),
|
||||
label: 'Research',
|
||||
cwd: '/project',
|
||||
createdAt: 1_700_000_000_000,
|
||||
cwd: `${HOME}/project`,
|
||||
sameWorkspace: false,
|
||||
createdAt: CREATED_AT,
|
||||
mention: '@[Research](dsh-session:InNvdXJjZSI)',
|
||||
}],
|
||||
})
|
||||
}
|
||||
}))
|
||||
const { source } = await bench(files, sessions)
|
||||
const { source } = await bench(files, sessions, { source: { updatedAt: UPDATED_AT } })
|
||||
const pending = source.candidates(session, request('re'))
|
||||
expect(files).toHaveBeenCalledTimes(1)
|
||||
expect(sessions).toHaveBeenCalledTimes(1)
|
||||
releaseSessions()
|
||||
releaseFiles()
|
||||
await expect(pending).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
{
|
||||
name: 'src/',
|
||||
description: 'src',
|
||||
icon: 'folder',
|
||||
section: 'Files & folders',
|
||||
}),
|
||||
value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'src', mention: '@src/' }),
|
||||
drill: true,
|
||||
},
|
||||
expect.objectContaining({
|
||||
name: 'a b.md',
|
||||
description: 'docs/a b.md',
|
||||
description: 'docs',
|
||||
icon: 'file',
|
||||
section: 'Files & folders',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'Research',
|
||||
description: 'source · /project · 2023-11-14T22:13:20.000Z',
|
||||
description: '~/project · 1h',
|
||||
icon: 'session',
|
||||
section: 'Sessions',
|
||||
}),
|
||||
@@ -199,8 +223,9 @@ describe('candidates', () => {
|
||||
value: [{
|
||||
sessionId: sid('source'),
|
||||
label: 'Research',
|
||||
cwd: '/project',
|
||||
createdAt: 0,
|
||||
cwd: `${HOME}/project`,
|
||||
sameWorkspace: false,
|
||||
createdAt: CREATED_AT,
|
||||
mention: '@[Research](dsh-session:InNvdXJjZSI)',
|
||||
}],
|
||||
}))
|
||||
@@ -258,14 +283,15 @@ describe('candidates', () => {
|
||||
await expect(source.candidates(session, request('bad'))).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('omits redundant session ids and labels sessions without a cwd', async () => {
|
||||
it('labels a session without a cwd and still dates it', async () => {
|
||||
const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
|
||||
const sessions = vi.fn(() => Promise.resolve({
|
||||
ok: true as const,
|
||||
value: [{
|
||||
sessionId: sid('same'),
|
||||
label: 'same',
|
||||
createdAt: 0,
|
||||
sameWorkspace: false,
|
||||
createdAt: CREATED_AT,
|
||||
mention: '@[same](dsh-session:InNhbWUi)',
|
||||
}],
|
||||
}))
|
||||
@@ -273,10 +299,140 @@ describe('candidates', () => {
|
||||
await expect(source.candidates(session, request('same'))).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'same',
|
||||
description: '(no cwd) · 1970-01-01T00:00:00.000Z',
|
||||
description: '(no cwd) · 3d',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the candidate createdAt for a session the Host list does not carry', async () => {
|
||||
const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
|
||||
const sessions = vi.fn(() => Promise.resolve({
|
||||
ok: true as const,
|
||||
value: [{
|
||||
sessionId: sid('unlisted'),
|
||||
label: 'Unlisted run',
|
||||
cwd: `${HOME}/project`,
|
||||
sameWorkspace: true,
|
||||
createdAt: CREATED_AT,
|
||||
mention: '@[Unlisted run](dsh-session:InVubGlzdGVkIg)',
|
||||
}],
|
||||
}))
|
||||
// A row absent from the list has no durable activity time to read.
|
||||
const { source } = await bench(files, sessions, { other: { updatedAt: UPDATED_AT } })
|
||||
await expect(source.candidates(session, request('unlisted'))).resolves.toEqual([
|
||||
expect.objectContaining({ name: 'Unlisted run', description: '3d' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('reads a session opened moments ago as the present, not a zero distance', async () => {
|
||||
const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
|
||||
const sessions = vi.fn(() => Promise.resolve({
|
||||
ok: true as const,
|
||||
value: [{
|
||||
sessionId: sid('just-now'),
|
||||
label: 'Just now',
|
||||
cwd: `${HOME}/project`,
|
||||
sameWorkspace: true,
|
||||
createdAt: NOW - 1_000,
|
||||
mention: '@[Just now](dsh-session:Imp1c3Qtbm93Ig)',
|
||||
}],
|
||||
}))
|
||||
const { source } = await bench(files, sessions, { 'just-now': { updatedAt: NOW - 1_000 } })
|
||||
await expect(source.candidates(session, request('just'))).resolves.toEqual([
|
||||
expect.objectContaining({ name: 'Just now', description: 'now' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('dates a session in the current workspace without repeating that workspace', async () => {
|
||||
const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
|
||||
const sessions = vi.fn(() => Promise.resolve({
|
||||
ok: true as const,
|
||||
value: [{
|
||||
sessionId: sid('sibling'),
|
||||
label: 'Sibling run',
|
||||
cwd: `${HOME}/project`,
|
||||
sameWorkspace: true,
|
||||
createdAt: CREATED_AT,
|
||||
mention: '@[Sibling run](dsh-session:InNpYmxpbmdyIg)',
|
||||
}],
|
||||
}))
|
||||
const { source } = await bench(files, sessions)
|
||||
await expect(source.candidates(session, request('sib'))).resolves.toEqual([
|
||||
expect.objectContaining({ name: 'Sibling run', description: '3d' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('directory header', () => {
|
||||
const drilledRequest = (query: string, quoted = false): CandidateRequest => ({
|
||||
query,
|
||||
quoted,
|
||||
position: 'inline',
|
||||
drilled: true,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
|
||||
it('publishes no header for a query the user typed', async () => {
|
||||
const { source } = await bench()
|
||||
expect(source.header?.(session, { query: 'src/module1/', drilled: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('publishes no header until a drilled query names a directory', async () => {
|
||||
const { source } = await bench()
|
||||
expect(source.header?.(session, { query: 'src', drilled: true })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('trails the workspace root down to the directory being listed', async () => {
|
||||
const { source } = await bench()
|
||||
expect(source.header?.(session, { query: 'src/module1/ind', drilled: true })).toEqual([
|
||||
{ label: 'Workspace', value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'Workspace', mention: '@' }) },
|
||||
{ label: 'src', value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'src', mention: '@src/' }) },
|
||||
{
|
||||
label: 'module1',
|
||||
value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'module1', mention: '@src/module1/' }),
|
||||
current: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps an open quote across every crumb of a quoted descent', async () => {
|
||||
const { source } = await bench()
|
||||
const crumbs = source.header?.(session, { query: 'my dir/sub/', quoted: true, drilled: true })
|
||||
expect(crumbs?.map(crumb => JSON.parse(crumb.value) as { mention: string }).map(value => value.mention))
|
||||
.toEqual(['@"', '@"my dir/', '@"my dir/sub/'])
|
||||
})
|
||||
|
||||
it('publishes no header when a segment cannot be written back as mention text', async () => {
|
||||
const { source } = await bench()
|
||||
expect(source.header?.(session, { query: 'ok/we\u0001ird/', drilled: true })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns to a crumb through the same drill outcome a folder row uses', async () => {
|
||||
const { source } = await bench()
|
||||
const crumbs = source.header?.(session, { query: 'src/module1/', drilled: true })
|
||||
expect(source.onPick({
|
||||
candidate: { name: 'src', value: crumbs?.[1]?.value ?? '' },
|
||||
session,
|
||||
position: 'inline',
|
||||
via: 'menu',
|
||||
action: 'drill',
|
||||
span: { start: 0, end: 13, draftRev: 1 },
|
||||
})).toEqual({ text: '@src/', continue: true })
|
||||
})
|
||||
|
||||
it('drops the row location a drilled listing already shows in its header', async () => {
|
||||
const files = vi.fn(() => Promise.resolve({
|
||||
ok: true as const,
|
||||
value: [{ path: 'src/module1/index.html', kind: 'file' as const }],
|
||||
}))
|
||||
const sessions = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
|
||||
const { source } = await bench(files, sessions)
|
||||
await expect(source.candidates(session, drilledRequest('src/module1/'))).resolves.toEqual([
|
||||
expect.objectContaining({ name: 'index.html', icon: 'file' }),
|
||||
])
|
||||
const [row] = await source.candidates(session, drilledRequest('src/module1/'))
|
||||
expect(row).not.toHaveProperty('description')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick and codec', () => {
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/session-controller/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../context/file-reference"
|
||||
},
|
||||
@@ -26,12 +29,21 @@
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../util/workspace-path"
|
||||
},
|
||||
{
|
||||
"path": "../connection/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../ui-input-trigger"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ const sid = (id: string) => id as SessionId
|
||||
const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) })
|
||||
|
||||
const req = (query: string, signal?: AbortSignal) =>
|
||||
({ query, position: 'leading' as const, signal: signal ?? new AbortController().signal })
|
||||
({ query, position: 'leading' as const, drilled: false, signal: signal ?? new AbortController().signal })
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
|
||||
@@ -10,13 +10,12 @@ import clsx from 'clsx'
|
||||
import {
|
||||
HoverCard, IconArchiveOutline20, IconBranchOutline16, IconEditOutline16,
|
||||
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
|
||||
IconTrashOutline16, IconTriangleRightFill14, Menu, relativeTime, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
|
||||
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
|
||||
import { relativeTime } from '../tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
|
||||
/** The standard locale seat, prop-passed from the browser root. */
|
||||
|
||||
@@ -325,15 +325,6 @@ export function deriveFlat(
|
||||
return rows.map(session => sessionNode(session, descendants, pendingInteractions))
|
||||
}
|
||||
|
||||
/** Relative-time bucket of a session row's trailing label. */
|
||||
export type RelativeTimeUnit = 'now' | 'minutes' | 'hours' | 'days' | 'months' | 'years'
|
||||
|
||||
/** Structured relative time: the bucket plus its magnitude (0 for 'now'). */
|
||||
export interface RelativeTime {
|
||||
unit: RelativeTimeUnit
|
||||
n: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge immediate title/Workspace substring matches with ranked Host content
|
||||
* matches. Local rows lead newest-first, content-only rows retain backend
|
||||
@@ -422,23 +413,3 @@ export function deriveSearchResults(
|
||||
hasMore: content.hasMore || ordered.length > limit,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative time for session rows, as a structured bucket the
|
||||
* renderer localizes ("now"/"5min"/"3h"/"2d"/"4mo"/"1y" in en).
|
||||
* @param updatedAt - epoch ms of the session's last activity.
|
||||
* @param now - current epoch ms (injected for pure rendering).
|
||||
* @returns the row's trailing time bucket and magnitude.
|
||||
*/
|
||||
export function relativeTime(updatedAt: number, now: number): RelativeTime {
|
||||
const MIN = 60_000
|
||||
const HOUR = 3_600_000
|
||||
const DAY = 86_400_000
|
||||
const diff = Math.max(0, now - updatedAt)
|
||||
if (diff < MIN) return { unit: 'now', n: 0 }
|
||||
if (diff < HOUR) return { unit: 'minutes', n: Math.floor(diff / MIN) }
|
||||
if (diff < DAY) return { unit: 'hours', n: Math.floor(diff / HOUR) }
|
||||
if (diff < 30 * DAY) return { unit: 'days', n: Math.floor(diff / DAY) }
|
||||
if (diff < 365 * DAY) return { unit: 'months', n: Math.floor(diff / (30 * DAY)) }
|
||||
return { unit: 'years', n: Math.floor(diff / (365 * DAY)) }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-
|
||||
import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, relativeTime,
|
||||
deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel,
|
||||
UNGROUPED_KEY,
|
||||
} from '../src/client/tree.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
@@ -486,15 +486,3 @@ describe('workspaceLabel', () => {
|
||||
expect(workspaceLabel('/')).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relativeTime', () => {
|
||||
it('buckets current, minute, hour, day, month, and year distances', () => {
|
||||
const now = 400 * 24 * 60 * 60 * 1_000
|
||||
expect(relativeTime(now, now)).toEqual({ unit: 'now', n: 0 })
|
||||
expect(relativeTime(now - 5 * 60_000, now)).toEqual({ unit: 'minutes', n: 5 })
|
||||
expect(relativeTime(now - 3 * 3_600_000, now)).toEqual({ unit: 'hours', n: 3 })
|
||||
expect(relativeTime(now - 2 * 86_400_000, now)).toEqual({ unit: 'days', n: 2 })
|
||||
expect(relativeTime(now - 60 * 86_400_000, now)).toEqual({ unit: 'months', n: 2 })
|
||||
expect(relativeTime(0, now)).toEqual({ unit: 'years', n: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/context/file-reference-local/README.md
|
||||
README.md: a05ff98d8bd5c1cadaa3759d248712400f09b723
|
||||
README.zh.md: df9950e4f473e35b691758c107ecd06ab48e6ce7
|
||||
README.md: da5db0882e9f4538bdd901a970eca98aac10bbde
|
||||
README.zh.md: cea38d335c8b29ddc3c875782cb62df1d0ae6e0d
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Agents and their host UIs get ranked path candidates for `@file` mentions, scoped to each agent's workspace and bounded so even large repositories stay responsive. `dsh-file-reference-local` implements `ctx.fileReferences` for the local filesystem: it keeps one reusable search index per agent, invalidates it after tool results so completion reflects workspace changes, and never follows directory symlinks. When the addressed agent can call `read`, it also installs a stable one-sentence guidance into the system prompt. Choose it when the agent's `read` tool operates on the Harness host filesystem; remote or virtual namespaces need a provider whose discovery matches the tool.
|
||||
Agents and their host UIs get ranked path candidates for `@file` mentions, scoped to each agent's workspace and bounded so even large repositories stay responsive. `dsh-file-reference-local` implements `ctx.fileReferences` for the local filesystem: it keeps one reusable search index per agent, rebuilds it in the background after tool results so completion reflects workspace changes without stalling, and never follows directory symlinks. When the addressed agent can call `read`, it also installs a stable one-sentence guidance into the system prompt. Choose it when the agent's `read` tool operates on the Harness host filesystem; remote or virtual namespaces need a provider whose discovery matches the tool.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -39,15 +39,15 @@ The defaults suit a typical workspace, so the minimal mount needs no configurati
|
||||
|
||||
### What you get
|
||||
|
||||
Typing `@` in a host UI returns up to `maxResults` ranked path candidates for the addressed agent. A query containing `/` lists the matching directory's entries directly; a bare query fuzzy-ranks the bounded recursive index. Directory candidates keep the mention open with a trailing slash. After any tool result, the agent's reusable index is invalidated, so later completion observes workspace mutations; an unchanged agent keeps its index across queries.
|
||||
Typing `@` in a host UI returns up to `maxResults` ranked path candidates for the addressed agent. A query containing `/` lists the matching directory's entries directly; a bare query fuzzy-ranks the bounded recursive index. Directory candidates keep the mention open with a trailing slash. After any tool result the agent's index is marked stale: the next query still answers from it and its replacement builds in the background, so a rebuild never sits in front of the caret.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `maxResults` | `20` | Maximum ranked candidates returned for one query |
|
||||
| `maxEntries` | `10000` | Maximum files and directories indexed per agent workspace |
|
||||
| `excludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and candidates |
|
||||
| `maxEntries` | `50000` | Maximum files and directories indexed per agent workspace |
|
||||
| `excludedDirectories` | `['.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | Directory basenames omitted from traversal and candidates |
|
||||
|
||||
Every numeric value must be a positive safe integer, and every excluded name must be a non-empty basename without `/` or `\`.
|
||||
|
||||
@@ -63,19 +63,19 @@ This section explains the design of the provider; the observable behavior is cov
|
||||
|
||||
### Design concept
|
||||
|
||||
The provider maintains one reusable `WorkspaceFileSearch` per agent, rooted at that session's `cwd`. Directory-scoped queries (`a/b/...`) list live directory state, while bare fuzzy queries share one bounded recursive traversal until the `@` interaction ends or a `tool/result` event invalidates it. The model guidance is a per-agent prompt section contributed only while the addressed agent has a `read` tool; agent disposal releases both the index and the prompt fiber.
|
||||
The provider maintains one reusable `WorkspaceFileSearch` per agent, rooted at that session's `cwd`. Directory-scoped queries (`a/b/...`) list live directory state, while bare fuzzy queries share one bounded recursive traversal. Only a workspace's first bare query waits for that traversal; a `tool/result` event marks the settled entries stale, and the next bare query serves them while the replacement builds. The model guidance is a per-agent prompt section contributed only while the addressed agent has a `read` tool; agent disposal releases both the index and the prompt fiber.
|
||||
|
||||
### Source map
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | `LocalFileReferenceService`: config validation, per-agent searches, prompt install |
|
||||
| [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`: traversal, ranking, exclusion, invalidation |
|
||||
| [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`: traversal, ranking, exclusion, staleness and background rebuild |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the discovery contract |
|
||||
|
||||
### Main flow
|
||||
|
||||
A `list(agent, query, signal)` call either lists one directory's entries or waits on the shared bounded index, ranks the candidates (exact, prefix, substring, then subsequence scores with directory bonuses), and returns at most `maxResults` in deterministic order. `tool/result` events invalidate the addressed agent's index so the next bare query observes a fresh tree; unreadable or excluded subtrees contribute no candidates.
|
||||
A `list(agent, query, signal)` call either lists one directory's entries or reads the shared bounded index, ranks the candidates (exact, prefix, substring, then subsequence scores with directory bonuses), and returns at most `maxResults` in deterministic order. `tool/result` events mark the addressed agent's index stale so a later bare query observes a fresh tree. An unreadable or excluded subtree contributes no candidates, while an unreadable root fails its traversal instead: a transient failure must not replace still-good entries with an empty index.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -114,7 +114,7 @@ Conditional and fixed: the one sentence is present while `read` is visible to th
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The stable sentence joins the system-prompt prefix. Mounting or removing this provider, or changing whether `read` is visible, changes that prefix; queries, candidates, and index invalidations do not.
|
||||
The stable sentence joins the system-prompt prefix. Mounting or removing this provider, or changing whether `read` is visible, changes that prefix; queries, candidates, and index staleness do not.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
@@ -124,7 +124,8 @@ The stable sentence joins the system-prompt prefix. Mounting or removing this pr
|
||||
These limits define when the provider is a poor fit. They are current package constraints.
|
||||
|
||||
- **Host-local namespace** — the provider scans the Harness host filesystem, so remote or virtual `read` implementations require a provider whose namespace matches the tool.
|
||||
- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear.
|
||||
- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. The default exclusions name only build outputs no ecosystem also uses for sources; `lib` is deliberately absent, so a workspace that builds into it adds that name through `excludedDirectories`.
|
||||
- **One invalidation of staleness** — a bare query answered right after a tool result reflects the tree as of the previous traversal; the following query sees the rebuild.
|
||||
- **No ignore-file semantics** — `.gitignore` and other project ignore files do not influence discovery; only configured directory basenames are excluded.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选,范围限定在各自 agent 的工作区,并有界以保证大型仓库依然响应迅速。`dsh-file-reference-local` 在本地文件系统上实现 `ctx.fileReferences`:它为每个 agent 维护一个可复用的搜索索引,在工具结果后使索引失效,让补全反映工作区变化,且从不跟随目录符号链接。当指定 agent 可以调用 `read` 时,它还会向系统提示词安装一句稳定指引。当 agent 的 `read` 工具作用于 Harness 宿主文件系统时选择它;远程或虚拟命名空间需要发现能力与工具一致的提供方。
|
||||
agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选,范围限定在各自 agent 的工作区,并有界以保证大型仓库依然响应迅速。`dsh-file-reference-local` 在本地文件系统上实现 `ctx.fileReferences`:它为每个 agent 维护一个可复用的搜索索引,在工具结果后于后台重建索引,让补全反映工作区变化而不发生停顿,且从不跟随目录符号链接。当指定 agent 可以调用 `read` 时,它还会向系统提示词安装一句稳定指引。当 agent 的 `read` 工具作用于 Harness 宿主文件系统时选择它;远程或虚拟命名空间需要发现能力与工具一致的提供方。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -39,15 +39,15 @@ agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选
|
||||
|
||||
### 你能得到什么
|
||||
|
||||
在宿主 UI 中输入 `@` 会为指定 agent 返回至多 `maxResults` 个排序路径候选。包含 `/` 的查询直接列出匹配目录的条目;裸查询对有界递归索引做模糊排序。目录候选以尾斜杠保持 mention 开放。任何工具结果之后,该 agent 的可复用索引都会失效,后续补全因此能观察到工作区变化;未变化的 agent 会在多次查询间保留其索引。
|
||||
在宿主 UI 中输入 `@` 会为指定 agent 返回至多 `maxResults` 个排序路径候选。包含 `/` 的查询直接列出匹配目录的条目;裸查询对有界递归索引做模糊排序。目录候选以尾斜杠保持 mention 开放。任何工具结果之后,该 agent 的索引会被标记为陈旧:下一次查询仍由它作答,其替代品在后台构建,因此重建不会挡在光标前面。
|
||||
|
||||
### 配置
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `maxResults` | `20` | 单次查询返回的排序候选最大数量 |
|
||||
| `maxEntries` | `10000` | 每个 agent 工作区建立索引的文件与目录最大数量 |
|
||||
| `excludedDirectories` | `['.git', 'node_modules']` | 遍历与候选中排除的目录基名 |
|
||||
| `maxEntries` | `50000` | 每个 agent 工作区建立索引的文件与目录最大数量 |
|
||||
| `excludedDirectories` | `['.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | 遍历与候选中排除的目录基名 |
|
||||
|
||||
所有数值都必须是正的安全整数,所有排除名都必须是不含 `/` 或 `\` 的非空基名。
|
||||
|
||||
@@ -63,19 +63,19 @@ agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选
|
||||
|
||||
### 设计理念
|
||||
|
||||
提供方为每个 agent 维护一个可复用的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根。目录范围查询(`a/b/...`)列出实时目录状态,裸模糊查询共享一次有界递归遍历,直到 `@` 交互结束或 `tool/result` 事件使其失效。模型指引是按 agent 的提示词段,仅在指定 agent 拥有 `read` 工具时贡献;agent 释放时会同时释放索引与提示词 fiber。
|
||||
提供方为每个 agent 维护一个可复用的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根。目录范围查询(`a/b/...`)列出实时目录状态,裸模糊查询共享一次有界递归遍历。只有一个工作区的首次裸查询会等待该遍历;`tool/result` 事件把已完成的条目标记为陈旧,下一次裸查询在替代品构建期间继续由它作答。模型指引是按 agent 的提示词段,仅在指定 agent 拥有 `read` 工具时贡献;agent 释放时会同时释放索引与提示词 fiber。
|
||||
|
||||
### 源码地图
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | `LocalFileReferenceService`:配置校验、按 agent 搜索、提示词安装 |
|
||||
| [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`:遍历、排序、排除、失效 |
|
||||
| [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`:遍历、排序、排除、陈旧标记与后台重建 |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | 发现约定的不变式伴生插件 |
|
||||
|
||||
### 主要流程
|
||||
|
||||
`list(agent, query, signal)` 要么列出某个目录的条目,要么等待共享的有界索引,对候选排序(精确、前缀、子串,再到子序列得分,目录有加成),并按确定性顺序返回至多 `maxResults` 个。`tool/result` 事件使指定 agent 的索引失效,下一次裸查询因此观察到全新目录树;不可读或已排除的子目录不贡献候选。
|
||||
`list(agent, query, signal)` 要么列出某个目录的条目,要么读取共享的有界索引,对候选排序(精确、前缀、子串,再到子序列得分,目录有加成),并按确定性顺序返回至多 `maxResults` 个。`tool/result` 事件把指定 agent 的索引标记为陈旧,之后的裸查询因此观察到全新目录树。不可读或已排除的子目录不贡献候选,而不可读的根目录则让该次遍历失败:一次瞬时故障不得用空索引覆盖仍然有效的条目。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -114,7 +114,7 @@ Tokens prefixed with @ are workspace paths the user explicitly referenced, relat
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引失效不会改变前缀。
|
||||
该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引陈旧标记不会改变前缀。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
@@ -124,7 +124,8 @@ Tokens prefixed with @ are workspace paths the user explicitly referenced, relat
|
||||
这些限制说明该提供方何时不合适。它们是当前包约束。
|
||||
|
||||
- **宿主本地命名空间**:提供方扫描 Harness 宿主的文件系统,因此远程或虚拟 `read` 实现需要使用命名空间与该工具一致的提供方。
|
||||
- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。
|
||||
- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。默认排除项只列没有任何生态用作源码目录的构建产物;`lib` 被刻意排除在外,因此构建进 `lib` 的工作区需通过 `excludedDirectories` 自行加上。
|
||||
- **一次失效的陈旧窗口**:紧接工具结果之后的模糊查询反映的是上一次遍历时的目录树;下一次查询才看到重建结果。
|
||||
- **没有忽略文件语义**:`.gitignore` 和其他项目忽略文件不会影响发现;系统只排除已配置的目录基名。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -15,9 +15,36 @@ export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-referenc
|
||||
/** Default maximum file and directory candidates rendered for one query. */
|
||||
export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20
|
||||
/** Default maximum entries retained in one workspace search index. */
|
||||
export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000
|
||||
/** Directory basenames omitted from traversal unless the deployment overrides them. */
|
||||
export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const
|
||||
export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 50_000
|
||||
/**
|
||||
* Directory basenames omitted from traversal unless the deployment overrides
|
||||
* them: version-control and dependency stores plus build-output names that no
|
||||
* ecosystem also uses for sources. Generated files carry the basenames of the
|
||||
* sources that produced them, so an unfiltered tree both spends the entry
|
||||
* budget twice and ranks `dist/x.js` beside `src/x.ts` for every query.
|
||||
*
|
||||
* `lib` is deliberately absent: Ruby gems and many npm packages keep their
|
||||
* sources there, and excluding it would make `@` miss those sources entirely
|
||||
* and silently. A workspace that builds into `lib` adds it through
|
||||
* `excludedDirectories`.
|
||||
*/
|
||||
export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = [
|
||||
'.git',
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'coverage',
|
||||
'target',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.mypy_cache',
|
||||
'.gradle',
|
||||
] as const
|
||||
|
||||
/** Resolved limits and exclusions for one workspace index. */
|
||||
export interface FileSearchConfig {
|
||||
@@ -41,14 +68,25 @@ interface IndexGeneration {
|
||||
promise: Promise<IndexedPath[]>
|
||||
}
|
||||
|
||||
/** A completed traversal and the invalidation counter it observed at its start. */
|
||||
interface SettledIndex {
|
||||
entries: IndexedPath[]
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellable, reusable fuzzy index rooted at one agent working directory.
|
||||
* Directory-scoped queries list live state; bare fuzzy queries share one
|
||||
* bounded traversal until the `@` interaction ends or a tool result invalidates it.
|
||||
* bounded traversal. Only the first query of a workspace waits for that
|
||||
* traversal — an invalidated index keeps answering while its replacement
|
||||
* builds behind the caret.
|
||||
*/
|
||||
export class WorkspaceFileSearch {
|
||||
private readonly excludedDirectories: ReadonlySet<string>
|
||||
private settled: SettledIndex | undefined
|
||||
private generation: IndexGeneration | undefined
|
||||
/** Monotonic invalidation counter; a settled index below it is stale. */
|
||||
private invalidations = 0
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
@@ -83,7 +121,7 @@ export class WorkspaceFileSearch {
|
||||
const fragment = slash < 0 ? '' : query.slice(slash + 1)
|
||||
return this.listDirectory(directory, fragment, signal)
|
||||
}
|
||||
const indexed = await waitForPromise(this.ensureIndex(), signal)
|
||||
const indexed = await this.indexFor(signal)
|
||||
return rankCandidates(
|
||||
indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)),
|
||||
query,
|
||||
@@ -91,31 +129,71 @@ export class WorkspaceFileSearch {
|
||||
)
|
||||
}
|
||||
|
||||
/** Discard the current index so the next bare query observes a fresh tree. */
|
||||
/**
|
||||
* Mark the index stale so a later bare query observes a fresh tree.
|
||||
*
|
||||
* The stale entries are kept and keep answering: a rebuild costs one
|
||||
* traversal of the whole workspace, and putting that in front of the caret
|
||||
* is what a caller invalidating on every tool result would otherwise pay.
|
||||
*/
|
||||
invalidate(): void {
|
||||
this.generation?.controller.abort(new Error('file search index invalidated'))
|
||||
this.generation = undefined
|
||||
this.invalidations += 1
|
||||
}
|
||||
|
||||
/** Abort traversal and make later queries return no candidates. */
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.invalidate()
|
||||
this.generation?.controller.abort(new Error('file search index disposed'))
|
||||
this.generation = undefined
|
||||
this.settled = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries a bare fuzzy query ranks. Only the first query of a workspace
|
||||
* waits for a traversal; afterwards a stale index answers immediately and
|
||||
* its replacement builds in the background.
|
||||
* @param signal - cancels this caller's wait without killing a shared traversal.
|
||||
* @returns indexed paths, at most one invalidation behind the tree.
|
||||
*/
|
||||
private async indexFor(signal: AbortSignal): Promise<readonly IndexedPath[]> {
|
||||
const settled = this.settled
|
||||
if (settled === undefined) return waitForPromise(this.ensureIndex(), signal)
|
||||
if (settled.startedAt < this.invalidations) {
|
||||
void this.ensureIndex().catch(() => {
|
||||
// A background refresh failure is not this caller's error: the stale
|
||||
// entries still answer and `settled.startedAt` stays behind, so the
|
||||
// next bare query starts a fresh attempt.
|
||||
})
|
||||
}
|
||||
return settled.entries
|
||||
}
|
||||
|
||||
private ensureIndex(): Promise<IndexedPath[]> {
|
||||
if (this.generation !== undefined) return this.generation.promise
|
||||
const controller = new AbortController()
|
||||
const startedAt = this.invalidations
|
||||
const generation = {
|
||||
controller,
|
||||
promise: Promise.resolve([] as IndexedPath[]),
|
||||
} satisfies IndexGeneration
|
||||
generation.promise = this.scanWorkspace(controller.signal).catch((error: unknown) => {
|
||||
/* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */
|
||||
if (this.generation === generation) this.generation = undefined
|
||||
throw error
|
||||
})
|
||||
generation.promise = this.scanWorkspace(controller.signal).then(
|
||||
(entries) => {
|
||||
/* v8 ignore next -- disposal aborts this traversal, so it reaches the
|
||||
* rejection handler instead; the guard only covers a scan that finished
|
||||
* its last directory in the instant before the abort landed, and must
|
||||
* not hand a disposed index its entries back. */
|
||||
if (this.disposed) return entries
|
||||
this.generation = undefined
|
||||
this.settled = { entries, startedAt }
|
||||
return entries
|
||||
},
|
||||
(error: unknown) => {
|
||||
/* v8 ignore next -- dispose clears `generation` synchronously; this only protects an unexpected scan failure */
|
||||
if (this.generation === generation) this.generation = undefined
|
||||
throw error
|
||||
},
|
||||
)
|
||||
this.generation = generation
|
||||
return generation.promise
|
||||
}
|
||||
@@ -130,7 +208,13 @@ export class WorkspaceFileSearch {
|
||||
if (directory === undefined) {
|
||||
throw new Error('file search selected a missing directory')
|
||||
}
|
||||
const entries = await readDirectory(directory.absolute, signal)
|
||||
// The root is not a subtree: an unreadable branch costs its own
|
||||
// candidates, but an unreadable root means the traversal learned
|
||||
// nothing. Letting that settle would publish an empty index over
|
||||
// entries that are still good and leave no invalidation to retry from.
|
||||
const entries = cursor === 0
|
||||
? await readWorkspaceRoot(directory.absolute, signal)
|
||||
: await readDirectory(directory.absolute, signal)
|
||||
for (const entry of entries) {
|
||||
signal.throwIfAborted()
|
||||
const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}`
|
||||
@@ -197,6 +281,13 @@ async function resolveDisplayDirectory(
|
||||
return absolute
|
||||
}
|
||||
|
||||
async function readWorkspaceRoot(absolute: string, signal: AbortSignal) {
|
||||
signal.throwIfAborted()
|
||||
const entries = await readdir(absolute, { withFileTypes: true })
|
||||
signal.throwIfAborted()
|
||||
return entries.sort((left, right) => compareText(left.name, right.name))
|
||||
}
|
||||
|
||||
async function readDirectory(absolute: string, signal: AbortSignal) {
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
activeAtToken,
|
||||
DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
|
||||
formatFileMention,
|
||||
WorkspaceFileSearch,
|
||||
} from '../src/search.ts'
|
||||
|
||||
const searches: WorkspaceFileSearch[] = []
|
||||
const roots: string[] = []
|
||||
/** Permission-stripped directories; restored before cleanup can remove them. */
|
||||
const locks: string[] = []
|
||||
|
||||
async function workspace(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-'))
|
||||
@@ -44,6 +47,7 @@ function search(root: string, overrides: Partial<ConstructorParameters<typeof Wo
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const locked of locks.splice(0)) await chmod(locked, 0o700).catch(() => undefined)
|
||||
for (const instance of searches.splice(0)) instance.dispose()
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
@@ -150,27 +154,97 @@ describe('WorkspaceFileSearch', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('invalidates cached traversal, enforces the entry cap, and settles disposal', async () => {
|
||||
it('serves an invalidated index while its replacement builds, then swaps it in', async () => {
|
||||
const root = await workspace()
|
||||
const capped = search(root, { maxEntries: 2 })
|
||||
const signal = new AbortController().signal
|
||||
expect(await capped.list('README', signal)).toEqual([
|
||||
{ path: 'README.md', kind: 'file' },
|
||||
])
|
||||
|
||||
const files = search(root)
|
||||
const signal = new AbortController().signal
|
||||
expect(await files.list('fresh-file', signal)).toEqual([])
|
||||
await writeFile(join(root, 'fresh-file.ts'), 'fresh')
|
||||
// No invalidation: the settled traversal is still the answer.
|
||||
expect(await files.list('fresh-file', signal)).toEqual([])
|
||||
files.invalidate()
|
||||
expect(await files.list('fresh-file', signal)).toEqual([
|
||||
{ path: 'fresh-file.ts', kind: 'file' },
|
||||
])
|
||||
// The stale entries answer this query; the rebuild runs behind the caret.
|
||||
expect(await files.list('fresh-file', signal)).toEqual([])
|
||||
await vi.waitFor(async () => {
|
||||
expect(await files.list('fresh-file', signal)).toEqual([
|
||||
{ path: 'fresh-file.ts', kind: 'file' },
|
||||
])
|
||||
})
|
||||
files.dispose()
|
||||
expect(await files.list('fresh-file', signal)).toEqual([])
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('keeps the stale entries when the workspace root is unreadable, and retries once it returns', async () => {
|
||||
const root = await workspace()
|
||||
const files = search(root)
|
||||
const signal = new AbortController().signal
|
||||
expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
|
||||
|
||||
// A root that vanishes under a live index: an unreadable branch costs its
|
||||
// own candidates, but an unreadable root must not be published as an
|
||||
// empty workspace over entries that are still good.
|
||||
await rm(root, { recursive: true, force: true })
|
||||
files.invalidate()
|
||||
expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50) })
|
||||
expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
|
||||
|
||||
// The failed attempt left the index stale, so its return is picked up
|
||||
// without waiting for another invalidation.
|
||||
await mkdir(root, { recursive: true })
|
||||
await writeFile(join(root, 'restored.ts'), 'restored')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await files.list('restored', signal)).toEqual([{ path: 'restored.ts', kind: 'file' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an unreadable subtree cost only its own candidates', async () => {
|
||||
const root = await workspace()
|
||||
const locked = join(root, 'locked')
|
||||
await mkdir(locked, { recursive: true })
|
||||
await writeFile(join(locked, 'sealed.ts'), 'sealed')
|
||||
await chmod(locked, 0o000)
|
||||
locks.push(locked)
|
||||
const files = search(root)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
// The branch itself yields nothing, and the rest of the tree still does.
|
||||
expect(await files.list('sealed', signal)).toEqual([])
|
||||
expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
|
||||
// The directory is still offered: only reading through it fails.
|
||||
expect(await files.list('locked', signal)).toEqual([{ path: 'locked', kind: 'directory' }])
|
||||
})
|
||||
|
||||
it('enforces the entry cap', async () => {
|
||||
const root = await workspace()
|
||||
const capped = search(root, { maxEntries: 2 })
|
||||
expect(await capped.list('README', new AbortController().signal)).toEqual([
|
||||
{ path: 'README.md', kind: 'file' },
|
||||
])
|
||||
})
|
||||
|
||||
it('never traverses an excluded build output, so generated twins cannot outrank sources', async () => {
|
||||
const root = await workspace()
|
||||
await mkdir(join(root, 'dist'), { recursive: true })
|
||||
await writeFile(join(root, 'dist', 'terminal-view.js'), 'built')
|
||||
const files = search(root, { excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES] })
|
||||
expect(await files.list('terminal-view', new AbortController().signal)).toEqual([
|
||||
{ path: 'src/terminal-view.ts', kind: 'file' },
|
||||
])
|
||||
expect(await files.list('dist/', new AbortController().signal)).toEqual([])
|
||||
})
|
||||
|
||||
it('still offers a `lib` tree, where several ecosystems keep their sources', async () => {
|
||||
const root = await workspace()
|
||||
await mkdir(join(root, 'lib'), { recursive: true })
|
||||
await writeFile(join(root, 'lib', 'gem-entry.rb'), 'source')
|
||||
const files = search(root, { excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES] })
|
||||
expect(await files.list('gem-entry', new AbortController().signal)).toEqual([
|
||||
{ path: 'lib/gem-entry.rb', kind: 'file' },
|
||||
])
|
||||
})
|
||||
|
||||
it('cancels individual callers, skips missing directories, and validates limits', async () => {
|
||||
const root = await workspace()
|
||||
expect(() => search(root, { maxResults: 0 })).toThrow('maxResults')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/context/session-reference/README.md
|
||||
README.md: d900d4711ae7c2755153313503062cac14c37406
|
||||
README.zh.md: c9cac086f20194d2dbaa0fbe7eadd970560a248c
|
||||
README.md: 804cfea6357d4e9fc202e6562e75d740c5702c24
|
||||
README.zh.md: 4a249ea63cb7546603666990f67add54ebfe035a
|
||||
|
||||
@@ -37,7 +37,7 @@ A message that cites other sessions is followed immediately by a `## Referenced
|
||||
|
||||
### Finding sessions to reference
|
||||
|
||||
`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the latest log-backed title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention.
|
||||
`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the projected title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable, and reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention.
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -120,7 +120,8 @@ The request and snapshot are consecutive append-only target messages and preserv
|
||||
|
||||
These limits define when cross-session references are a poor fit. They are current package constraints.
|
||||
|
||||
- **No body discovery** — candidate queries inspect folded titles but do not search message bodies. A non-empty query may inspect every visible persisted session log through the session-query service's bounded, cancellable batch; a dedicated title index may replace that discovery path without changing URI, snapshot, or persistence contracts.
|
||||
- **No body discovery** — candidate queries inspect titles but do not search message bodies.
|
||||
- **Labels come from projections alone** — an attached session is labeled from its live projection cut, a cold one from its durable checkpoint, and a session neither answers for is labeled by its id and cannot be found by its title. Discovery never reads a log: folding one title costs a whole log, and this runs under every completion keystroke. A session persisted before the projection cache was composed regains its title the first time it is opened, which checkpoints it.
|
||||
- **Trusted caller boundary** — the service assumes its host is authorized to read every session exposed by `ctx.sessionQuery`; it is not a model-facing search tool.
|
||||
- **Text projection only** — non-text user and assistant blocks are not propagated across sessions.
|
||||
- **No live link** — references are snapshots, not forks, resumes, subscriptions, or source-session mutations.
|
||||
|
||||
@@ -37,7 +37,7 @@ kind: "package-reference"
|
||||
|
||||
### 查找可引用的会话
|
||||
|
||||
`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或最新日志标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。
|
||||
`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或投影标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id,并报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。
|
||||
|
||||
### 配置
|
||||
|
||||
@@ -120,7 +120,8 @@ kind: "package-reference"
|
||||
|
||||
这些限制说明跨会话引用何时不合适。它们是当前包约束。
|
||||
|
||||
- **不支持消息正文检索**:候选查询会检查折叠后的标题,但不搜索消息主体。非空查询可能通过 session-query 服务有界、可取消的批处理检查每个可见的持久化会话日志;专用标题索引未来可以替换这条发现路径,而不改变 URI、快照或持久化约定。
|
||||
- **不支持消息正文检索**:候选查询会检查标题,但不搜索消息主体。
|
||||
- **标签只来自投影**:已挂载的会话由实时投影切面标注,冷会话由持久化 checkpoint 标注,两者都答不上来的会话用 id 作标签且无法按标题搜到。发现路径绝不读日志:折叠一个标题的代价是整份日志,而这段代码位于补全的每一次击键之下。早于投影缓存组合存在的会话,只要被打开一次(销毁时即写 checkpoint)就会恢复标题。
|
||||
- **受信任调用方边界**:该服务假设宿主有权读取 `ctx.sessionQuery` 公开的每个会话;它不是面向模型的搜索工具。
|
||||
- **只投影文本**:不会在会话间传播非文本 user 与 assistant 块。
|
||||
- **没有实时链接**:引用是快照,不是 fork、恢复、订阅或源会话变更。
|
||||
|
||||
@@ -59,10 +59,18 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-output-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-session-projection-cache": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-compaction": "workspace:^",
|
||||
@@ -70,7 +78,10 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-output-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,12 @@ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionSurfaceSnapshot, SessionTitleObservationResult } from '@deepseek-ai/dsh-session-query'
|
||||
// Type-only: the `title` projection key plus the live registry and durable
|
||||
// cache Context merges — the two projection faces discovery labels from.
|
||||
import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionRecord, SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
|
||||
import {
|
||||
DEFAULT_CANDIDATE_LIMIT,
|
||||
DEFAULT_MAX_REFERENCE_BYTES,
|
||||
@@ -149,6 +154,10 @@ export class SessionReferenceResolver extends TypertRemoteService {
|
||||
|
||||
/**
|
||||
* List reference candidates, ranked by working-directory affinity.
|
||||
*
|
||||
* Discovery runs at keystroke rate, so a title only ever comes from a
|
||||
* projection read: see {@link SessionReferenceResolver.projectedTitle} for
|
||||
* which sessions can answer one and which fall back to their id.
|
||||
* @param agent - target agent; self is excluded and its cwd drives ranking.
|
||||
* @param query - optional case-insensitive session-id/cwd/title substring.
|
||||
* @param limit - optional positive result cap.
|
||||
@@ -170,26 +179,12 @@ export class SessionReferenceResolver extends TypertRemoteService {
|
||||
const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal))
|
||||
.filter(record => record.header.id !== agent.id)
|
||||
.map((record, index) => ({ record, index }))
|
||||
const inspected = needle === ''
|
||||
? records
|
||||
.sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|
||||
|| a.index - b.index)
|
||||
.slice(0, limit)
|
||||
: records
|
||||
const observations = await settleWithCancellation(
|
||||
this.ctx.sessionQuery.readTitleSnapshots(inspected.map(({ record }) => record.header.id), signal),
|
||||
signal,
|
||||
)
|
||||
return inspected.map(({ record, index }, observationIndex) => {
|
||||
const observation = observations[observationIndex] as SessionTitleObservationResult
|
||||
return {
|
||||
record,
|
||||
index,
|
||||
label: observation.status === 'fulfilled'
|
||||
? observation.value.title?.title ?? record.header.id
|
||||
: record.header.id,
|
||||
}
|
||||
}).filter(({ record, label }) => {
|
||||
const labelled = records.map(({ record, index }) => ({
|
||||
record,
|
||||
index,
|
||||
label: this.projectedTitle(record) ?? record.header.id,
|
||||
}))
|
||||
return labelled.filter(({ record, label }) => {
|
||||
if (needle === '') return true
|
||||
return record.header.id.toLocaleLowerCase().includes(needle)
|
||||
|| record.header.cwd?.toLocaleLowerCase().includes(needle) === true
|
||||
@@ -201,10 +196,42 @@ export class SessionReferenceResolver extends TypertRemoteService {
|
||||
sessionId: record.header.id,
|
||||
label,
|
||||
...record.header.cwd === undefined ? {} : { cwd: record.header.cwd },
|
||||
sameWorkspace: record.header.cwd !== undefined && record.header.cwd === targetCwd,
|
||||
createdAt: record.header.createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The title a session's projections can answer without reading its log.
|
||||
*
|
||||
* Attachment is decided by the store at read time, not by the listing:
|
||||
* a session that attached in between would otherwise be answered from a
|
||||
* checkpoint its live log has already moved past.
|
||||
*
|
||||
* An attached session answers from its live registry cut, which advances
|
||||
* with every committed event, so a rename or a just-generated title is
|
||||
* visible immediately; its events are already in memory, so the lazy fold
|
||||
* costs no I/O. A cold session answers from the durable checkpoint the
|
||||
* projection cache wrote when it went cold.
|
||||
*
|
||||
* Nothing else is attempted. Folding a title from a log costs the whole
|
||||
* log, and this call sits under every keystroke of `@` completion. A
|
||||
* session that no projection can answer 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 until it is opened once, which
|
||||
* checkpoints it.
|
||||
* @param record - the listed session, live or cold.
|
||||
* @returns the projected title, or undefined when no projection holds one.
|
||||
*/
|
||||
private projectedTitle(record: SessionRecord): string | undefined {
|
||||
const attached = this.ctx.get('sessions')?.get(record.header.id)
|
||||
const projections = this.ctx.get('sessionProjections')
|
||||
if (attached !== undefined && projections !== undefined) {
|
||||
return titleOf(projections.snapshot(attached, ['title']))
|
||||
}
|
||||
return titleOf(this.ctx.get('sessionProjectionCache')?.cachedSnapshot(record.header, ['title']))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote face of {@link listCandidates}: the configured candidate limit
|
||||
* applies, and every candidate carries the canonical mention a host inserts
|
||||
@@ -336,6 +363,12 @@ function renderPrompt(data: readonly ReferencedSessionData[]): string {
|
||||
return `${PROMPT_PREFIX}${stringifyTagSafeJson(data)}${PROMPT_SUFFIX}`
|
||||
}
|
||||
|
||||
/** The title in one projection snapshot; undefined when the unit is absent or still untitled. */
|
||||
function titleOf(snapshot: ProjectionSnapshot | undefined): string | undefined {
|
||||
const title = snapshot?.values.title
|
||||
return title === undefined || title === null ? undefined : title
|
||||
}
|
||||
|
||||
function candidateRank(candidateCwd: string | undefined, targetCwd: string | undefined): number {
|
||||
if (candidateCwd !== undefined && targetCwd !== undefined && candidateCwd === targetCwd) return 0
|
||||
if (candidateCwd === undefined) return 1
|
||||
|
||||
@@ -51,6 +51,12 @@ export interface SessionReferenceCandidate {
|
||||
label: string
|
||||
/** Source session working directory, when recorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
* True when {@link SessionReferenceCandidate.cwd} is recorded and equals the
|
||||
* requesting agent's. Hosts that only surface a distinguishing location
|
||||
* read this instead of comparing paths they never received.
|
||||
*/
|
||||
sameWorkspace: boolean
|
||||
/** Source session creation time in Unix epoch milliseconds. */
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction'
|
||||
import { createUserMessage, ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
|
||||
import SessionTitleService from '@deepseek-ai/dsh-session-title'
|
||||
import SessionReferenceResolver, {
|
||||
decodeSessionReferenceUri,
|
||||
encodeSessionReferenceUri,
|
||||
@@ -35,11 +37,29 @@ class TestSessionQueryEngine extends SessionQueryEngine {
|
||||
async function harness(config: Config = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
// The live registry and the title unit it hosts: discovery labels an
|
||||
// attached session from its projection cut, never from its log.
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
// Shipped base values: this suite only needs the unit the service registers.
|
||||
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 })
|
||||
await ctx.plugin(TestSessionQueryEngine)
|
||||
await ctx.plugin(SessionReferenceResolver, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand in for the projection cache with a fixed checkpoint table: the
|
||||
* resolver reads `cachedSnapshot` alone, and the point under test is which
|
||||
* sessions still reach a log fold.
|
||||
*/
|
||||
function withProjectionCache(ctx: Context, rows: Record<string, string | null>): void {
|
||||
ctx.provide('sessionProjectionCache', {
|
||||
cachedSnapshot: (meta: { id: SessionId }) => (
|
||||
meta.id in rows ? { asOfSeq: 0, values: { title: rows[meta.id] } } : undefined
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function fakeAgent(session: Session): Agent {
|
||||
return { id: session.id, session } as Agent
|
||||
}
|
||||
@@ -253,16 +273,16 @@ describe('session reference discovery and preparation', () => {
|
||||
})
|
||||
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([
|
||||
{ sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', createdAt: 25 },
|
||||
{ sessionId: SessionId('same'), label: 'same', cwd: '/same', createdAt: 20 },
|
||||
{ sessionId: SessionId('none'), label: 'none', createdAt: 30 },
|
||||
{ sessionId: SessionId('other'), label: 'other', cwd: '/else', createdAt: 40 },
|
||||
{ sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 },
|
||||
{ sessionId: SessionId('same'), label: 'same', cwd: '/same', sameWorkspace: true, createdAt: 20 },
|
||||
{ sessionId: SessionId('none'), label: 'none', sameWorkspace: false, createdAt: 30 },
|
||||
{ sessionId: SessionId('other'), label: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 },
|
||||
])
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([
|
||||
{ sessionId: SessionId('other'), label: 'other', cwd: '/else', createdAt: 40 },
|
||||
{ sessionId: SessionId('other'), label: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 },
|
||||
])
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([
|
||||
{ sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', createdAt: 25 },
|
||||
{ sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 },
|
||||
])
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), '', 0))
|
||||
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
|
||||
@@ -283,6 +303,77 @@ describe('session reference discovery and preparation', () => {
|
||||
listSessions.mockRestore()
|
||||
})
|
||||
|
||||
it('reads an attached session\'s current title, ahead of any checkpoint', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
|
||||
const live = ctx.sessions.create(SessionId('live'), { meta: { cwd: '/same' } })
|
||||
live.append('session/title', { title: 'Old title', messageSeqs: [], source: { kind: 'fallback' } })
|
||||
// The durable checkpoint is write-behind, so it still holds the old value.
|
||||
withProjectionCache(ctx, { live: 'Old title' })
|
||||
live.append('session/title', { title: 'Renamed mid turn', messageSeqs: [], source: { kind: 'user' } })
|
||||
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
|
||||
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'renamed'))
|
||||
.resolves.toEqual([
|
||||
{ sessionId: live.id, label: 'Renamed mid turn', cwd: '/same', sameWorkspace: true, createdAt: live.header.createdAt },
|
||||
])
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'old title')).resolves.toEqual([])
|
||||
expect(readTitles).not.toHaveBeenCalled()
|
||||
readTitles.mockRestore()
|
||||
})
|
||||
|
||||
it('labels a cold session from its checkpoint and reads no log', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
|
||||
const cold = { id: SessionId('cold'), createdAt: 10, cwd: '/same' }
|
||||
withProjectionCache(ctx, { cold: 'Cold checkpoint' })
|
||||
vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([
|
||||
{ header: cold, live: false, persisted: true },
|
||||
] as never)
|
||||
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
|
||||
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'checkpoint'))
|
||||
.resolves.toEqual([
|
||||
{ sessionId: cold.id, label: 'Cold checkpoint', cwd: '/same', sameWorkspace: true, createdAt: 10 },
|
||||
])
|
||||
expect(readTitles).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('labels a session no projection answers for by its id, still without a log read', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
|
||||
const seeded = { id: SessionId('seeded'), createdAt: 10, cwd: '/same' }
|
||||
// Persisted before the cache was composed: the title lives only in its log.
|
||||
withProjectionCache(ctx, {})
|
||||
vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([
|
||||
{ header: seeded, live: false, persisted: true },
|
||||
] as never)
|
||||
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
|
||||
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([
|
||||
{ sessionId: seeded.id, label: seeded.id, cwd: '/same', sameWorkspace: true, createdAt: 10 },
|
||||
])
|
||||
// Its own title cannot find it, and discovery still never opens the log.
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'anything')).resolves.toEqual([])
|
||||
expect(readTitles).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('labels every session by id when no projection face is composed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(TestSessionQueryEngine)
|
||||
await ctx.plugin(SessionReferenceResolver)
|
||||
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
|
||||
const other = ctx.sessions.create(SessionId('other'), { meta: { cwd: '/same' } })
|
||||
other.append('session/title', { title: 'Unreadable', messageSeqs: [], source: { kind: 'fallback' } })
|
||||
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([
|
||||
{ sessionId: other.id, label: other.id, cwd: '/same', sameWorkspace: true, createdAt: other.header.createdAt },
|
||||
])
|
||||
})
|
||||
|
||||
it('serves the Remote face with the configured limit and canonical mentions', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same', createdAt: 10 } })
|
||||
@@ -296,6 +387,7 @@ describe('session reference discovery and preparation', () => {
|
||||
sessionId: SessionId('source]'),
|
||||
label: 'source]',
|
||||
cwd: '/same',
|
||||
sameWorkspace: true,
|
||||
createdAt: 20,
|
||||
mention: formatSessionReferenceMention({ sessionId: SessionId('source]'), label: 'source]' }),
|
||||
}])
|
||||
@@ -377,38 +469,15 @@ describe('session reference discovery and preparation', () => {
|
||||
)).rejects.toThrow(/invalid session reference URI/)
|
||||
})
|
||||
|
||||
it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => {
|
||||
it('still matches an unlabeled session on its own metadata', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'))
|
||||
// No cwd, no title event: nothing but the id identifies it.
|
||||
const source = ctx.sessions.create(SessionId('source'))
|
||||
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
|
||||
readTitles.mockResolvedValueOnce([{
|
||||
sessionId: source.id,
|
||||
status: 'rejected',
|
||||
reason: new Error('broken title log'),
|
||||
}])
|
||||
|
||||
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([
|
||||
{ sessionId: source.id, label: source.id, createdAt: source.header.createdAt },
|
||||
{ sessionId: source.id, label: source.id, sameWorkspace: false, createdAt: source.header.createdAt },
|
||||
])
|
||||
|
||||
let releaseTitles: (() => void) | undefined
|
||||
let titleSignal: AbortSignal | undefined
|
||||
readTitles.mockImplementationOnce(async (_ids, signal) => {
|
||||
titleSignal = signal
|
||||
await new Promise<void>((resolve) => { releaseTitles = resolve })
|
||||
return []
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source', undefined, controller.signal)
|
||||
await vi.waitFor(() => { expect(releaseTitles).toBeTypeOf('function') })
|
||||
expect(titleSignal).toBe(controller.signal)
|
||||
const cancelledTitles = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
|
||||
controller.abort('autocomplete superseded')
|
||||
await cancelledTitles
|
||||
releaseTitles?.()
|
||||
await Promise.resolve()
|
||||
readTitles.mockRestore()
|
||||
})
|
||||
|
||||
it('projects only the current user/assistant surface and records snapshot metadata', async () => {
|
||||
|
||||
@@ -4,17 +4,48 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../util/output-retention" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../../compaction/compaction" },
|
||||
{ "path": "../../runtime-diagnostics/invariants" },
|
||||
{ "path": "../../session-query/session-query" }
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/output-retention"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../compaction/compaction"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-projection-cache"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-title"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1708,7 +1708,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
|
||||
description: 'List reference candidates, ranked by working-directory affinity.',
|
||||
description: 'List reference candidates, ranked by working-directory affinity.\n\nDiscovery runs at keystroke rate, so a title only ever comes from a projection read: see SessionReferenceResolver.projectedTitle for which sessions can answer one and which fall back to their id.',
|
||||
parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }],
|
||||
returns: 'candidates labeled by latest title or, when absent, session id.',
|
||||
},
|
||||
@@ -4876,7 +4876,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionReferenceCandidate',
|
||||
declaration: 'export interface SessionReferenceCandidate {\n sessionId: SessionId;\n label: string;\n cwd?: string;\n createdAt: number;\n}',
|
||||
declaration: 'export interface SessionReferenceCandidate {\n sessionId: SessionId;\n label: string;\n cwd?: string;\n sameWorkspace: boolean;\n createdAt: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionReferenceInput',
|
||||
|
||||
Generated
+21
@@ -2856,12 +2856,21 @@ importers:
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/remotes
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/session-controller
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-ui-input-trigger':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-input-trigger
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
@@ -2877,6 +2886,9 @@ importers:
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/protocol
|
||||
'@deepseek-ai/dsh-util-workspace-path':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/workspace-path
|
||||
|
||||
packages/client/ui-renderer:
|
||||
dependencies:
|
||||
@@ -4061,9 +4073,18 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-projection
|
||||
'@deepseek-ai/dsh-session-projection-cache':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-projection-cache
|
||||
'@deepseek-ai/dsh-session-query':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-query/session-query
|
||||
'@deepseek-ai/dsh-session-title':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-title
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/protocol
|
||||
|
||||
Reference in New Issue
Block a user