diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml index b8cfc26916..8b48c9526c 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml @@ -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-27-web-at-mention-discovery-and-row-content.md -2026-08-27-web-at-mention-discovery-and-row-content.md: bb2bdfd19a453262d6b2311e17412e4e8761606e -2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 8f45da994973c21f42f6cba1e8c8402238719260 +2026-08-27-web-at-mention-discovery-and-row-content.md: 49587a35411952ddb270e2dc2687dcc98b3bebe5 +2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 0a26f5ddc2cb1469c2b69c160a134e36cd84586e diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md index bb2bdfd19a..49587a3541 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md @@ -24,7 +24,7 @@ The cache is optional, and without it the previous fold path stands unchanged, i **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 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 sixteen — version-control and dependency stores plus the build-output basenames of the ecosystems this harness runs in — 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 with the relative-time bucket the session list uses, so one session reads the same age on both surfaces. `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. +**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 that says whether a drill or typing produced it. `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. @@ -40,6 +40,8 @@ The zh composer placeholder says `文件或对话`, matching the `对话` sectio **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 covers the measured 41% and stays a config field. A workspace that keeps sources under one of those basenames must override `excludedDirectories`. +**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. diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md index 8f45da9949..0a26f5ddc2 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md @@ -24,7 +24,7 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的 **失效的文件索引在替代品构建期间继续作答。** `invalidate()` 递增一个计数器而不是丢弃遍历。裸查询由已完成的条目作答,并启动一次后台重建、完成后原子替换;只有一个工作区的首次裸查询会等待。失败的刷新保留陈旧条目与计数器,下一次查询因此重试。`DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` 从两个名字增至十六个——版本控制与依赖目录,加上本 harness 运行的各生态的构建产物基名——`DEFAULT_FILE_SEARCH_MAX_ENTRIES` 提高到 50 000。两者仍是部署方可覆盖的 `excludedDirectories` 与 `maxEntries` 配置字段。 -**每一行只承载能区分它的信息。** 文件显示其父目录,位于工作区根目录时不显示。下钻后的目录列表不显示父目录,因为面包屑已经在显示。会话仅在 `SessionReferenceCandidate.sameWorkspace` 为 false 时显示其工作区——由宿主计算,因为排序时它本就同时握有两个工作目录——并使用会话列表所用的相对时间分档标注时间,因此同一个会话在两处读到的时长一致。`relativeTime` 从 `ui-workspace` 的 `tree.ts` 移到 `ui-primitives`;按 locale-owned 文案的规则,词句仍留在各插件自己的字典里。session id 离开行内:它本就是无标题会话回落到的标签。 +**每一行只承载能区分它的信息。** 文件显示其父目录,位于工作区根目录时不显示。下钻后的目录列表不显示父目录,因为面包屑已经在显示。会话仅在 `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 也不得承载它。 @@ -40,6 +40,8 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的 **读 `.gitignore` 来约束索引。** 暂时否决:这会给一条必须保持同步且廉价的路径引入 ignore 文件解析器与 git 依赖。基名列表覆盖了实测的 41%,且本就是配置字段。把源码放在其中某个基名下的工作区需覆盖 `excludedDirectories`。 +**在宿主侧从 `sessionListMetadata` 投影读取会话最近活动时间。** 否决:该投影键由 `api-session-controller` 声明,读取它会让 `packages/context` 的能力依赖 BFF 装配层——本仓库没有这个方向的先例。客户端的 `ctx.sessions.list` 里本就有同一个数字,而这也正是让两处界面「由构造而非由巧合」保持一致的原因。 + **让 `MenuView` 识别 `@` 触发符并自行绘制面包屑。** 否决:`MenuView` 与 `/` 共用,把文件引用语义硬编码进去,越过了 source 注册表本就用来守住的包边界。 **把 `drilled` 作为可选字段加进 `CandidateRequest`。** 否决:管线始终知道它,而可选字段会诱使 source 把「请求早于该字段」读成「未下钻」。改为必填并更新每一处调用点,符合预发布阶段的取舍。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 932611b278..2926dcc1c0 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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: 902a0e51e9fa0af603eb0a0588d19110d31c84c4 -module-graph.zh.md: 5a71e69a7f6bf8c9d4959afda06f9afe8cf3868e +module-graph.md: 41b05c95f7e45c2fc044ce7bf62dfc8c7d31c45e +module-graph.zh.md: 1fb86d8745ce909d97ada49565bf200c0eb868b2 diff --git a/docs/module-graph.md b/docs/module-graph.md index 902a0e51e9..41b05c95f7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1546,6 +1546,7 @@ 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 @@ -1926,7 +1927,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-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-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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 5a71e69a7f..1fb86d8745 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1548,6 +1548,7 @@ 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 @@ -1928,7 +1929,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-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-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) | diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index 6c3841170b..fae26277f7 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-reference/README.i18n.yaml @@ -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: 9c2655fcac96803ba47864c2ab99ed123ca8e201 -README.zh.md: 15f61ba1a692539e87bfe551225b346b78a203e7 +README.md: 1f82f4bb30f2ad5d194a70279f9e80885d6a048e +README.zh.md: 03a79ec4ff34431a63d8a7b0de115e3ec63b9392 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 9c2655fcac..1f82f4bb30 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -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. A session row is dated with the same relative-time bucket the session list uses, so one session reads the same age on both surfaces. 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. +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 diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index 15f61ba1a6..03a79ec4ff 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -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` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前所列目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。 ### 序列化 diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index aa1ce68bd3..f7e9afbde5 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -33,6 +33,7 @@ "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" @@ -47,6 +48,7 @@ "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:^", @@ -59,6 +61,7 @@ }, "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:^", diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index cafe481c56..c82adcdbe4 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -6,7 +6,8 @@ * 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. + * 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 */ @@ -15,6 +16,7 @@ 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 { @@ -28,7 +30,7 @@ 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', 'connection', 'remote', 'remote.fileReferences', + 'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', ] @@ -40,31 +42,39 @@ 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, drilled, signal }) { - const files = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( + 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, withLocation, t)), - ...sessionItems.map(candidate => sessionCandidate(candidate, now, home, t)), + ...sessionItems.map(candidate => sessionCandidate( + candidate, + listed[candidate.sessionId]?.updatedAt ?? candidate.createdAt, + now, + home, + t, + )), ] }, header(_session: ClientSessionContext, req) { @@ -198,11 +208,12 @@ function fileCandidate( function sessionCandidate( candidate: SessionReferenceMentionCandidate, + updatedAt: number, now: number, home: string | undefined, t: Translate, ) { - const { unit, n } = relativeTime(candidate.createdAt, now) + 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. diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 51907a75b7..03e19b1b27 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -22,6 +22,8 @@ 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'] }) @@ -71,6 +73,7 @@ async function bench( mention: '@[Research](dsh-session:InNvdXJjZSI)', }], })), + listed: Record = {}, ): Promise<{ ctx: Context; fiber: ReturnType; source: InputTriggerSource }> { const ctx = new Context() let source: InputTriggerSource | undefined @@ -90,6 +93,7 @@ async function bench( 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') @@ -99,7 +103,7 @@ async function bench( describe('apply', () => { it('declares its services and releases the @ reference registration on disposal', async () => { expect(inject).toEqual([ - 'inputTriggers', 'locale', 'connection', 'remote', 'remote.fileReferences', + 'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', ]) const { fiber } = await bench() @@ -121,6 +125,7 @@ describe('apply', () => { 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 }) @@ -177,7 +182,7 @@ describe('candidates', () => { }) } })) - 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) @@ -199,7 +204,7 @@ describe('candidates', () => { }), expect.objectContaining({ name: 'Research', - description: '~/project · 3d', + description: '~/project · 1h', icon: 'session', section: 'Sessions', }), @@ -299,6 +304,26 @@ describe('candidates', () => { ]) }) + 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({ @@ -312,7 +337,7 @@ describe('candidates', () => { mention: '@[Just now](dsh-session:Imp1c3Qtbm93Ig)', }], })) - const { source } = await bench(files, sessions) + 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' }), ]) diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index bc35a964c1..edaf356f28 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../context/file-reference" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90aa404403..a18f145286 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2850,6 +2850,9 @@ 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