diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index bb9425fa64..900e1d01b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38 -2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464 +2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964 +2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 7c8f8cb676..ad2aa904be 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -18,7 +18,9 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. -- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. +- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. @@ -30,6 +32,9 @@ Placement and policy rulings folded into this decision: - **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. - **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. +- **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. +- **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. +- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 05545fc3cd..30e719ad9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -18,7 +18,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 -- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 +- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 @@ -30,6 +32,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **统一方法集的 seam(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。 - **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture/测试后端,与促成这项工作的插件教义相悖。 - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 +- **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 +- **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 +- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml new file mode 100644 index 0000000000..b3e0c18cdd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml @@ -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-07-30-web-context-injection-disclosure.md +2026-07-30-web-context-injection-disclosure.md: 84c3259f3f226e501a671cc55cacf7d7d96f61fb +2026-07-30-web-context-injection-disclosure.zh.md: 4d77e06e27badb02fb73ca2ea2a739b33c5804de diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md new file mode 100644 index 0000000000..84c3259f3f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md @@ -0,0 +1,33 @@ +# Agent Note: Web context injection disclosure + +Status: implemented + +English | [中文](2026-07-30-web-context-injection-disclosure.zh.md) + +## Problem + +The Web conversation rendered every logged non-user message through the generic `JsonBlock`. That presentation used a textual triangle, compact label typography, a bordered JSON panel, and unrelated spacing, so context injection did not match the Tool calls disclosure shown in the product design. Restyling the generic primitive would also change unknown events and attachment fallbacks. + +## Decision + +`MessageItem` routes context nodes to `ContextInjectionRow`. The row starts collapsed, names the presentation `上下文注入`, uses the existing browse glyph, and exposes the whole 24px header as one pointer and keyboard disclosure target. Its expanded body begins 4px below the header at the shared 22px content indent and renders the design's 141px scrollport with 8px radius, code-block background, 11/16 code text, and no border. + +`ContextInjectionRow` serializes both logged `content` and `source` into one inline JSON value, preserving provenance alongside model-visible material. The display remains bounded by the existing 20,000-character truncation policy. It changes no session event, runtime fold, or context-producing plugin. + +The package-internal `DisclosureRow` owns the header geometry, icon-to-chevron transition, controlled open state, and Enter/Space behavior shared by context and `ToolRow`. `ToolRow` remains the semantic owner of tool state, summaries, file links, and expanded tool bodies. Context does not enter the keyed toolview slot and gains no context-specific slot while all context sources share one presentation. + +## Verification + +Conversation component tests pin the collapsed default, browse glyph, whole-row pointer and keyboard toggles, inline JSON shape, truncation, and unchanged generic unknown-event rendering. The keyless assembled-Web history scenario injects context through the real Agent API, records the collapsed row in its ARIA golden, and measures the design's icon, header, indent, gap, scrollport, padding, radius, typography, color, and overflow in Chromium. + +## Alternatives considered + +**Restyle `JsonBlock` globally.** Unknown surface events and miscellaneous content blocks use that primitive for a separate generic fallback, so a global visual change would couple unrelated presentations. + +**Render context as a read tool.** Reusing `ToolRow` directly would add false tool semantics, state and keyed dispatch to a logged non-user message. + +**Add a keyed context-view slot.** Every current context source uses the same title and provenance body. A registration seam has no present consumer and can be added without changing the row if distinct source-owned presentations emerge. + +## Consequences + +Context injection matches the Tool calls visual language without changing its durable meaning. The shared disclosure header prevents the two rows from drifting, while the dedicated context body and generic `JsonBlock` remain independently evolvable. The fixed-height body trades automatic expansion for a stable transcript rhythm and requires scrolling to inspect long injected instructions. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md new file mode 100644 index 0000000000..4d77e06e27 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web 上下文注入展开项 + +Status: implemented + +[English](2026-07-30-web-context-injection-disclosure.md) | 中文 + +## 问题 + +Web 会话原本通过通用 `JsonBlock` 渲染每条已记录的非用户消息。这种呈现使用文本三角符号、紧凑的标签字体、有边框的 JSON 面板和另一套间距,因此上下文注入与产品设计中的 Tool calls 展开项不一致。修改通用原语的样式还会影响未知事件和附件的兜底呈现。 + +## 决策 + +`MessageItem` 将上下文节点路由至 `ContextInjectionRow`。该行初始折叠,标题为 `上下文注入`,使用现有的浏览图标,并使整个 24px 标题栏成为可通过指针和键盘操作的展开目标。其展开主体从标题栏下方 4px 处开始,与共用的 22px 内容缩进对齐,并渲染设计规定的 141px 滚动区;滚动区采用 8px 圆角、代码块背景、11/16 代码文本且无边框。 + +`ContextInjectionRow` 将已记录的 `content` 和 `source` 序列化为一个内联 JSON 值,在模型可见内容旁保留来源信息。显示内容继续受现有的 20,000 字符截断策略约束。该变更不修改任何会话事件、运行时折叠逻辑或上下文生成插件。 + +包内部的 `DisclosureRow` 负责上下文行和 `ToolRow` 共用的标题栏几何、图标至折叠箭头的过渡、受控打开状态,以及 Enter/Space 操作。`ToolRow` 仍是工具状态、摘要、文件链接和展开后工具主体的语义 owner。所有上下文来源共用同一套呈现;上下文不会进入键控 toolview slot,也不会获得上下文专用 slot。 + +## 验证 + +会话组件测试固定验证初始折叠状态、浏览图标、整行的指针与键盘切换、内联 JSON 形状、截断,以及通用未知事件渲染保持不变。无密钥的组装后 Web 历史场景通过真实 Agent API 注入上下文,在 ARIA 预期输出中记录折叠行,并在 Chromium 中测量设计规定的图标、标题栏、缩进、间隙、滚动区、内边距、圆角、字体排版、颜色和溢出行为。 + +## 考虑过的替代方案 + +**全局重新设置 `JsonBlock` 样式。** 之所以否决:未知 surface 事件和其他内容块使用该原语作为独立的通用兜底呈现,全局视觉变更会把无关的呈现耦合起来。 + +**将上下文渲染为 read 工具。** 之所以否决:直接复用 `ToolRow` 会为已记录的非用户消息添加错误的工具语义、状态和键控分发。 + +**新增键控 context-view slot。** 之所以否决:当前所有上下文来源都使用相同的标题和来源信息主体,注册 seam 暂无消费方。如果将来出现由不同来源拥有的呈现,仍可在不更改该行的情况下添加此 seam。 + +## 后果 + +上下文注入与 Tool calls 采用一致的视觉语言,同时不改变其持久保存的语义。共用的展开项标题栏可以防止这两种行逐渐偏离,而专用的上下文主体与通用 `JsonBlock` 仍可独立演进。固定高度的主体以无法随内容自动增高为代价,为 transcript(文本记录)维持稳定的排版节奏;查看较长的注入指令时必须滚动。 diff --git a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml index b56814b9a3..50a6f1e0db 100644 --- a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml @@ -1,6 +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 -2026-07-14-typescript-program-backed-semantic-gates.md: 43a7b9b5369feb199721f5f1348c03cde66ee411 -2026-07-14-typescript-program-backed-semantic-gates.zh.md: 1ab027d723e30007e6675ae1f3589fb594d10afc +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md +2026-07-14-typescript-program-backed-semantic-gates.md: 91639d53b660c68ae52c7ddcef6b3f594e82273e +2026-07-14-typescript-program-backed-semantic-gates.zh.md: 2270408564f0fc90241255dfb86a65c30362d651 diff --git a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md index 43a7b9b536..91639d53b6 100644 --- a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md +++ b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md @@ -30,6 +30,8 @@ The wrapper owns config diagnostics, semantic compiler options, repository-relat Context and agent-dispatch calls contribute only finite string-literal event sets. Direct `EventsService.dispatch()` calls recover the event slot through array literals, constant aliases, conditional branches, and resolved call sites of non-exported local helpers. Generic forwarding parameters are not concrete producers: attribution stays with the call sites that supply a closed event value. +Semantic queries run only where a branch can consume them: calls are prefiltered by the closed event-API method-name set before receiver classification, and helper call sites are indexed on demand instead of eagerly resolving every call in every package source. The demand-driven index proves locality per helper — a helper that is non-exported, sits in a real ES module, and whose every same-file reference is a direct callee has all of its calls in that file by module scoping, so only that file is indexed. Any unproven premise (an export modifier, a global script file, an aliasing or otherwise unclassifiable reference) falls back to the original full package-source index, which is the unchanged original semantics; the proof affects cost, never results. A lazy single global index was rejected because the helper-parameter path is reached on the current tree, so it would still pay nearly the whole `getResolvedSignature` sweep. + Every declared harness event must have a discovered producer. A missing producer fails generation as dead vocabulary or an unsupported semantic dispatch shape; listener-free extension points remain valid. `internal/dispatch` instrumentation is not treated as a subscription to every event it observes, so the matrix contains direct product listeners rather than manually asserted indirect relationships. ### B. Scoped-event routing generates one typed resolver map diff --git a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md index 1ab027d723..2270408564 100644 --- a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md @@ -30,6 +30,8 @@ Status: implemented Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件集合。对于直接调用 `EventsService.dispatch()` 的路径,生成器会沿数组字面量、常量别名、条件分支和未导出本地辅助函数的已解析调用点恢复事件槽位。泛型转发参数不算作具体生产方:事件仍归属于传入封闭事件值的调用点。 +语义查询只在存在消费分支的位置运行:调用先经过封闭的事件 API 方法名集合预过滤,再做接收者分类;辅助函数调用点索引按需构建,而不是预先对全部包源码的每个调用求解签名。需求式索引对每个辅助函数逐一证明局部性——未导出、位于真正的 ES 模块文件中、且同文件所有引用都是直接调用位的辅助函数,按模块作用域规则其全部调用必在本文件内,此时只索引该文件。任一前提无法证明(带导出修饰符、位于全局 script 文件、存在别名化或无法归类的引用)即回退到原全部包源码索引,回退路径就是原语义本身:证明只影响开销,不影响结果。惰性单一全局索引方案被否决,因为当前源码树确实会走到辅助函数参数路径,该方案仍需支付几乎全额的 `getResolvedSignature` 扫描成本。 + 每个已声明的 harness 事件都必须存在扫描得到的生产方。找不到生产方时,生成过程会将其视为无调用方的事件词汇或尚不支持的语义 dispatch 形态并明确失败;没有监听方的扩展点仍然合法。`internal/dispatch` 插桩不会被当作它所观察的每个事件的订阅,因此关系矩阵只记录直接的产品监听方,不再手工补充间接关系。 ### B. 带作用域的事件路由生成一份强类型解析函数表 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9800a0d62..9ffe59ead9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,8 +120,9 @@ jobs: # across six always-on runner instances, and the timing-sensitive # process suites have documented aggregate-contention failures. # 8 × 6 instances = 48 workers worst case on 64 cores. - DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '24' }} - DSH_GATE_CONCURRENCY: '8' + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '8' }} + DSH_GATE_CONCURRENCY: '3' + NODE_OPTIONS: '--max-old-space-size=8192' steps: - uses: actions/checkout@v6 with: diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1c030b4097..dbcc752408 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -9,7 +9,8 @@ // from live session memory), refresh (keyless replay that rewrites goldens). // // Composition divergences from `dsh web`, all deliberate, all via include -// patches after the shipped surface overlay: temp persistenceRoot; +// patches after the shipped surface overlay: temp persistenceRoot; user skill +// roots redirected to empty temp directories (project discovery remains real); // workspace-context disabled (recorded fixtures must not embed this repo's // AGENTS.md); session-title-llm disabled (its fire-and-forget title call // would race the loop for the session's replay cursor); webserver pinned to @@ -172,6 +173,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { const toolRows = page.locator('[data-variant], [data-sample]') await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0) + + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + agent.inject(createUserMessage({ + content: [{ + type: 'text', + text: '\n' + + 'The following workspace instructions may be relevant to your work. ' + + 'Use them as guidance when applicable.\n\n' + + Array.from({ length: 24 }, (_, index) => `Instruction ${index + 1}: preserve the logged context contract.`).join('\n') + + '\n', + }], + source: { + kind: 'workspace-instructions', + baseline: true, + changes: [{ + action: 'set', + scope: '.\u0000AGENTS.md', + path: 'AGENTS.md', + digest: 'context-injection-browser-snapshot', + }], + }, + })) + await page.getByRole('button', { name: '上下文注入' }).waitFor({ timeout: 10_000 }) }, 60_000) it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { @@ -132,6 +158,58 @@ describe('web e2e: seeded history renders through cold resume', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) + it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) + const disclosure = page.getByRole('button', { name: '上下文注入' }) + expect(await disclosure.getAttribute('aria-expanded')).toBe('false') + const collapsedIcon = disclosure.locator('svg').first() + const collapsedIconBox = await collapsedIcon.boundingBox() + expect(collapsedIconBox?.width).toBe(14) + expect(collapsedIconBox?.height).toBe(14) + + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') + const body = page.locator('[data-context-injection-body]') + await body.waitFor({ timeout: 5_000 }) + const headerBox = await disclosure.boundingBox() + const bodyBox = await body.boundingBox() + if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable') + expect(headerBox.height).toBe(24) + expect(bodyBox.x - headerBox.x).toBe(22) + expect(bodyBox.y - headerBox.y - headerBox.height).toBe(4) + expect(bodyBox.height).toBe(141) + + const style = await body.evaluate((element) => { + const computed = getComputedStyle(element) + return { + backgroundColor: computed.backgroundColor, + borderRadius: computed.borderRadius, + color: computed.color, + fontSize: computed.fontSize, + lineHeight: computed.lineHeight, + padding: [ + computed.paddingTop, + computed.paddingRight, + computed.paddingBottom, + computed.paddingLeft, + ], + scrolls: element.scrollHeight > element.clientHeight, + } + }) + expect(style).toEqual({ + backgroundColor: 'rgb(249, 250, 251)', + borderRadius: '8px', + color: 'rgb(129, 133, 140)', + fontSize: '11px', + lineHeight: '16px', + padding: ['10px', '16px', '12px', '12px'], + scrolls: true, + }) + + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') + }) + it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow')) // Interaction over cold-resumed history: read summaries are host-open diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index e1581ba986..efc43a272e 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -31,6 +31,10 @@ - button "在新对话中分支": - img - text: {{clock}} +- button "上下文注入": + - img + - img + - text: 上下文注入 - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md index 4bbd790afa..baaaa6f3dc 100644 --- a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -5,6 +5,37 @@ - img - button "browse-golden" - button "Edit path" + - list: + - listitem: + - button "adopted": + - img + - text: adopted + - img + - listitem: + - button "alpha-ws": + - img + - text: alpha-ws + - img + - listitem: + - button "beta-ws": + - img + - text: beta-ws + - img + - listitem: + - button "browse-golden": + - img + - text: browse-golden + - img + - listitem: + - button "same-name": + - img + - text: same-name + - img + - listitem: + - button "workspace": + - img + - text: workspace + - img - list: - listitem: - button "alpha": @@ -19,5 +50,6 @@ - button "New folder": - img - text: New folder + - button "Show hidden files" - button "Cancel" - button "Open" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 521eb924a4..a66742e2e4 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: ae245d1c904b291413d097c0b6d88c23c44f7c95 -README.zh.md: e80c55e11e122d66b2a7c7d978a6aadb4e39c659 +README.md: 5fe9086ed92b9f3f498493a192b7e27a31da124c +README.zh.md: bb1d08e24ec5654fc8b24c10418dad356f8cd3ce diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ae245d1c90..5fe9086ed9 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,6 +10,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission ` command line through the bar's injected `command` callback. +Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). + Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e80c55e11e..bb1d08e24e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,6 +8,8 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 +已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开后的 141px 滚动区会以内联 JSON 的形式有界展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 + 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 @@ -20,7 +22,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar 坑位本身为 `session-maybe`:没有当前会话时,同一个 bar 以惰性态渲染(machine face 缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此 textarea DOM 在选定 workspace 的切换中得以存活;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影:`tokenUsage` 提供完整日志计费用量(计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量),`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM 和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。未组合 token-meter 的部署会整组省略 token 分组;只有提供方压力与路由容量都已知时才显示占用率。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后者胜」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 UI;模型选择器不增加圆环或附属控件。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css new file mode 100644 index 0000000000..7ffdfca5bd --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css @@ -0,0 +1,29 @@ +/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px clipped code block. */ + +.root { + min-width: 0; +} + +.root[data-open] { + padding-bottom: 4px; +} + +.chevron { + color: var(--dsw-alias-label-secondary); +} + +.body { + box-sizing: border-box; + width: calc(100% - 22px); + height: 141px; + margin: 4px 0 0 22px; + overflow: auto; + padding: 10px 16px 12px 12px; + border: none; + border-radius: 8px; + background: var(--dsw-alias-markdown-code-block); + color: var(--dsw-alias-label-tertiary); + font: 400 11px/16px var(--ds-font-family-code); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx new file mode 100644 index 0000000000..d21dba531b --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -0,0 +1,81 @@ +import { useMemo, useState } from 'react' +import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client' +import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow } from './DisclosureRow.tsx' +import css from './ContextInjectionRow.module.css' + +const MAX_CHARS = 20_000 + +function inlineJson(payload: unknown): string { + const raw = JSON.stringify(payload) + let formatted = '' + let quoted = false + let escaped = false + + for (let index = 0; index < raw.length; index++) { + const char = raw.charAt(index) + if (quoted) { + formatted += char + if (escaped) escaped = false + else if (char === '\\') escaped = true + else if (char === '"') quoted = false + continue + } + if (char === '"') { + quoted = true + formatted += char + continue + } + if (char === '{' || char === '[') { + formatted += char + const close = char === '{' ? '}' : ']' + if (raw[index + 1] !== close) formatted += ' ' + continue + } + if (char === '}' || char === ']') { + const open = char === '}' ? '{' : '[' + if (raw[index - 1] !== open) formatted += ' ' + formatted += char + continue + } + formatted += char === ':' || char === ',' ? `${char} ` : char + } + return formatted +} + +/** Props for the logged non-user message presentation. */ +export interface ContextInjectionRowProps { + content: ContextMessageNode['content'] + source: ContextMessageNode['source'] +} + +/** + * Render logged context with the Tool calls disclosure chrome from Figma. + * @param props - Durable content and source provenance. + * @returns A collapsed context row with a bounded JSON body. + */ +export function ContextInjectionRow({ content, source }: ContextInjectionRowProps) { + const [open, setOpen] = useState(false) + const body = useMemo(() => { + if (!open) return '' + const text = inlineJson({ content, source }) + return text.length > MAX_CHARS + ? `${text.slice(0, MAX_CHARS)}\n… 已截断,共 ${text.length} 字符` + : text + }, [content, open, source]) + + return ( + } + chevronClassName={css.chevron} + title="上下文注入" + open={open} + expandable + expandOnRowClick + onToggle={() => { setOpen(value => !value) }} + > +
{body}
+
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/DisclosureRow.module.css b/packages/client/ui-conversation/src/client/chat/DisclosureRow.module.css new file mode 100644 index 0000000000..04f2d18d0a --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/DisclosureRow.module.css @@ -0,0 +1,69 @@ +/* Shared Tool calls disclosure header: [16px leading] gap 6 [title 14/24]. */ + +.root { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; +} + +.row { + position: relative; + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +.row[data-expandable] { + cursor: pointer; +} + +.leading { + position: relative; + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + padding: 0; + border: none; + background: none; + color: var(--dsw-alias-label-tertiary); +} + +button.leading { + cursor: pointer; +} + +.iconIdle { + display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; +} + +.chevronHover { + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; +} + +.row:hover .iconIdle { + opacity: 0; +} + +.row:hover .chevronHover { + opacity: 1; +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx b/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx new file mode 100644 index 0000000000..0b1e9ea1b0 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx @@ -0,0 +1,101 @@ +import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import clsx from 'clsx' +import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './DisclosureRow.module.css' + +/** Shared 24px disclosure chrome for conversation flow rows. */ +export interface DisclosureRowProps { + icon: ReactNode + title: string + open: boolean + expandable: boolean + onToggle: () => void + /** Makes the complete title row the disclosure target. */ + expandOnRowClick?: boolean | undefined + /** Replaces the collapsed icon with a chevron while the row is hovered. */ + previewChevron?: boolean | undefined + collapsedContent?: ReactNode + children?: ReactNode + className?: string | undefined + rowClassName?: string | undefined + leadingClassName?: string | undefined + chevronClassName?: string | undefined + titleClassName?: string | undefined +} + +/** + * Render one disclosure header and its controlled expanded content. + * @param props - Visual content, controlled state, and interaction policy. + * @returns The disclosure row. + */ +export function DisclosureRow({ + icon, + title, + open, + expandable, + onToggle, + expandOnRowClick = false, + previewChevron = expandable, + collapsedContent, + children, + className, + rowClassName, + leadingClassName, + chevronClassName, + titleClassName, +}: DisclosureRowProps) { + const rowExpands = expandable && expandOnRowClick + const toggleFromLeading = (event: MouseEvent) => { + event.stopPropagation() + onToggle() + } + const toggleFromKeyboard = (event: KeyboardEvent) => { + if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + onToggle() + } + const collapsedLeading = previewChevron + ? ( + <> + {icon} + + + ) + : icon + const leading = open + ? + : collapsedLeading + + return ( +
+
+ {expandable && !rowExpands ? ( + + ) : ( + + {leading} + + )} + {title} + {!open && collapsedContent} +
+ {open && children} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a149d37337..aa56d35270 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,6 +10,7 @@ import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' @@ -94,9 +95,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) } case 'context': return ( -
- -
+ ) default: return ( diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 08031f570c..e53b472c50 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -9,10 +9,6 @@ .row { position: relative; /* sweep-glare overlay anchor */ overflow: hidden; - display: flex; - align-items: center; - height: 24px; - min-width: 0; } /* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band — @@ -41,24 +37,8 @@ 90%, 100% { left: 100%; } } -/* Expand-on-row (Think / code): pointer only — no row fill hover. */ -.row[data-expandable] { - cursor: pointer; -} - .leading { - position: relative; /* .chevronHover overlay anchor */ - flex: none; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - margin-right: 6px; - padding: 0; - border: none; - background: none; - color: var(--dsw-alias-label-tertiary); + flex-shrink: 0; } /* Cordis lifecycle tools retain their generic row mechanics while carrying a @@ -76,40 +56,8 @@ background: var(--dsw-alias-state-business-primary); } -button.leading { - cursor: pointer; -} - -/* Hover preview on expandable rows: the idle tool icon crossfades (100ms) - into a down chevron before the row is opened. The chevron overlays the - icon cell absolutely so both can stay mounted for the opacity transition. */ -.iconIdle { - display: inline-flex; - opacity: 1; - transition: opacity 100ms ease; -} - -.chevronHover { - position: absolute; - inset: 0; - margin: auto; - opacity: 0; - transition: opacity 100ms ease; -} - -.row:hover .iconIdle { - opacity: 0; -} - -.row:hover .chevronHover { - opacity: 1; -} - .title { - flex: none; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-secondary); + font-weight: 400; } .sep { diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index fe0df556ce..365000ebb9 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -8,11 +8,11 @@ // component-local view state. File-tool summaries are path links that open // through the host; the row itself is not a details-panel control. -import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { useState, type MouseEvent, type ReactNode } from 'react' import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' +import { DisclosureRow } from './DisclosureRow.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -82,63 +82,27 @@ export function ToolRow({ // this substitution never shows. const text = body ?? '' const open = expanded && expandable - const rowExpands = expandable && expandOnRowClick const toggleExpand = () => { setExpanded(v => !v) } - const toggleFromLeading = (event: MouseEvent) => { - event.stopPropagation() - toggleExpand() - } - const toggleFromKeyboard = (event: KeyboardEvent) => { - if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return - event.preventDefault() - toggleExpand() - } const openFile = (event: MouseEvent) => { event.stopPropagation() if (filePath !== undefined) onOpenFile?.(filePath) } - // Expandable rows preview the toggle on hover: the tool icon yields to a - // down chevron (CSS swap on .row:hover); state dots still take precedence. - const collapsedIcon = expandable - ? ( - <> - {icon} - - - ) - : icon - const leading = open - ? - : leadingFor(state, collapsedIcon) return (
-
- {expandable && !rowExpands ? ( - - ) : ( - - {leading} - - )} - {title} - {!open && ( + {fileLink ? ( @@ -154,18 +118,18 @@ export function ToolRow({ )} )} -
- {/* The terminal presenter's description belongs ABOVE the card per the - render-intent contract, so an expanded terminal row keeps showing it - even though the collapsed summary is hidden while open. */} - {open && terminalBody?.description !== undefined && ( -
{terminalBody.description}
- )} - {open && (terminalBody !== null - ? - : variant === 'code' - ? - :
{text}
)} + > + {/* The terminal presenter's description belongs above the card per + the render-intent contract. */} + {terminalBody?.description !== undefined && ( +
{terminalBody.description}
+ )} + {terminalBody !== null + ? + : variant === 'code' + ? + :
{text}
} +
) } diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 31d43998ad..b22737f6ab 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -107,11 +107,48 @@ describe('MessageItem arms', () => { expect(view.queryByRole('button', { name: '复制' })).toBeNull() }) - it('context and unknown nodes render their JSON rows', () => { + it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => { const ctxView = render( - , + , ) - expect(ctxView.getByText(/上下文注入/)).toBeTruthy() + const disclosure = ctxView.getByRole('button', { name: '上下文注入' }) + expect(disclosure.getAttribute('aria-expanded')).toBe('false') + expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() + expect(ctxView.container.querySelector('svg')).not.toBeNull() + + fireEvent.click(disclosure) + expect(disclosure.getAttribute('aria-expanded')).toBe('true') + expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe( + '{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], ' + + '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }', + ) + + fireEvent.keyDown(disclosure, { key: ' ' }) + expect(disclosure.getAttribute('aria-expanded')).toBe('false') + }) + + it('context preserves the bounded JSON truncation contract', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: '上下文注入' })) + expect(view.container.querySelector('[data-context-injection-body]')?.textContent) + .toMatch(/… 已截断,共 \d+ 字符$/) + }) + + it('unknown nodes retain the generic JSON row', () => { const unknownView = render( , ) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4454afde3a..d807bd737a 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/host/directory-picker-browse/README.md -README.md: 318380405214d5f25ad77e348c4e134a8981ffb3 -README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754 +README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77 +README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 3183804052..23153881b8 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 2f88f64cc2..d7010e2941 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会立即提交目标,待父层级到达后再在其中重新选中目标的实际条目——双栏,因此后退绝不塌缩(父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 800af854a1..85349962e5 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -1,6 +1,7 @@ /* Directory-browser dialog (figma 813-23126 family). The shared Modal renders * headless here — mask, card, Escape only — and this module owns the figma - * frame: 600×420 card (viewport-clamped), header (title + crumbs, l3 separator), + * frame: 680×500 card (viewport-clamped; upsized from the figma 600×420), + * header (title + crumbs, l3 separator), * the one-or-two-column Miller content, and the bordered footer. */ /* Doubled class beats Modal's own .dialog regardless of stylesheet order. */ @@ -8,19 +9,26 @@ * columns scroll, so shrinking the height keeps Open/Cancel reachable * instead of clipping them below a fixed overlay. */ .dialog.dialog { - width: min(600px, 100%); - height: min(420px, calc(100dvh - 32px)); + width: min(680px, 100%); + height: min(500px, calc(100dvh - 32px)); padding: 0; gap: 0; } -/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */ +/* Card-scope wrapper hosting the path editor's Escape and focus-leave + * observers; display:contents keeps header/content/footer as direct flex + * children of the Modal card. */ +.editorScope { + display: contents; +} + +/* Header block: pl24 pr14 pt16 pb8, 8px between title row and crumb row. */ .header { display: flex; flex-direction: column; gap: 8px; flex: none; - padding: 22px 14px 12px 24px; + padding: 16px 14px 8px 24px; border-bottom: 1px solid var(--dsw-alias-border-l3); } @@ -54,8 +62,12 @@ align-items: stretch; flex: 1 1 0; min-height: 0; - gap: 20px; + /* 12px of row gap on each side of the divider; the left side reads wider + * by the column's trailing 8px scrollbar clearance, which is deliberate — + * the thumb needs that room, the right pane's rows do not. */ + gap: 12px; overflow-x: auto; + scrollbar-width: none; } .crumbTrail { @@ -126,28 +138,34 @@ color: var(--dsw-alias-label-primary); } -/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with - * the hairline divider centered between them; each column scrolls alone. */ +/* Miller content: symmetric 16px vertical padding so the divider clears the + * header and footer rules evenly; each column scrolls alone (column widths + * live at .column). */ .content { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; - padding: 16px 24px 0; + /* Right inset is slimmer than the left: the trailing column's own 8px + * scrollbar clearance makes up the optical difference. */ + padding: 16px 16px 16px 24px; } +/* Columns split the row evenly around the divider (a solo column takes the + * whole row); 256px is the floor below which the row scrolls (scrollbar + * hidden, the effect pins the child pane into view) instead of squeezing + * the panes. */ .column { display: flex; flex-direction: column; gap: 2px; - width: 256px; - flex: none; - overflow-y: auto; -} - -.columnWide { - width: 100%; flex: 1 1 0; + min-width: 256px; + overflow-y: auto; + /* The themed scrollbar occupies the column's edge (styled scrollbars are + * classic, gutter-taking ones); the extra clearance keeps the row pills + * clear of the thumb. */ + padding-right: 8px; } .divider { @@ -228,8 +246,8 @@ color: var(--dsw-alias-state-error-primary); } -/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed - * card leaves the figma 28px below the 36px buttons. */ +/* Footer: l3 separator on top, symmetric padding so the row sits vertically + * centered in the bar; New-folder and the show-hidden toggle pin left. */ .footerBar { display: flex; align-items: center; @@ -238,10 +256,42 @@ flex-wrap: wrap; gap: 8px; flex: none; - padding: 12px 24px 28px; + padding: 16px 24px; border-top: 1px solid var(--dsw-alias-border-l3); } +/* Show-hidden toggle: a subtle fixed-label text button left of the gap; + * the pressed state seats a check glyph after the label (Menu's selected + * vocabulary; trailing so the label never shifts) instead of flipping the + * wording. */ +.showHiddenToggle { + display: inline-flex; + align-items: center; + gap: 4px; + border: none; + background: transparent; + padding: 0; + font-size: 13px; + line-height: 20px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + white-space: nowrap; +} + +.showHiddenToggle:hover { + color: var(--dsw-alias-label-primary); +} + +.showHiddenToggle:disabled { + color: var(--dsw-alias-label-caption); + cursor: default; +} + +.showHiddenToggleActive { + color: var(--dsw-alias-label-primary); +} + .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f348f1dd8d..859667d115 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -1,22 +1,30 @@ /** * The in-app workspace-directory browser (figma Harness 813-23126 family): a - * 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls + * 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls * sideways, the columns scroll down) whose header carries the title, the selection-path * breadcrumb, and a click-to-edit path zone; below it a Miller view — one - * full-width level until a row is selected, then two 256px columns (level | - * selected folder's children) around a hairline divider. Selecting in the + * full-width level until a row is selected, then two columns splitting the + * row evenly (256px floor; level | selected folder's children) around a + * hairline divider. Navigations land selection-anchored: a crumb jump or a + * submitted path commits the target immediately, then re-selects it in its + * parent level once that level arrives, so stepping back keeps two panes + * away from the display root. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and * selects the created folder. Open adopts the selected folder, falling back * to the listed level. Pure consumer of the injected browse calls — the * owning flow decides what "Open" means and owns the workspace-creation - * error surface. Hidden entries are host-flagged and filtered here (a - * show-hidden toggle is deferred work, client-side only). + * error surface. Hidden entries are host-flagged and hidden by default; the + * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when + * on) reveals them (client-side only). The path editor opens seeded with a + * trailing separator, and while the draft's directory part names a listed + * level, its final segment prefix-filters that level's rows (a dot-led + * prefix also reveals the hidden entries it names). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, + Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' @@ -59,17 +67,59 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } +/** + * The listing's platform separator, inferred from the home path the host + * stamped — never from typed text or entry paths, where a backslash is a + * legal POSIX name character. Still a heuristic at the last step: a POSIX + * home directory whose own name contains a backslash would misread. + * TODO: replace with a host-stamped `separator` field on the wire + * DirectoryListing so the platform fact travels verbatim (the trade-off is + * recorded in the directory-picker capability seam Agent Note). + */ +function separatorOf(listing: DirectoryListing): '\\' | '/' { + return listing.home.includes('\\') ? '\\' : '/' +} + +/** + * The path draft's final segment, when its directory part is exactly the + * level `listing` lists — the segment the level prefix-filters on while the + * user types. Any other draft (no separator yet, or naming some other + * directory) leaves the level unfiltered. The directory part compares + * exactly (it is the host's own path text, reached by seeding or erasing); + * only the name filter downstream is case-insensitive. + */ +function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { + if (draft === null) return null + const sep = separatorOf(listing) + const cut = draft.lastIndexOf(sep) + if (cut === -1) return null + const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` + return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null +} + /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide }: { +function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix, pathEditing }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void - wide: boolean + showHidden: boolean + filterPrefix: string | null + pathEditing: boolean }) { + const visible = entries.filter((entry) => { + // The selection is exempt from both filters: it anchors the two-pane + // view (crumbs and the child pane point at it), so neither the hidden + // filter after a dot-reveal pick nor a prefix miss may orphan it. + if (entry.path === selectedPath) return true + if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true + }) return ( -
- {entries.filter(entry => !entry.hidden).map((entry) => { +
+ {visible.map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE @@ -80,6 +130,15 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide }: { aria-current={selected || undefined} className={clsx(css.row, selected && css.rowSelected)} disabled={busy} + // While the path editor is open, keep focus in it: a focus + // steal on mousedown would blur the editor and (in engines + // where the blur lands before our guards) drop this click. + // Outside editing, rows keep native focus behavior. + onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} + // Editing-time focus parking happens after commit (the + // DirectoryBrowser refocus effect): a right-pane pick replaces + // this very column, so focusing the clicked node here would + // still fall to body. onClick={() => { onPick(entry) }} > {selected @@ -110,6 +169,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) + // Show-hidden toggle state (pure client-side filter, reset on each open). + const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) const [creatingFolder, setCreatingFolder] = useState(false) @@ -156,28 +217,86 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return { seq, scan: listDirectory(path, controller.signal) } }, [supersede, listDirectory]) - /** Replace the whole view with one freshly listed level (no selection). */ + /** + * Launch a follow-up listing under the CURRENT supersession seq: a newer + * intent aborts it like the leg it continues, and it supersedes nothing. + */ + const continueScan = useCallback((path: string): Promise => { + const controller = new AbortController() + scanController.current = controller + return listDirectory(path, controller.signal) + }, [listDirectory]) + + /** + * Replace the whole view with a freshly navigated level. The target level + * commits the moment it arrives (single wide level: the editor closes and + * loading ends on this first settlement, so an Enter-submitted navigation + * is never withdrawn waiting on anything further). Away from the display + * root — the same collapse the crumb header renders, so crumbs and pane + * shape never disagree — a parent leg then upgrades the landing in place: + * the target's ACTUAL parent-level entry re-selected (left pane = parent, + * right pane = the target), so a crumb jump reads as stepping back one + * pane. A failed parent leg, or a truncated parent window that lacks the + * target, leaves the committed single-pane landing — the upgrade must + * never orphan the selection it exists to anchor. + */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) setLoading(true) setError(null) - scan.then((next) => { + scan.then((target) => { if (seq !== requestSeq.current) return - setParent(next) + setParent(target) setSelected(null) setChild(null) setLoading(false) setPathDraft(null) + // Arity is label-independent: only the collapsed chain's depth decides. + if (displayCrumbs(target, '').length < 2) return + const parentCrumb = target.crumbs.at(-2) + /* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */ + if (parentCrumb === undefined) return + continueScan(parentCrumb.path).then((parentLevel) => { + if (seq !== requestSeq.current) return + // Windows resolves a typed path preserving its case; anchor on the + // parent level's actual entry so selection comparisons hold. + const sep = separatorOf(parentLevel) + const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value) + const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) + if (match === undefined) return + setParent(parentLevel) + setSelected(match) + setChild(target) + }, () => { + // Swallows the parent-leg failure (its abort included): the + // committed single-pane landing stands, and nobody asked to see + // the parent level. + }) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) }) - }, [launchListing]) + }, [launchListing, continueScan]) + + // Editor-close focus parking (consumed by the refocus effect below the + // miller-row ref): a pick parks on the selection's row, Enter and an + // input-focused Escape park on the crumb edit zone that replaces the + // input. Pointer-out cancels never set (or clear) these — yanking focus + // back from wherever the user clicked would be worse than the fall. + const refocusPick = useRef(false) + const refocusEditZone = useRef(false) + const pathInputRef = useRef(null) + const editZoneRef = useRef(null) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) + // A pick while the path editor is open adopts the (filtered) row and + // closes the editor — the draft served its purpose. Focus re-parks on + // the selection after commit (see the refocus effect below). + if (pathDraft !== null) refocusPick.current = true + setPathDraft(null) setSelected(entry) setChild(null) setLoading(true) @@ -193,8 +312,31 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // An unreadable selection cannot be the committing target while the // breadcrumb still names the level: fall back to the single pane. setSelected(null) + // Clearing the selection can unmount the very row the pick parked + // focus on (a dot-revealed hidden row re-hides); the refocus effect + // re-parks on the edit zone only if focus actually fell to body. + refocusEditZone.current = true }) - }, [launchListing]) + }, [launchListing, pathDraft]) + + /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ + const cancelPathEdit = useCallback(() => { + // Cancel also withdraws a navigation the editor already launched: its + // late success must not jump to the cancelled path, so the pending + // request is superseded and the view leaves the loading state. + supersede() + setLoading(false) + setPathDraft(null) + setError(null) + // Editing may have superseded the selection's preview request; a + // selection with no preview would render a half-empty two-pane view, so + // cancel falls back to the single-pane level. + if (child === null) setSelected(null) + // With no level listed yet (the editor superseded the initial home + // listing), plain cancellation would leave a permanently blank picker: + // restart the home listing. + if (parent === null) navigate() + }, [supersede, child, parent, navigate]) /** A right-column pick advances the view one level: child becomes the level. */ const advance = useCallback((entry: DirectoryEntry) => { @@ -213,6 +355,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setSelected(null) setChild(null) setCreatingFolder(false) + setShowHidden(false) navigate() return } @@ -221,6 +364,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setPathDraft(null) setFolderDraft(null) setCreateError(null) + // A close mid-flight (failed Enter, then Cancel) may leave refocus + // flags armed; retire them so a later render cannot consume them. + refocusPick.current = false + refocusEditZone.current = false }, [open, navigate, supersede]) /** The folder a create or Open acts on: the selection, else the listed level. */ @@ -285,6 +432,37 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) + // Every editor exit that would drop focus to body re-parks it after + // commit, so keyboard traversal stays inside the dialog (the Modal has no + // focus trap): a pick lands on the selection's row — aria-current in the + // freshly rendered left pane, which survives even a right-pane advance + // replacing the picked button's column — while Enter and an input-focused + // Escape land on the crumb edit zone that replaces the input. + useEffect(() => { + if (pathDraft !== null) return + if (refocusPick.current) { + refocusPick.current = false + refocusEditZone.current = false + const rowHost = millerRowRef.current + /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ + if (rowHost === null) return + const row = rowHost.querySelector('button[aria-current="true"]') + /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ + if (row === null) return + row.focus() + return + } + if (refocusEditZone.current) { + refocusEditZone.current = false + // Re-park only when the close actually dropped focus to body; focus + // the user parked elsewhere (a surviving row) stays theirs. + if (document.activeElement !== document.body) return + const zone = editZoneRef.current + /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ + if (zone === null) return + zone.focus() + } + }) if (!open) return null const twoPane = selected !== null @@ -310,149 +488,216 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, className={clsx(css.dialog)} headless > -
-

{t('browser.title')}

-
- {pathDraft === null - ? ( - <> - - {crumbs.map((crumb, index) => ( - - {index > 0 && } - - - ))} - - {/* The empty zone right of the crumbs is the path-edit affordance. */} - + + ))} + + {/* The empty zone right of the crumbs is the path-edit affordance. */} +
+
+
+
+ {parent !== null && ( + )} -
-
-
-
- {parent !== null && ( - - )} - {twoPane && } - {twoPane && child !== null && ( - - )} -
- {loading &&
{t('browser.loading')}
} - {/* The backend bounds a level at its complete-result limit; say so + {twoPane && } + {twoPane && child !== null && ( + + )} +
+ {loading &&
{t('browser.loading')}
} + {/* The backend bounds a level at its complete-result limit; say so * whenever a visible pane was cut instead of letting the tail of a * huge directory go silently missing. */} - {(parent?.truncated === true || child?.truncated === true) && !loading + {(parent?.truncated === true || child?.truncated === true) && !loading &&
{t('browser.truncated')}
} - {error !== null &&
{error}
} -
-
- - - - + {error !== null &&
{error}
} +
+
+ + + + + +
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}