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 6855a0af2b..048536527a 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: 9884385cf9e0d51604bab9e4fd3c4bee77448331 -2026-07-28-directory-picker-capability-seam.zh.md: 8c229b9fb08d5052ba8a512f2153a89a9e5fd455 +2026-07-28-directory-picker-capability-seam.md: 01968990db81852dbf965a90fc151bab357ecb55 +2026-07-28-directory-picker-capability-seam.zh.md: ffbb939eabcca3e16711a4cadcadad50660a9e04 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 9884385cf9..01968990db 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 @@ -20,7 +20,8 @@ Placement and policy rulings folded into this decision: - **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 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, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: 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. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. +- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the WHOLE bar in the editor's own box — the bar carries the outline and padding in both modes, so the hover previews exactly the field the click produces and nothing resizes when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and any other directory part is scanned after a 250ms rest and lands through the same selection-anchored, two-pane landing every navigation uses, so typing a path moves the Miller view exactly as a crumb jump does — typing deeper descends, erasing segments walks back up — without leaving the editor. **The pane arity is the invariant**: the last pane always lists the level the path names, with its parent beside it and nothing but a display root listing alone. Skipping the scan whenever *any* pane happened to list the directory was the cheaper rule and the wrong one — erasing a segment then left the level being typed on the left with its own child pane still standing to its right, so the panes stopped reading as "where I am, and where I came from". Only the last pane's own tail costs no scan. One landing shape, two callers: a submitted path closes the editor and announces failures, the draft-following scan keeps both to itself. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. Two further rules keep one keystroke to one movement: the walk waits BOTH legs out rather than taking the submitted-navigation wait bound (nothing waits on a speculative scan, so landing single-pane and upgrading would be the very flash this exists to avoid, and it would strand the two-pane view whenever a tail keystroke aborted a slow parent leg), and the tail filters only the LAST pane — narrowing a pane the draft has walked away from would move the view once as it narrows and again as its landing replaces it. A level also keeps answering the directory text that produced it (`scanned`), because the Host resolves what it is given: `..` segments and, on Windows, forward slashes reach a level whose own path spells the request differently, and without the memo those drafts would rescan on every keystroke and never filter. +- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: 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. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. All three timing constants — the 200ms parent-leg bound, the 300ms silence window, and the editor's 250ms draft rest — are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs, and would pay rest plus RPC before the panes follow a typed path — revisit all three together when a remote consumer lands. - **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. @@ -34,6 +35,9 @@ Placement and policy rulings folded into this decision: - **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 permanently visible path input above the Miller view.** Rejected: the breadcrumb is already the "where am I" reading, and a second always-present field duplicates it while costing a row of a 500px card that the columns need. The glyph plus the hover-lit zone puts the affordance on the bar that already answers the question. +- **Scanning the draft on every keystroke, or only on Enter.** Per keystroke: walking one path segment issues a listing per character, most of them for directories the operator is typing through, not at. Only on Enter (what shipped first): the panes and the typed text disagreed for the whole edit — the complaint this bullet answers. The 250ms rest keeps one scan per directory the typing actually settles on. +- **Emptying a pane on a prefix miss (what shipped first).** Rejected: mid-name the miss is the normal state, so the pane blanked exactly while the operator needed it to confirm the name; releasing the filter keeps the level readable and costs only the transient wideness. - **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 8c229b9fb0..ffbb939eab 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 @@ -20,7 +20,8 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `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 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。 +- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时**整条栏**以编辑器自身的那只框亮起——轮廓与内边距在两种模式下都由栏承载,于是悬停预览的正是点击后出现的那只输入框,区域与输入框互换时也没有任何尺寸变化。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并经由每次导航共用的那套以选中项为锚的双栏落地落定,于是键入路径移动 Miller 视图的方式与 crumb 跳转完全一致——继续键入即下潜、删掉末段即上退——全程不必离开编辑器。**分栏个数才是不变量**:最后一栏永远是路径所指的那一层,其上一层在它旁边,只有展示根会独占一栏。"只要任意一栏碰巧列出了该目录就跳过扫描"是更省事、也是错的规则——删掉一段之后,正在键入的那一层会留在左栏,而它自己的子栏仍立在右边,于是两栏不再读作"我在哪儿、我从哪儿来"。只有最后一栏自己的末段不需要扫描。一种落地形态、两个调用方:提交的路径关闭编辑器并呈现失败,草稿跟随扫描则两者都不做。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键,而非以它指名的目录部分为键,于是顶掉在飞扫描的那次按键会重新武装等待,被拒提交之后的编辑也能释放那道扣留;而它读取的分栏是 ref 而非依赖,否则落地会重新武装等待,遇到以不同拼写作答的宿主便会永远扫描下去。以及,走过的路不回退:关闭编辑器——包括取消——都把分栏留在草稿带到的地方,由面包屑指明、Open 的兜底目标随之而动,因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行,焦点会被重新停回编辑器——Modal 并没有焦点陷阱。另有两条规则保证一次按键只让视图移动一次:这段行走会**等齐两程**,而不套用提交导航的等待上限(推测性扫描没有任何东西在等它,先落单栏再升级恰恰就是它要避免的那次闪动,而且一旦末段按键中止了缓慢的父层级这一程,双栏视图就会永久丢失);末段也只过滤**最后一栏**——去收窄一个草稿已经走开的分栏,会让视图先因收窄动一次、再因它自己的落地动一次。此外,层级会持续应答产生它的那段目录文本(`scanned`),因为宿主会规范化它收到的东西:`..` 段与 Windows 的正斜杠都会抵达一个自身路径拼写不同的层级;没有这份记忆,这类草稿会每敲一键就重扫一次,而且永远过滤不了。 +- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。三个时序常量——200ms 父层级上限、300ms 静默窗口,以及编辑器的 250ms 草稿停顿——都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态,而且要先付停顿再付 RPC 分栏才跟上——待远程消费方落地时,三者一并重新审视。 - **符号链接:为可进入性而跟随。** 用 `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 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 @@ -34,6 +35,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 +- **在 Miller 视图上方常驻一个路径输入框。** 否决:面包屑本就在回答"我在哪儿",再常驻一个字段是重复回答,还要从 500px 卡片里挪走一行——那是列需要的高度。图标加悬停亮起的区域,把这个入口放在了已经回答该问题的那一栏上。 +- **每敲一个键就扫描草稿,或只在 Enter 时扫描。** 每键扫描:走完一段路径就是每个字符一次列举,其中多数目录操作者只是路过而非停留。只在 Enter 时扫描(最初落地的行为):整个编辑过程中各栏与所键入文本各说各话——正是本条所回应的抱怨。250ms 的停顿把扫描收敛为"键入真正停下来的每个目录一次"。 +- **前缀无一匹配时清空该栏(最初落地的行为)。** 否决:名字敲到一半时"无匹配"才是常态,于是恰恰在操作者需要它确认名字时把栏清空了;解除过滤保住了层级的可读性,代价只是短暂的宽松。 - **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 6e9974fdce..d7e0f75f2d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.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-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: 8c2a7c42b7898776de5d42459b09c0fb1737ec0b -2026-07-27-trajectory-inspection-ledger.zh.md: 6c5733046dc2cfd3fd2bd005f4cf5c2d2bd110af +2026-07-27-trajectory-inspection-ledger.md: cdeaa30ea64f47b0e0110baf566f747a4591a384 +2026-07-27-trajectory-inspection-ledger.zh.md: df2a3d266161a7c1c4444863971f3d177533af8c diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index 8c2a7c42b7..cdeaa30ea6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -21,7 +21,9 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. -- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. +- Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay. - This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. ## Alternatives considered @@ -34,10 +36,12 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. +**Override the composer seat from Trajectory CSS.** Rejected: a cross-package selector would depend on generated class specificity and stylesheet order. An explicit view marker keeps seat geometry and active-phase precedence in `ConversationRoot`, while Trajectory owns only its internal clearance. + **Keep timing in a separate Waterfall tab.** Rejected: the placeholder summarized node counts rather than record timing and forced users to switch away from the rows they wanted to focus. A full-domain Overview keeps timing and filtered records in one visual context. **Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces. ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin projection, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview, and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin tail following, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index 6c5733046d..df2a3d2661 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -21,7 +21,9 @@ Status: implemented - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 -- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。 +- Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。 - 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 ## 曾考虑的替代方案 @@ -34,10 +36,12 @@ Status: implemented **复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 +**由 Trajectory CSS 覆盖 composer seat。** 不予采纳:跨包(package)选择器会依赖生成类选择器的优先级和样式表顺序。显式视图标记让 seat 几何形状和活跃阶段优先级留在 `ConversationRoot` 中,而 Trajectory 只负责自身内部的避让空间。 + **将计时保留在独立的 waterfall 标签页中。** 不予采纳:占位实现汇总的是节点数而非记录计时,并迫使用户离开想要聚焦的记录。保留完整时间范围的 Overview 区域让计时和筛选后的记录处于同一视觉上下文中。 **修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。 ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间与耗时数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定投影、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 区域与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定末尾跟随、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 35f77b96ec..4f13ea21cf 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -138,6 +138,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) await page.getByRole('tab', { name: 'Trajectory' }).click() await page.waitForTimeout(100) + const overlayLayout = await page.getByRole('table').evaluate((table) => { + const host = table.closest('[data-conversation-scroll]') + const seat = host?.querySelector('[data-composer-seat]') ?? null + const pane = table.parentElement + return { + hostPosition: host === null ? null : getComputedStyle(host).position, + paneOverflowX: pane === null ? null : getComputedStyle(pane).overflowX, + paneScrollableWidth: pane === null ? null : pane.scrollWidth - pane.clientWidth, + seatPosition: seat === null ? null : getComputedStyle(seat).position, + } + }) + expect(overlayLayout).toEqual({ + hostPosition: 'relative', + paneOverflowX: 'hidden', + paneScrollableWidth: 0, + seatPosition: 'absolute', + }) expect({ pageErrors: tripwire.pageErrors, slotErrors, @@ -153,6 +170,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.locator('tr[data-kind="tool"]').first().click() const details = page.getByRole('complementary', { name: 'Event details' }) await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1) + expect(await details.getByRole('tabpanel').evaluate(panel => getComputedStyle(panel).overflowX)) + .toBe('hidden') await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({ heading: getComputedStyle(heading).backgroundColor, @@ -162,6 +181,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) await page.getByRole('tab', { name: 'Result' }).click() await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) + const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first() + await assistantSpan.hover() + const timingTooltip = page.getByRole('tooltip') + await timingTooltip.waitFor({ timeout: 5_000 }) + await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/) + const assistantTimingStyle = await assistantSpan.evaluate(node => ({ + background: getComputedStyle(node).backgroundImage, + ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'), + })) + expect(assistantTimingStyle.background).toContain('linear-gradient') + expect(assistantTimingStyle.ttft).toMatch(/%$/) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1b8afbb247..7a48c4d8ac 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -516,6 +516,7 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { // shape so goldens stay stable across midnight and year boundaries. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') .replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') + .replace(/(? { + // The panes must track the draft without leaving the editor, so the + // typed text and what is listed under it never disagree. + // Staged by this scenario itself (mkdir is recursive and idempotent), so + // running it alone through -t sees the same tree the assertions describe. + const staged = join(scaffold.workspaceCwd, 'browse-golden') + await mkdir(join(staged, 'alpha', 'only-under-alpha'), { recursive: true }) + await mkdir(join(staged, 'beta'), { recursive: true }) + const dialog = await browseTo(staged) + await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await dialog.getByRole('button', { name: 'Edit path' }).click() + const path = dialog.getByLabel('Edit path') + // A directory part no pane lists: the panes walk to it, landing the + // ordinary two-pane Miller view (level | its children) with the editor + // still up and the draft intact. + await path.fill(`${join(staged, 'alpha')}${sep}`) + await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2) + expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`) + // Erasing back past the separator walks the panes up, so the level being + // typed is the last pane again (its children no longer stand to its + // right) and the tail filters it. + await path.fill(`${staged}${sep}al`) + await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(0) + expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0) + await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2) + // A tail nobody matches is a name still being spelled: the level shows + // whole instead of emptying under it. + await path.fill(`${staged}${sep}zzz`) + await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1) + await dialog.getByRole('button', { name: 'Cancel' }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + /** * Expand Ungrouped and return its seeded session row. The only visible child * is the non-blank persisted Session; the blank Session created while diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index be480eaea2..1a83efb551 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -208,6 +208,26 @@ ); } +/* Views may opt into a composer overlay while ConversationRoot retains + ownership of the seat geometry and its active-phase precedence. */ +.scrollBody:has([data-conversation-composer-overlay]) { + position: relative; + overflow: hidden; +} + +.scrollBody:has([data-conversation-composer-overlay]) > .viewArea { + flex: 1 1 0; + min-height: 0; + overflow: hidden; +} + +.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat { + position: absolute; + right: 0; + bottom: 0; + left: 0; +} + /* Hero phase: the composer stack (hero chrome + workspace row + card) is flex-centered in the column; composer phase docks it at the bottom. Flex, NOT absolute+transform: a transform would make this box the containing diff --git a/packages/client/ui-primitives/src/Tooltip.module.css b/packages/client/ui-primitives/src/Tooltip.module.css index 5853531bd4..4da0eebc2d 100644 --- a/packages/client/ui-primitives/src/Tooltip.module.css +++ b/packages/client/ui-primitives/src/Tooltip.module.css @@ -14,7 +14,7 @@ color: var(--dsw-static-neutral-bluish-00); font-size: 14px; line-height: 22px; - white-space: nowrap; + white-space: pre-line; pointer-events: none; animation: tooltip-in 150ms var(--ds-ease-in-out); } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 30e0c5fe0e..e85583a50c 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -1,6 +1,6 @@ // Hover/focus label bubble (figma tooltip pill: dark plate, white text). -// TODO: interaction is a placeholder (no show delay, no flip on viewport -// collision, no arrow) — visuals and behavior get a proper pass later. +// TODO: interaction is a placeholder (no flip on viewport collision or +// arrow) — visuals and behavior get a proper pass later. // The anchor is the child element itself (cloneElement, no wrapper node), so // attaching a tooltip never changes the anchor's layout context. The bubble is // position:fixed and coordinates come from the anchor's rect at show time, so @@ -27,12 +27,13 @@ interface AnchorProps { * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text. * @param props.side - placement relative to the anchor (default 'right'). + * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. * @param props.disabled - suppress the bubble while true; the anchor renders identically so * toggling never remounts it (which would cut its CSS transitions). * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused. */ -export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement }) { +export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -43,15 +44,26 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { else if (childRef != null) (childRef as MutableRefObject).current = el }, [childRef]) const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + const showTimer = useRef | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). const triggers = useRef({ hover: false, focus: false }) // Disabling mid-hover (e.g. clicking a rail control expands the sidebar) // must drop an already-visible bubble: no mouseleave fires. + const cancelShow = useCallback(() => { + if (showTimer.current === null) return + clearTimeout(showTimer.current) + showTimer.current = null + }, []) useEffect(() => { - if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) } - }, [disabled]) + if (disabled) { + cancelShow() + triggers.current = { hover: false, focus: false } + setPos(null) + } + return cancelShow + }, [cancelShow, disabled]) const show = () => { if (disabled) return @@ -63,7 +75,19 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { ? { x: r.right + 10, y: r.top + r.height / 2 } : { x: r.left + r.width / 2, y: r.bottom + 8 }) } + const showAfterHoverDelay = () => { + cancelShow() + if (delayMs <= 0) { + show() + return + } + showTimer.current = setTimeout(() => { + showTimer.current = null + show() + }, delayMs) + } const hide = () => { + cancelShow() if (!triggers.current.hover && !triggers.current.focus) setPos(null) } @@ -71,9 +95,9 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { <> {cloneElement(children, { ref: mergedRef, - onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() }, - onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) }, - onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() }, + onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) }, + onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {pos !== null && ( diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 591a5eb67a..5dc9a1a378 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -1,11 +1,37 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('Tooltip', () => { + it('can delay pointer hover without delaying keyboard focus', () => { + vi.useFakeTimers() + try { + render( + + + , + ) + const anchor = screen.getByText('anchor') + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseLeave(anchor) + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByRole('tooltip').textContent).toBe('Timing details') + fireEvent.mouseLeave(anchor) + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Timing details') + } finally { + vi.useRealTimers() + } + }) + it('shows the bubble to the right on hover and hides it on leave', () => { render( diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 41c954adc0..36e56c4569 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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-trajectory/README.md -README.md: a65c11aed9dd74f9b0b60795441f876c1d64b3ad -README.zh.md: 6e25d24c6b65673b3d003e624b6e0727be60c0e1 +README.md: 5d0ea3bbbbfca2b8c0ee02ed07ca956fbd377e11 +README.zh.md: 1bfff4c18ea2e834781e2c6cb76773595eeed5ad diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index a65c11aed9..5d0ea3bbbb 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 6e25d24c6b..1bfff4c18e 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 86cc8dfd8b..0b1cbf8030 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -15,7 +15,9 @@ .tablePane { flex: 1; min-width: 0; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px); container: trajectory-table / inline-size; } @@ -27,7 +29,7 @@ ); width: 100%; - min-width: 480px; + min-width: 0; border-spacing: 0; table-layout: fixed; color: var(--dsw-alias-label-primary); @@ -104,6 +106,11 @@ border-bottom: 0; } +.table tbody tr[data-request-only='true']:has(+ tr[data-request-only='true']) td { + /* Keep consecutive boundary markers from painting their halos over one another. */ + height: 9px; +} + .table tbody tr[data-request-only='true']:last-child td { /* Retain the lower half of the 16px boundary marker at the table's end. */ height: 9px; @@ -277,7 +284,7 @@ z-index: 3; top: 0; left: 0; - display: inline-flex; + display: inline-grid; flex: none; align-items: center; box-sizing: border-box; @@ -292,8 +299,18 @@ white-space: nowrap; } +.turnLabelFull, .turnLabelCompact { - display: none; + grid-area: 1 / 1; + max-width: 64px; + overflow: hidden; + opacity: 1; + white-space: nowrap; +} + +.turnLabelCompact { + max-width: 0; + opacity: 0; } .turnLabelActive { @@ -350,15 +367,23 @@ } .kindTagIcon { - display: none; + display: inline-flex; + flex: none; align-items: center; justify-content: center; - width: 13px; + width: 0; height: 13px; + overflow: hidden; + opacity: 0; + transform: scale(0.8); } .kindTagLabel { - display: inline; + display: inline-block; + max-width: 72px; + overflow: hidden; + opacity: 1; + white-space: nowrap; } .table .kindSlot .message { @@ -393,19 +418,66 @@ } .kindTagIcon { - display: inline-flex; + width: 13px; + opacity: 1; + transform: scale(1); } .kindTagLabel { - display: none; + max-width: 0; + opacity: 0; } .turnLabelFull { - display: none; + max-width: 0; + opacity: 0; } .turnLabelCompact { - display: inline; + max-width: 64px; + opacity: 1; + } +} + +@media (prefers-reduced-motion: no-preference) { + .eventColumn, + .event, + .requestBoundaryControl, + .kindSlot, + .kindTag, + .kindTagIcon, + .kindTagLabel, + .turnLabelFull, + .turnLabelCompact { + transition-duration: 180ms; + transition-timing-function: var(--ds-ease-in-out); + } + + .eventColumn, + .kindSlot { + transition-property: width; + } + + .event { + transition-property: padding-right, padding-left; + } + + .requestBoundaryControl { + transition-property: left; + } + + .kindTag { + transition-property: padding-right, padding-left; + } + + .kindTagIcon { + transition-property: width, opacity, transform; + } + + .kindTagLabel, + .turnLabelFull, + .turnLabelCompact { + transition-property: max-width, opacity; } } @@ -834,7 +906,9 @@ .detailBody { flex: 1; min-height: 0; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px); scrollbar-gutter: stable; } @@ -842,7 +916,7 @@ display: flex; box-sizing: border-box; flex-direction: column; - padding-bottom: 12px; + padding-bottom: calc(12px + var(--dsh-trajectory-bottom-clearance, 0px)); overflow: hidden; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 4f88bf0bca..7973649cf5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,6 +1,6 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { IconChevronRightOutline14, @@ -22,6 +22,8 @@ import { formatElapsedSeconds } from './trajectory-record.ts' import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' import css from './TrajectoryTable.module.css' +const BOTTOM_FOLLOW_THRESHOLD_PX = 2 + const KIND_LABEL: Record = { system: 'SYSTEM', user: 'USER', @@ -1711,6 +1713,9 @@ export function TrajectoryTable({ // ledger has rendered. Not-found leaves the request pending (`turns` in the // deps retries as history pages in); the ack clears the store field. const rootRef = useRef(null) + const tablePaneRef = useRef(null) + const followsTableTail = useRef(false) + const tableScrollInitialized = useRef(false) const pendingScrollIndex = useRef(null) const openRecordSummaryRef = useRef(openRecordSummary) openRecordSummaryRef.current = openRecordSummary @@ -1734,11 +1739,30 @@ export function TrajectoryTable({ row.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }) + useLayoutEffect(() => { + const pane = tablePaneRef.current + if (pane === null) return + if (!tableScrollInitialized.current) { + tableScrollInitialized.current = true + followsTableTail.current = + pane.scrollHeight - pane.clientHeight - pane.scrollTop + <= BOTTOM_FOLLOW_THRESHOLD_PX + return + } + if (followsTableTail.current) pane.scrollTop = pane.scrollHeight + }, [turns]) return (
{ + const pane = event.currentTarget + followsTableTail.current = + pane.scrollHeight - pane.clientHeight - pane.scrollTop + <= BOTTOM_FOLLOW_THRESHOLD_PX + }} onClick={(event) => { if (event.target === event.currentTarget) clearAllSelections() }} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 1ca5ab2627..4d548e64d8 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -7,6 +7,10 @@ user-select: none; } +.root :global([role='tooltip']) { + font: var(--dsw-font-xxxs-11); +} + .plot { display: grid; grid-template-columns: 44px minmax(0, 1fr); @@ -53,6 +57,10 @@ touch-action: none; } +.track[data-panning='true'] { + cursor: grabbing; +} + .empty { position: absolute; top: 50%; @@ -105,8 +113,15 @@ .span { position: absolute; top: calc(var(--trajectory-span-lane) * 14px); - left: calc(var(--trajectory-span-left) + 1px); - width: max(2px, calc(var(--trajectory-span-width) - 2px)); + left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap)); + width: max( + 2px, + calc( + var(--trajectory-span-width) + - var(--trajectory-span-gap) + - var(--trajectory-span-gap) + ) + ); height: 8px; min-width: 2px; border-radius: 1px; @@ -127,23 +142,35 @@ } .span[data-timeline-span='message'] { - background: color-mix( + --trajectory-assistant-decoding-color: color-mix( in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%, var(--dsw-alias-state-error-secondary) ); -} - -.span[data-timeline-span='tool'] { - background: var(--dsw-alias-state-warn-label); -} - -.span[data-timeline-span='subtool'] { - background: color-mix( + --trajectory-assistant-ttft-color: color-mix( in srgb, - var(--dsw-alias-state-warn-label) 62%, - var(--dsw-alias-label-tertiary) + var(--trajectory-assistant-decoding-color) 54%, + var(--dsw-alias-bg-layer-2) ); + + background: var(--trajectory-assistant-decoding-color); + opacity: 1; +} + +.span[data-timeline-span='message'][data-assistant-timing='true'] { + background: linear-gradient( + to right, + var(--trajectory-assistant-ttft-color) 0, + var(--trajectory-assistant-ttft-color) var(--trajectory-assistant-ttft), + var(--trajectory-assistant-decoding-color) var(--trajectory-assistant-ttft), + var(--trajectory-assistant-decoding-color) 100% + ); +} + +.span[data-timeline-span='tool'], +.span[data-timeline-span='subtool'] { + background: var(--dsw-alias-state-warn-label); + opacity: 1; } .span[data-error='true'] { @@ -161,7 +188,7 @@ .span[data-hovered='true']:not([data-current='true']) { z-index: 1; - opacity: 0.78; + opacity: 1; box-shadow: 0 0 0 1px var(--dsw-alias-bg-layer-2), 0 0 0 2px color-mix( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 0fd0825f95..87d7cdcd34 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -4,7 +4,9 @@ import { memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, } from 'react' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { TrajectoryTurnModel } from './layout.ts' +import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' import { deriveTrajectoryTimeline, formatTimelineOffset, @@ -18,6 +20,14 @@ const MINIMUM_ZOOM_OPERATIONS = 4 const EDGE_PAN_ZONE_FRACTION = 0.08 const EDGE_PAN_STEP_FRACTION = 0.025 const MAXIMUM_EDGE_PAN_PX = 32 +const TIMELINE_TOOLTIP_DELAY_MS = 500 + +interface TimelineRecordDetail { + decodingMs?: number + durationMs?: number + startedAt?: number + ttftMs?: number +} interface FractionRange { start: number @@ -29,6 +39,94 @@ interface HoverPoint { recordIndex: number | null } +interface PanGesture { + anchorClientX: number + anchorStart: number + moved: boolean + pannable: boolean + pointerId: number +} + +function assistantTimingDetail( + metrics: AssistantMetricDetail | undefined, +): Pick { + const start = metrics?.stepStartTime + const first = metrics?.firstTokenTime + const completed = metrics?.completedTime + if ( + metrics?.timingRecorded !== true + || typeof start !== 'number' + || typeof first !== 'number' + || typeof completed !== 'number' + || !Number.isFinite(start) + || !Number.isFinite(first) + || !Number.isFinite(completed) + || first < start + || completed < first + ) return {} + return { ttftMs: first - start, decodingMs: completed - first } +} + +function timelineRecordDetail(cell: TrajectoryCellProps): TimelineRecordDetail { + const durationMs = cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds) + ? undefined + : Math.max(0, cell.timeSeconds * 1_000) + const startedAt = cell.startedAt === null || !Number.isFinite(cell.startedAt) + ? undefined + : cell.startedAt + return { + ...(durationMs === undefined ? {} : { durationMs }), + ...(startedAt === undefined ? {} : { startedAt }), + ...assistantTimingDetail(cell.assistantMetrics), + } +} + +function timelineKindLabel(kind: TrajectoryCellKind): string { + switch (kind) { + case 'system': return 'SYSTEM' + case 'user': return 'USER' + case 'context': return 'CONTEXT' + case 'compacted': return 'COMPACTED' + case 'message': return 'ASSISTANT' + case 'tool': return 'TOOL' + case 'subtool': return 'SUBTOOL' + } +} + +function formatRecordedTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }) +} + +function timelineTooltipLabel( + kind: TrajectoryCellKind, + detail: TimelineRecordDetail | undefined, +): string { + const heading = timelineKindLabel(kind) + if (detail === undefined) return heading + const duration = detail.durationMs === undefined + ? null + : `Total ${formatTimelineOffset(detail.durationMs)}` + const range = detail.startedAt === undefined + ? null + : detail.durationMs === undefined + ? `Started ${formatRecordedTime(detail.startedAt)}` + : `${formatRecordedTime(detail.startedAt)} → ${formatRecordedTime( + detail.startedAt + detail.durationMs, + )}` + const segments = detail.ttftMs === undefined || detail.decodingMs === undefined + ? null + : `TTFT ${formatTimelineOffset(detail.ttftMs)} · Decoding ${formatTimelineOffset( + detail.decodingMs, + )}` + const timing = [duration, segments].filter(value => value !== null).join(' · ') + return [heading, range, timing].filter(value => value !== null && value !== '').join('\n') +} + /** Props for the fixed full-domain overview above the trajectory ledger. */ export interface TrajectoryTimelineProps { turns: readonly TrajectoryTurnModel[] @@ -105,14 +203,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ onRecordFocus, }: TrajectoryTimelineProps) { const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns]) - const durationByIndex = useMemo( + const detailByIndex = useMemo( () => new Map(turns.flatMap(turn => turn.groups.flatMap(group => - group.cells.flatMap(cell => - cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds) - ? [] - : [[cell.index, Math.max(0, cell.timeSeconds * 1_000)] as const], - ), + group.cells.map(cell => [cell.index, timelineRecordDetail(cell)] as const), ), )), [turns], @@ -123,10 +217,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ anchorClientX: number recordIndex: number | null } | null>(null) + const panRef = useRef(null) const rootRef = useRef(null) const trackRef = useRef(null) const [draft, setDraft] = useState(null) const [hover, setHover] = useState(null) + const [panning, setPanning] = useState(false) const [viewport, setViewport] = useState(null) const [animateViewport, setAnimateViewport] = useState(false) useEffect(() => { @@ -267,6 +363,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } const onPointerDown = (event: PointerEvent) => { + if (event.button === 2) { + panRef.current = { + anchorClientX: event.clientX, + anchorStart: domainStart, + moved: false, + pannable: viewport !== null, + pointerId: event.pointerId, + } + if (viewport !== null) setAnimateViewport(false) + setPanning(true) + if (typeof event.currentTarget.setPointerCapture === 'function') { + event.currentTarget.setPointerCapture(event.pointerId) + } + return + } if (event.button !== 0) return const anchor = fractionAt(event) const anchorTime = domainStart + anchor * domainDuration @@ -285,10 +396,24 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } const onPointerMove = (event: PointerEvent) => { - const drag = dragRef.current const rect = event.currentTarget.getBoundingClientRect() const fraction = fractionAt(event) setHover({ fraction, recordIndex: recordIndexAt(event) }) + const pan = panRef.current + if (pan !== null && pan.pointerId === event.pointerId) { + if (Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX) { + pan.moved = true + } + if (!pan.pannable) return + const delta = (event.clientX - pan.anchorClientX) / Math.max(1, rect.width) + const nextStart = Math.min( + Math.max(pan.anchorStart - delta * domainDuration, model.start), + model.end - domainDuration, + ) + setViewport({ start: nextStart, end: nextStart + domainDuration }) + return + } + const drag = dragRef.current if (drag === null || drag.pointerId !== event.pointerId) return let nextDomainStart = domainStart if (viewport !== null) { @@ -326,6 +451,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } const onPointerEnd = (event: PointerEvent) => { + const pan = panRef.current + if (pan !== null && pan.pointerId === event.pointerId) { + const moved = pan.moved + || Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX + panRef.current = null + setPanning(false) + if (!moved) onRangeChange(null) + return + } const drag = dragRef.current if (drag === null || drag.pointerId !== event.pointerId) return const pointFraction = fractionAt(event) @@ -375,8 +509,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ const onPointerCancel = () => { dragRef.current = null + panRef.current = null setDraft(null) setHover(null) + setPanning(false) } return ( @@ -386,6 +522,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
{ - if (dragRef.current === null) setHover(null) + if (dragRef.current === null && panRef.current === null) setHover(null) }} onDoubleClick={(event) => { event.preventDefault() @@ -402,9 +539,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ }} onContextMenu={(event) => { event.preventDefault() - setAnimateViewport(false) - onRangeChange(null) - setViewport(null) }} > {hover !== null && hover.recordIndex === null && draft === null && ( @@ -466,7 +600,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ className={css.lanes} data-animate-viewport={animateViewport || undefined} data-timeline-domain - aria-hidden="true" style={projectedDomainStyle} > {model.spans @@ -476,34 +609,51 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ .map((span) => { const left = (span.start - model.start) / fullDuration const width = (span.end - span.start) / fullDuration - const durationMs = durationByIndex.get(span.index) + const widthPercent = Math.max(width * 100, 0.35) + const detail = detailByIndex.get(span.index) + const ttftMs = detail?.ttftMs + const decodingMs = detail?.decodingMs + const ttftFraction = ttftMs === undefined + || decodingMs === undefined + || ttftMs + decodingMs <= 0 + ? null + : ttftMs / (ttftMs + decodingMs) return ( - = activeRange.start - ? 'true' - : 'false'} + + label={timelineTooltipLabel(span.kind, detail)} + side="bottom" + delayMs={TIMELINE_TOOLTIP_DELAY_MS} + > + ) })}
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index d62074a26c..9ba75abaac 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -459,7 +459,7 @@ export function TrajectoryView({ } return ( -
+
{ diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 326ac41a99..687f4a4657 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -13,19 +13,6 @@ background: var(--dsw-alias-bg-layer-1); } -/* Trajectory keeps the ledger and details panel inside the remaining - * conversation height. Only the ledger pane scrolls; the composer remains - * the fixed flex sibling below this view. */ -:global([data-conversation-scroll]):has(.root) { - overflow: hidden; -} - -:global([data-conversation-scroll]):has(.root) > :first-child { - flex: 1 1 0; - min-height: 0; - overflow: hidden; -} - .ledger { position: relative; z-index: 0; @@ -35,4 +22,7 @@ min-height: 0; min-width: 0; overflow: hidden; + + /* ConversationRoot publishes the floating composer's live height. */ + --dsh-trajectory-bottom-clearance: calc(var(--dsh-composer-height, 152px) + 16px); } diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index f02eb6e0cc..65c3da3255 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -160,6 +160,53 @@ describe('TrajectoryTable', () => { expect(onClearSelection).toHaveBeenCalledOnce() }) + it('follows appended records only while the ledger is already at the bottom', () => { + const view = render() + const tablePane = screen.getByRole('table').parentElement as HTMLElement + let scrollHeight = 200 + Object.defineProperties(tablePane, { + clientHeight: { configurable: true, get: () => 100 }, + scrollHeight: { configurable: true, get: () => scrollHeight }, + }) + tablePane.scrollTop = 100 + fireEvent.scroll(tablePane) + + scrollHeight = 260 + view.rerender( + , + ) + expect(tablePane.scrollTop).toBe(260) + + tablePane.scrollTop = 20 + fireEvent.scroll(tablePane) + scrollHeight = 320 + view.rerender( + , + ) + expect(tablePane.scrollTop).toBe(20) + }) + it('keeps running and failure semantics distinct from record roles', () => { const view = render() expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy() diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index fca6608950..0650e28746 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -9,7 +9,7 @@ */ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -288,6 +288,7 @@ describe('tab switching in ConversationRoot', () => { expect(screen.queryByRole('columnheader')).toBeNull() expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() + expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) @@ -445,7 +446,7 @@ describe('tab switching in ConversationRoot', () => { .toBe('outside') fireEvent.contextMenu(plot) expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) - .toBeNull() + .toBe('outside') }) it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => { @@ -528,6 +529,57 @@ describe('timeline projection', () => { }], }] satisfies readonly TrajectoryTurnModel[] + it('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => { + vi.useFakeTimers() + try { + const view = render( + , + ) + const span = view.container.querySelector( + '[data-timeline-span="message"]', + ) + expect(span?.getAttribute('title')).toBeNull() + expect(span?.getAttribute('data-assistant-timing')).toBe('true') + expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%') + + fireEvent.mouseEnter(span as HTMLElement) + act(() => { vi.advanceTimersByTime(499) }) + expect(view.container.querySelector('[role="tooltip"]')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + const tooltip = view.container.querySelector('[role="tooltip"]') + expect(tooltip?.textContent).toContain('Total 2.0 s') + expect(tooltip?.textContent).toContain('TTFT 500 ms') + expect(tooltip?.textContent).toContain('Decoding 1.5 s') + } finally { + vi.useRealTimers() + } + }) + it('cancels native scrolling across the timeline while zooming', () => { render( { })).toBe(false) }) + it('scales sequence gutters with narrow operation spans', () => { + const view = render( + , + ) + const span = view.container.querySelector('[data-timeline-span]') + expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%') + expect(span?.style.getPropertyValue('--trajectory-span-gap')) + .toBe('clamp(0.25px, 0.8%, 1px)') + }) + + it('clears the selection without changing zoom on a zoomed right click', () => { + const onRangeChange = vi.fn() + const view = render( + , + ) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ + x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, + toJSON: () => ({}), + }) + fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 }) + const domain = view.container.querySelector('[data-timeline-domain]') + const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width') + expect(domainWidth).not.toBe('100%') + + fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(fireEvent.contextMenu(plot)).toBe(false) + fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 }) + + expect(onRangeChange).toHaveBeenCalledOnce() + expect(onRangeChange).toHaveBeenCalledWith(null) + expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth) + }) + + it('clears the selection and suppresses the context menu at full zoom', () => { + const onRangeChange = vi.fn() + render( + , + ) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + + fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(fireEvent.contextMenu(plot)).toBe(false) + fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(onRangeChange).toHaveBeenCalledOnce() + expect(onRangeChange).toHaveBeenCalledWith(null) + }) + + it('pans the zoomed viewport with a right-button drag without changing the selection', () => { + const onRangeChange = vi.fn() + const view = render( + , + ) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ + x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, + toJSON: () => ({}), + }) + fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 }) + const domain = view.container.querySelector('[data-timeline-domain]') + const before = domain?.style.getPropertyValue('--trajectory-domain-left') + + fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(plot.getAttribute('data-panning')).toBe('true') + expect(fireEvent.contextMenu(plot)).toBe(false) + fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 }) + fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 }) + + expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before) + expect(onRangeChange).not.toHaveBeenCalled() + expect(plot.getAttribute('data-panning')).toBeNull() + }) + it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => { const onRangeChange = vi.fn() const view = render( diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 673d053e3a..7c2df43ab2 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: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c -README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f +README.md: 62384cc0b0e5756e56c1d608252721c506a0915f +README.zh.md: 8f495e1e4d87486d0565eadcbf7df694494c7096 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 52b5fe7e89..62384cc0b0 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 whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; 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). +**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 and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; 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, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; 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 every filter; 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 742da39470..8f495e1e4d 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 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;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)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `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 2f207e4195..b5fbb611a4 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -49,11 +49,29 @@ color: var(--dsw-alias-label-primary); } +/* The bar IS the editor's box in both modes: it carries the rounded outline + * and the inner padding, the crumbs and the input sit inside it, and hovering + * the edit zone lights the whole row rather than the remainder right of the + * crumbs. The negative left margin pays back the border and padding, so the + * crumb (and input) text keeps the column the title sits in. */ .crumbBar { display: flex; align-items: center; gap: 4px; - min-height: 20px; + box-sizing: border-box; + min-height: 24px; + margin-left: -9px; + padding: 0 8px; + border: 1px solid transparent; + border-radius: 8px; +} + +/* Lit by the affordance the row belongs to, never by a crumb: a crumb's hover + * offers navigation, not path entry. Editing keeps the outline standing. */ +.crumbBar:has(.crumbEditZone:enabled:hover), +.crumbBar:has(.crumbEditZone:focus-visible), +.crumbBar:has(.pathInput) { + border-color: var(--dsw-alias-border-l2); } /* Deep chains scroll inside the trail (the effect pins the tail into view) @@ -118,25 +136,50 @@ color: var(--dsw-alias-label-tertiary); } -/* The empty remainder of the bar: invisible, but a real click target that - * flips the bar into path-edit mode. */ +/* The empty remainder of the bar: a real click target that flips the bar into + * path-edit mode. The pencil glyph seated at its right edge is the standing + * affordance; the outline the gesture lights belongs to the bar, so the whole + * row reads as the box the input will occupy. */ .crumbEditZone { + display: flex; + align-items: center; + justify-content: flex-end; flex: 1 0 34px; min-width: 34px; - align-self: stretch; + height: 22px; + padding: 0; border: none; background: transparent; cursor: text; + outline: none; } +.crumbEditGlyph { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.crumbEditZone:enabled:hover .crumbEditGlyph, +.crumbEditZone:focus-visible .crumbEditGlyph { + color: var(--dsw-alias-label-primary); +} + +.crumbEditZone:disabled { + cursor: default; +} + +.crumbEditZone:disabled .crumbEditGlyph { + color: var(--dsw-alias-label-caption); +} + +/* Chrome-free: the bar around it draws the box (border, radius, padding). */ .pathInput { box-sizing: border-box; flex: 1 1 0; min-width: 0; - height: 24px; - padding: 0 8px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 8px; + height: 22px; + padding: 0; + border: none; outline: none; background: transparent; font-size: 13px; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f5510fda7c..fc6dde82f1 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -18,15 +18,27 @@ * owning flow decides what "Open" means and owns the workspace-creation * 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). + * on) reveals them (client-side only). The path editor announces itself with + * a pencil glyph and a bar-wide hover-lit outline, opens seeded with a + * trailing separator, and keeps the panes under the draft: the final segment + * prefix-filters the LAST pane while that pane's level is the one the draft's + * directory part names (a dot-led prefix also reveals the hidden entries it + * names, and a prefix nobody matches releases the filter), while any other + * directory part is scanned after a short debounce and lands like any other + * navigation — selection-anchored and two-pane away from the display root, + * both legs waited out so one keystroke moves the view once. The pane arity + * holds throughout: the last pane is the level the path names and the one + * beside it is its parent, so typing deeper descends and erasing segments + * walks back up, moving the Miller view without leaving the editor. Panes the + * draft walked to stay put when the editor closes (cancellation included): + * the crumbs name where the walk ended, and Open's fallback target follows + * them. */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, + Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, 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' @@ -75,6 +87,15 @@ const SLOW_SCAN_DELAY_MS = 300 */ const PARENT_LEG_WAIT_MS = 200 +/** + * How long a typed draft rests before the panes follow it to a directory no + * pane lists. The window absorbs the keystrokes that walk through + * intermediate directory parts (every character of `/usr/lo` past the + * separator would otherwise be its own scan) while staying short enough that + * a pause reads as "the list moved with me". + */ +const DRAFT_PREVIEW_DEBOUNCE_MS = 250 + /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled @@ -100,21 +121,89 @@ 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 +/** The listed level as a directory part: its own path, separator-terminated (the root already is). */ +function levelDirectory(listing: DirectoryListing): string { 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 + return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` +} + +/** The directory text a draft-following scan last sent, with the level path the host answered it with. */ +interface ScannedDirectory { + /** The draft's directory part, verbatim as it went to the host. */ + readonly directory: string + /** `path` of the listing that came back. */ + readonly landed: string +} + +/** + * The draft's directory part — everything through its last separator — or + * null while no separator has been typed at all (nothing addresses a + * directory yet). The platform comes from `listing`: on Windows a forward + * slash separates too (the host's `resolve` accepts either), while on POSIX a + * backslash is a legal name character and never separates. + */ +function draftDirectory(listing: DirectoryListing, draft: string): string | null { + const cut = separatorOf(listing) === '\\' + ? Math.max(draft.lastIndexOf('\\'), draft.lastIndexOf('/')) + : draft.lastIndexOf('/') + return cut === -1 ? null : draft.slice(0, cut + 1) +} + +/** + * How the draft reads against one level: the directory part it names, and — + * when `listing` is the level that directory part addresses — the final + * segment that prefix-filters it while the user types (case-insensitively, + * downstream). A level answers a directory part when its own path is that + * part, or when it is the level that very text just produced (`scanned`): the + * host resolves what it is given, so `..` segments and Windows forward + * slashes reach a level whose path spells the request differently. + * @param listing - the level to read the draft against. + * @param draft - the current path draft. + * @param scanned - the last draft-following scan's directory and landing. + * @returns the draft's directory part (null with no separator typed) and its + * filtering tail (null when this level does not answer that directory). + */ +function readDraft( + listing: DirectoryListing, + draft: string, + scanned: ScannedDirectory | null, +): { directory: string | null; tail: string | null } { + const directory = draftDirectory(listing, draft) + if (directory === null) return { directory: null, tail: null } + const answers = directory === levelDirectory(listing) + || (scanned !== null && scanned.directory === directory && scanned.landed === listing.path) + return { directory, tail: answers ? draft.slice(directory.length) : null } +} + +/** + * The rows one column renders. The selection is exempt from every filter: 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. A prefix narrows the level only while some row it would actually + * show matches — a tail nobody matches is a name being spelled, not a demand + * for an empty pane, so the level shows whole and its hidden rows return to + * obeying the toggle. Counting only displayable rows is what keeps that true: + * were a hidden row ever to match a prefix that does not reveal it (today + * `hidden` means dot-prefixed, so it cannot), the level would narrow to + * nothing. + */ +function visibleEntries( + entries: readonly DirectoryEntry[], + selectedPath: string | null, + showHidden: boolean, + filterPrefix: string | null, +): readonly DirectoryEntry[] { + const needle = filterPrefix === null ? '' : filterPrefix.toLowerCase() + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + const displayable = (entry: DirectoryEntry): boolean => showHidden || !entry.hidden || needle.startsWith('.') + const matches = (entry: DirectoryEntry): boolean => displayable(entry) && entry.name.toLowerCase().startsWith(needle) + const narrowing = needle !== '' && entries.some(matches) + return entries.filter((entry) => { + if (entry.path === selectedPath) return true + if (narrowing) return matches(entry) + return showHidden || !entry.hidden + }) } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -127,16 +216,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr 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 - }) + const visible = visibleEntries(entries, selectedPath, showHidden, filterPrefix) return (
{visible.map((entry) => { @@ -264,27 +344,83 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [restartSlowScanWindow, listDirectory]) /** - * Replace the whole view with a freshly navigated level. Away from the + * Enter owns the view from submission until its navigation lands, so the + * debounce timer the same keystrokes armed must not supersede it. Cleared + * by the next edit (and by opening the editor); a failed submission leaves + * it set until the operator edits again, so the rejected path is not + * immediately re-scanned as a preview. + */ + const previewSuspended = useRef(false) + + // The panes as the draft-following scan must read them when its wait + // fires: current, but NOT a dependency of the wait (see the effect below). + const viewRef = useRef<{ parent: DirectoryListing | null; child: DirectoryListing | null }>({ parent: null, child: null }) + useEffect(() => { viewRef.current = { parent, child } }, [parent, child]) + + // What the last draft-following scan asked for and what came back, so a + // level still answers the text that produced it after the host respelled + // it. Stale entries are harmless: a match needs both the directory text and + // that level's own path, which together already mean the same directory. + const scanned = useRef(null) + + /** + * A landed preview replaced the pane a keyboard operator may have Tabbed + * onto, so the focus it drops is re-parked on the still-open editor (the + * Modal has no focus trap). Consumed by the refocus effect below. + */ + const refocusPathInput = useRef(false) + + /** + * Replace the whole view with a freshly scanned level. Away from the * display root — the same collapse the crumb header renders, so crumbs and * pane shape never disagree — the landing is two-pane: 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. Both legs land * as one frame when the parent leg settles within * {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the - * target commits alone — single wide level, the editor closes, loading - * ends — and a late parent leg still upgrades the landing in place. A - * failed parent leg, or a truncated parent window that lacks the target, - * leaves the single-pane landing — the upgrade must never orphan the - * selection it exists to anchor. Until whichever commit comes first, the - * previous view keeps rendering: navigation swaps the panes, it never - * blanks them. + * target commits alone — single wide level, loading ends — and a late + * parent leg still upgrades the landing in place. A failed parent leg, or a + * truncated parent window that lacks the target, leaves the single-pane + * landing — the upgrade must never orphan the selection it exists to + * anchor. Until whichever commit comes first, the previous view keeps + * rendering: a landing swaps the panes, it never blanks them. + * + * Two callers, one landing shape. A submitted path (Enter, a crumb) closes + * the editor on arrival, announces its failure, and takes the wait bound — + * it is answering a gesture, so it may not hang on a stalled parent. The + * editor's own draft-following scan keeps all three to itself: it is + * speculative, nothing waits on it, and the stale view keeps rendering, so + * it waits for BOTH legs rather than flashing a single pane it would then + * upgrade — one keystroke must move the view once. A failure leaves the + * last readable panes standing and says nothing, while an arrival clears + * the stale message and re-parks focus the swap dropped. + * @param path - the level to list; absent lists the Host home directory. + * @param options - `closeEditor` retires the path draft on arrival and + * bounds the wait for the parent leg; `announce` surfaces a failure as the + * dialog's alert. */ - const navigate = useCallback((path?: string) => { + const land = useCallback((path: string | undefined, options: { closeEditor: boolean; announce: boolean }) => { const { seq, scan } = launchListing(path) setLoading(true) - setError(null) + if (options.announce) setError(null) + // What every landing does once its panes are committed, whichever shape + // committed them. + const settle = (): void => { + setLoading(false) + if (options.closeEditor) { + setPathDraft(null) + return + } + setError(null) + refocusPathInput.current = true + } scan.then((target) => { if (seq !== requestSeq.current) return + // The level the panes will present as current answers this exact + // directory text, however the host respelled it (`..`, a Windows + // forward slash): the tail filters, and the same text asks for no + // second scan. + if (!options.closeEditor && path !== undefined) scanned.current = { directory: path, landed: target.path } // The single-pane landing; `landed` makes it first-commit-only, while // the two-pane commit below may still upgrade an already-landed view. let landed = false @@ -294,8 +430,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setParent(target) setSelected(null) setChild(null) - setLoading(false) - setPathDraft(null) + settle() } // Arity is label-independent: only the collapsed chain's depth decides. if (displayCrumbs(target, '').length < 2) { landSingle(); return } @@ -316,23 +451,29 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setChild(target) // Idempotent on a late upgrade of a timed-out landing: reopening the // editor or starting a newer scan supersedes this seq, so reaching - // here means the draft is closed and the loading flag is this - // navigation's own. - setLoading(false) - setPathDraft(null) + // here means the settlement is still this landing's own. + settle() }, () => { // The parent-leg failure (its abort included) never surfaces: the // target listed fine, and nobody asked to see the parent level. landSingle() }) - window.setTimeout(landSingle, PARENT_LEG_WAIT_MS) + // Only a submitted navigation is bounded: the walk waits both legs out + // (see the contract above), and a keystroke aborts it if the operator + // moves on first. + if (options.closeEditor) window.setTimeout(landSingle, PARENT_LEG_WAIT_MS) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) - setError(failureText(reason)) + if (options.announce) setError(failureText(reason)) }) }, [launchListing, continueScan]) + /** Commit a submitted path (Enter, a crumb, the initial home listing): the editor closes, failures surface. */ + const navigate = useCallback((path?: string) => { + land(path, { closeEditor: true, announce: true }) + }, [land]) + // 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 @@ -381,6 +522,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing, pathDraft]) + /** + * Walk the panes to the directory the draft addresses, WITHOUT closing the + * editor. The landing is an ordinary one — selection-anchored and two-pane + * away from the display root — so typing a path moves the Miller view + * exactly as a crumb jump does, and the draft's final segment + * prefix-filters the arrival from the next render on. + */ + const previewDraftLevel = useCallback((directory: string) => { + land(directory, { closeEditor: false, announce: false }) + }, [land]) + /** 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 @@ -499,8 +651,40 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return () => { window.clearTimeout(timer) } }, [loading, scanWindow]) + // The panes follow the draft: EVERY keystroke replaces the pending timer, + // and the target is decided when it fires, off the panes as they stand + // then. Keying the wait on the draft (not on the directory part it names) + // is what makes a keystroke that superseded an in-flight scan re-arm one, + // and what lets an edit after a rejected submission release the hold the + // submission took. The panes are read through a ref for the converse + // reason: were they dependencies, the landing this commits would re-arm the + // wait, and a host answering with a differently spelled path would scan + // forever. + useEffect(() => { + if (pathDraft === null) return + const timer = window.setTimeout(() => { + if (previewSuspended.current) return + // The level the panes present as current: it alone may answer the + // draft, so anything else it names is a level to walk to. + const current = viewRef.current.child ?? viewRef.current.parent + if (current === null) return + const { directory, tail } = readDraft(current, pathDraft, scanned.current) + if (directory === null || tail !== null) return + previewDraftLevel(directory) + }, DRAFT_PREVIEW_DEBOUNCE_MS) + return () => { window.clearTimeout(timer) } + }, [pathDraft, previewDraftLevel]) + // After the hooks: a closed dialog renders nothing and evaluates no copy. const crumbSource = child ?? parent + // The draft's tail filters the level it names, which by the pane invariant + // is the LAST pane — never a pane the draft has already walked away from. + // Narrowing that stale pane would move the view twice for one keystroke: + // once as it narrows, again as its landing replaces it. It holds still + // instead, and the filter arrives with the level it belongs to. + const typedPrefix = crumbSource === null || pathDraft === null + ? null + : readDraft(crumbSource, pathDraft, scanned.current).tail const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home')) const crumbTail = crumbs.at(-1)?.path useEffect(() => { @@ -523,6 +707,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // 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 (refocusPathInput.current) { + refocusPathInput.current = false + // Only when the swap actually dropped focus to body: focus the operator + // still holds (the input itself, a surviving row) stays theirs. + if (document.activeElement === document.body) pathInputRef.current?.focus() + } if (pathDraft !== null) return if (refocusPick.current) { refocusPick.current = false @@ -637,11 +827,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, ))} - {/* The empty zone right of the crumbs is the path-edit affordance. */} + {/* The empty zone right of the crumbs is the path-edit + * affordance: the whole remainder of the bar clicks into + * the editor, and the pencil glyph parked at its right + * edge (with the same tooltip) is what says so — an + * invisible target the operator must guess at is the one + * way into typing a path. */} ) : ( @@ -682,6 +881,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // repopulate the view with the older path. supersede() setLoading(false) + // A fresh edit releases the submission hold: the panes + // may follow the new text wherever it points. + previewSuspended.current = false setPathDraft(event.target.value) }} {...compositionGuard} @@ -699,6 +901,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // focus on the returning crumb edit zone (a failure // keeps the editor, so the flag waits until close). refocusEditZone.current = true + // The submitted path owns the view now: a debounce + // timer still pending from these keystrokes would + // otherwise supersede this navigation and land the + // draft's parent directory instead. + previewSuspended.current = true navigate(pathDraft) } } @@ -716,7 +923,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={select} showHidden={showHidden} - filterPrefix={draftPrefixFor(parent, pathDraft)} + filterPrefix={child === null ? typedPrefix : null} pathEditing={draftPending} /> )} @@ -728,7 +935,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={advance} showHidden={showHidden} - filterPrefix={draftPrefixFor(child, pathDraft)} + filterPrefix={typedPrefix} pathEditing={draftPending} /> )} diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index ce9f03fb0b..6329887add 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -11,9 +11,14 @@ const HOME = '/home/u' const DOCS = `${HOME}/Documents` const HARNESS = `${DOCS}/harness` -/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */ +/** + * Listing fake over a tiny fixed tree; unknown paths reject like the Host. + * A trailing separator is dropped the way the Host's own `resolve` drops it, + * so a directory part typed into the path editor addresses its level. + */ function listingFor(path?: string): DirectoryListing { - const target = path ?? HOME + const asked = path ?? HOME + const target = asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked const tree: Record = { [HOME]: { path: HOME, @@ -647,7 +652,7 @@ describe('DirectoryBrowser', () => { }) it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { - mount() + const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') @@ -659,16 +664,29 @@ describe('DirectoryBrowser', () => { // A dot-led prefix names hidden entries, so it reveals the match. fireEvent.change(input, { target: { value: `${HOME}/.co` } }) expect(screen.getByRole('listitem').textContent).toBe('.config') - // A prefix matching nothing empties the level (no stale rows linger). + // A prefix nobody matches releases the filter: the level shows whole + // (hidden rows back under the toggle) instead of emptying under a name + // the operator is still spelling. fireEvent.change(input, { target: { value: `${HOME}/zzz` } }) - expect(screen.queryByRole('listitem')).toBeNull() - // A draft naming some other directory (or none) leaves the level whole. + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) + // Its dot-led reveal lapses with it. + fireEvent.change(input, { target: { value: `${HOME}/.zzz` } }) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) + // A tail inside the listed level names no level to walk to: the wait + // fires and finds nothing to scan. + const settled = b.listDirectory.mock.calls.length + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) + // A draft naming some other directory (or none) leaves the level whole — + // and a draft with no separator at all addresses no directory either. fireEvent.change(input, { target: { value: 'no-separator' } }) expect(screen.getByRole('listitem').textContent).toBe('Documents') + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) }) it('filters the child pane in two-pane mode and follows the draft back up a level', async () => { - mount() + const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(columns()).toHaveLength(2) }) @@ -679,15 +697,301 @@ describe('DirectoryBrowser', () => { expect(input.value).toBe(`${DOCS}/`) fireEvent.change(input, { target: { value: `${DOCS}/h` } }) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + // The child pane already lists that directory: no scan follows, and both + // panes stay. + const settled = b.listDirectory.mock.calls.length + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) + expect(columns()).toHaveLength(2) + // A miss releases the right pane's filter rather than emptying it. fireEvent.change(input, { target: { value: `${DOCS}/zzz` } }) - expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) - expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() - // Erasing back into the parent's own path moves the filter to the LEFT - // pane and releases the right one. The selected row is exempt (it - // anchors the two-pane view), so it alone survives the miss. - fireEvent.change(input, { target: { value: `${HOME}/zz` } }) - expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + // Erasing back into the parent's own path re-lands on it rather than + // filtering the LEFT pane: the level being typed is always the last pane, + // never a pane with a deeper level standing to its right. Home is the + // display root, so it lands alone. + fireEvent.change(input, { target: { value: `${HOME}/zz` } }) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) + }) + + it('follows the draft into a directory no pane lists, landing the two-pane Miller view', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(columns()).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Typing past a separator addresses a level nobody shows: the panes walk + // to it once the typing rests, landing the ordinary selection-anchored + // two-pane view (level | its children) with the tail filtering the right + // pane — a typed path moves the Miller view exactly as a crumb jump does. + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + // Still editing: the panes moved under the draft, the editor stayed. + expect(screen.getByLabelText('browser.editPath').value).toBe(`${DOCS}/h`) + // Typing on inside a level the panes already list costs no scan at all: + // the prefix filter alone answers the draft, both panes stay. + const settled = b.listDirectory.mock.calls.length + fireEvent.change(input, { target: { value: `${DOCS}/ha` } }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) + expect(columns()).toHaveLength(2) + }) + + it('keeps the typed level in the last pane, its parent beside it, as the draft walks', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Two levels down: the typed level on the right, its parent on the left. + fireEvent.change(input, { target: { value: `${HARNESS}/` } }) + await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() }) + expect(columns()).toHaveLength(2) + expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) + // Erasing back to the parent's own path re-lands on it: the level being + // typed moves BACK into the last pane instead of staying on the left with + // its own child pane still to the right. + fireEvent.change(input, { target: { value: `${DOCS}/ha` } }) + await waitFor(() => { expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() }) + expect(columns()).toHaveLength(2) + expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['harness']) + expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) + }) + + it('holds a stale pane still until its landing, instead of narrowing it first', async () => { + // Own three-level tree: the level that goes stale needs two rows for the + // narrowing this pins against to be visible at all. + const ROOT = '/u' + const MID = `${ROOT}/mid` + const LEAF = `${MID}/leaf` + const chain = [{ name: '/', path: '/', hidden: false }, { name: 'u', path: ROOT, hidden: false }] + const tree: Record = { + [ROOT]: { + path: ROOT, + home: ROOT, + crumbs: chain, + entries: [{ name: 'mid', path: MID, hidden: false }, { name: 'other', path: `${ROOT}/other`, hidden: false }], + truncated: false, + }, + [MID]: { + path: MID, + home: ROOT, + crumbs: [...chain, { name: 'mid', path: MID, hidden: false }], + entries: [{ name: 'leaf', path: LEAF, hidden: false }, { name: 'sibling', path: `${MID}/sibling`, hidden: false }], + truncated: false, + }, + [LEAF]: { + path: LEAF, + home: ROOT, + crumbs: [...chain, { name: 'mid', path: MID, hidden: false }, { name: 'leaf', path: LEAF, hidden: false }], + entries: [], + truncated: false, + }, + } + mount({ + listDirectory: vi.fn(async (path?: string) => { + const asked = path ?? ROOT + const found = tree[asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked] + if (found === undefined) throw new Error(`cannot list ${asked}`) + return found + }), + }) + await waitFor(() => { expect(screen.getByText('mid')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${LEAF}/` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling']) + // Deleting the separator names the level the LEFT pane lists. That pane + // is stale — its landing will move it right — so it must not narrow to + // the tail first: one deletion, one movement. + fireEvent.change(input, { target: { value: LEAF } }) + expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling']) + await waitFor(() => { expect(within(columns()[0]!).getByText('other')).toBeTruthy() }) + expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf']) + }) + + it('keeps the walked-to panes when the editor is cancelled, Open adopting where the walk ended', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.keyDown(input, { key: 'Escape' }) + // Cancel closes the editor; it does not rewind the walk. The operator + // watched the panes move, so the crumbs, the panes, and Open's target all + // stay where the walk ended. + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(columns()).toHaveLength(2) + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + expect(screen.getByRole('navigation').textContent).toContain('Documents') + const open = screen.getByRole('button', { name: 'browser.open' }) + expect(open.disabled).toBe(false) + fireEvent.click(open) + expect(b.onOpen).toHaveBeenCalledWith(DOCS) + }) + + it('waits both legs out for a walk: one keystroke never flashes a single pane', async () => { + let landParent = (): void => {} + const listDirectory = vi.fn(async (path?: string) => { + // The parent leg outlives the submitted-navigation wait bound; a walk + // has nothing waiting on it, so it holds the stale view instead of + // landing single-pane and upgrading. + if (path === HOME) return await new Promise((resolve) => { landParent = () => { resolve(listingFor(HOME)) } }) + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(HOME, expect.anything()) }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + // Well past the submitted-navigation bound: still the pre-walk view. + expect(columns()).toHaveLength(1) + expect(screen.getByText('Documents')).toBeTruthy() + await act(async () => { landParent() }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + }) + + it('walks the panes back up when erased segments leave the listed levels', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + // Erasing back to a directory neither pane lists walks up to it; the + // filesystem root is the display root, so it lands the single wide level + // with the tail filtering it. + fireEvent.change(input, { target: { value: '/ho' } }) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + expect(b.listDirectory).toHaveBeenCalledWith('/', expect.anything()) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['home']) + }) + + it('re-arms the draft-following scan after a keystroke superseded one in flight', async () => { + let started = 0 + const listDirectory = vi.fn(async (path?: string) => { + if (path !== `${DOCS}/`) return listingFor(path) + started += 1 + // The first scan never settles: the next keystroke aborts it, and only + // a re-armed wait can still land the level the draft names. + if (started === 1) return await new Promise(() => {}) + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(started).toBe(1) }) + // A further tail keystroke supersedes the in-flight scan; the panes must + // still follow, not sit on the stale level until a separator is typed. + fireEvent.change(input, { target: { value: `${DOCS}/ha` } }) + await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + }) + + it('follows the draft again after an edit releases a failed submission hold', async () => { + const listDirectory = vi.fn(async (path?: string) => { + if (path === HARNESS) throw new Error('target unreadable') + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Submitting inside the debounce window holds the pending scan back. + fireEvent.change(input, { target: { value: HARNESS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('target unreadable') }) + // Correcting only the final segment leaves the directory part unchanged; + // the edit must still release the hold and re-arm the wait. + fireEvent.change(input, { target: { value: `${HARNESS}x` } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) }) + await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + }) + + it('re-parks focus on the editor when a landed scan unmounts the focused row', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Two levels down, so the walk replaces the LEFT pane the focused row + // lives in (a landing that re-lists the same level reuses its rows). + fireEvent.change(input, { target: { value: `${HARNESS}/` } }) + // The keyboard path: focus Tabbed onto a row of the level about to be + // replaced. Without a re-park it would fall to body, outside a Modal that + // has no focus trap. + rowButton(screen.getByRole('listitem')).focus() + await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() }) + expect(document.activeElement).toBe(screen.getByLabelText('browser.editPath')) + }) + + it('keeps the panes and stays silent when a draft-following scan fails', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/nope/x` } }) + await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledWith(`${HOME}/nope/`, expect.anything()) }) + // A half-typed directory is unreadable most of the time: the last + // readable level keeps rendering and no error interrupts the typing. + expect(screen.getByText('Documents')).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('holds the draft-following scan while a submitted path is in flight', async () => { + const listDirectory = vi.fn(async (path?: string) => { + // The submitted leg never settles, so the debounce window elapses with + // the navigation still owning the view. + if (path === HARNESS) return await new Promise(() => {}) + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: HARNESS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + // Only the initial home listing and the submitted path — the draft's + // directory part was never scanned behind the navigation's back. + expect(listDirectory.mock.calls.map(call => call[0])).toEqual([undefined, HARNESS]) + }) + + it('discards draft-following scans that a newer edit superseded', async () => { + let landDocs = (): void => {} + let failRoot = (): void => {} + const listDirectory = vi.fn(async (path?: string) => { + if (path === `${DOCS}/`) return await new Promise((resolve) => { landDocs = () => { resolve(listingFor(DOCS)) } }) + if (path === '/') { + return await new Promise((_, reject) => { + failRoot = () => { reject(new Error('root unreadable')) } + }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) }) + fireEvent.change(input, { target: { value: '/x' } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith('/', expect.anything()) }) + // Back onto the listed level: neither pending scan may still land. + fireEvent.change(input, { target: { value: `${HOME}/D` } }) + await act(async () => { landDocs(); failRoot() }) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) + expect(screen.queryByRole('alert')).toBeNull() }) it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => { @@ -813,7 +1117,8 @@ describe('DirectoryBrowser', () => { ], truncated: false, } - mount({ listDirectory: vi.fn(async () => windowsListing) }) + const listDirectory = vi.fn(async () => windowsListing) + mount({ listDirectory }) await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') @@ -821,6 +1126,18 @@ describe('DirectoryBrowser', () => { expect(input.value).toBe(ROOT) fireEvent.change(input, { target: { value: `${ROOT}u` } }) expect(screen.getByRole('listitem').textContent).toBe('Users') + // Windows separates on a forward slash too (so does the Host's resolve), + // so a path typed that way names its directory; the level the Host + // answers with spells it back with a backslash, and once that scan lands + // the level answers the typed spelling — the tail filters it. + fireEvent.change(input, { target: { value: 'C:/p' } }) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Program Files') }) + // And the same spelling asks for no second scan. + const settled = listDirectory.mock.calls.length + fireEvent.change(input, { target: { value: 'C:/pr' } }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(listDirectory.mock.calls).toHaveLength(settled) + expect(screen.getByRole('listitem').textContent).toBe('Program Files') }) it('clicking away from the path editor cancels it back to the crumb view', async () => { @@ -977,6 +1294,10 @@ describe('DirectoryBrowser', () => { fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') fireEvent.change(input, { target: { value: DOCS } }) + // With no level listed there is no platform separator to read, so the + // draft-following wait resolves to nothing and the editor types blind. + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(listDirectory).toHaveBeenCalledTimes(1) listDirectory.mockImplementation(async (path?: string) => listingFor(path)) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })