diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml index 629476cf8e..43675cbd56 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.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-consolidated-tui-presentation.md -2026-07-28-consolidated-tui-presentation.md: f87d543a698d6e77abf9120c6579100df4b60b64 -2026-07-28-consolidated-tui-presentation.zh.md: 005e408f0e75207027315546942f9eab57d595d1 +2026-07-28-consolidated-tui-presentation.md: 8200c8e96cdea7cf5ae54c1328623bb03f841d00 +2026-07-28-consolidated-tui-presentation.zh.md: 852bd413b25f93f3b9096b78d69b21ab420df37b diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md index f87d543a69..8200c8e96c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md @@ -26,7 +26,7 @@ A tool card has one colored `Tool / ` status header over one dim body. Pre Injected context renders as prose in `ContextCardComponent`, not through the XML tree renderer. Exact matched outer `` lines are stripped, but mismatched, unpaired, or inline tag-like text remains verbatim. Model-facing content is unchanged. Folding uses the shared `preview` helper after body assembly, so it depends only on row count, never parser success or payload characters. -`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. +`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. The hidden phase additionally folds each turn's assistant steps into one message; the [hidden-mode assistant fold Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md) owns that rule. ### Cross-workspace resume diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md index 005e408f0e..852bd413b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md @@ -26,7 +26,7 @@ Status: implemented 注入上下文由 `ContextCardComponent` 按普通文本呈现,不经过 XML 树渲染器。仅移除精确配对的外层 `` 行;不匹配、单边或正文内类似标签的文本都原样保留。面向模型的内容不变。折叠在正文组装完成后使用共享 `preview` 辅助函数,因此只取决于行数,不依赖解析是否成功或载荷包含哪些字符。 -`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。 +`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息;该规则由[隐藏模式 assistant 折叠 Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md)负责。 ### 跨工作区恢复 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/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml new file mode 100644 index 0000000000..4931fa907b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md +2026-07-31-fail-loud-releases-the-terminal.md: 2a6e7fcbbdd5d35bcf70dee09fdb9e5592486b78 +2026-07-31-fail-loud-releases-the-terminal.zh.md: f75c21cf79b241e6714c10ec7df9ac25f3d978b4 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md new file mode 100644 index 0000000000..2a6e7fcbbd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md @@ -0,0 +1,59 @@ +# Agent Note: fail-loud releases the terminal before exiting + +Status: implemented + +English | [中文](2026-07-31-fail-loud-releases-the-terminal.zh.md) + +## Problem + +A `dsh` launch whose config failed validation printed its diagnostic and returned the user to a broken shell. Typing was invisible, and the next command was mangled by stray text: + +``` +dsh: fatal load failure: ValidationError: invalid config: + - $.providers expected object but got [object Object] (at providers) +$ 1;2;4cecho hello +zsh: command not found: 4cecho +``` + +The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The transactional Loader now settles config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.) + +Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above. + +The `/exit` path was never affected, because it disposes the tree and reaches the TUI's own `shutdown()`, which calls `drainInput()` (absorbing the pending reply) and then `ui.stop()`. The defect was that a *failed boot* had no path to that same teardown. + +## Decision + +`installFailLoud` takes an optional `release` teardown, awaited between the diagnostic and the exit: + +- The diagnostic is written **before** the release, so a hanging or failing disposer cannot swallow the reason. +- A latch, not an uninstall, keeps the first rejection the reported one. Removing the listener during teardown would let a second concurrent rejection become uncaught, and Node would kill the process mid-teardown — stranding exactly the terminal state this restores. Later rejections, including the release's own, fall through to the pending exit. +- The release is bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS` (2s) and its rejection is swallowed. A wedged or failing disposer delays the fatal exit; it never cancels it. That timer stays **referenced**: an `unref()`ed one lets Node reach an empty event loop and exit 0 on the very failure being reported, because an `unhandledRejection` listener suppresses the default fatal exit. +- Omitting `release` keeps the previous behavior exactly, so the ACP, JSON-RPC, and demo bins are unchanged. + +`dsh`'s TUI launcher passes a release that disposes the root context, which runs the TUI's existing `shutdown()` and hands the terminal back. + +The launcher captures the root context in `boot()`'s `prepare` hook rather than from its return value. The rejection arrives while `boot()` is still in flight, so `app.current` assigned after the `await` would still be `undefined` at exactly the moment the hook needs it. `prepare` runs after the Loader installs and before any config-tree entry mounts, which covers the whole window in which an entry can reject. + +## Alternatives considered + +**Reset the terminal from the fail-loud handler** (write `ESC [ ? 2004 l`, pop the keyboard protocol, clear raw mode). This duplicates pi-tui's teardown in a package that owns no terminal, and would drift as pi-tui's startup sequence changes. It also cannot absorb the in-flight Device Attributes reply, which is what corrupts the next prompt — only draining stdin while it is still raw does that. + +**Register a `process.on('exit')` terminal reset in the TUI.** Exit handlers are synchronous, so they cannot await `drainInput()`; the stray reply would still land. It also puts teardown on a global hook rather than the disposal path that already exists. + +**Have the TUI refuse to start until the tree settles.** This serializes a deliberately concurrent Loader and delays first paint for every healthy launch to fix a failure path. + +**Reorder config entries so `llm-pi-ai` mounts before `ui-tui`.** Ordering is not a guarantee the Loader makes, and any future entry could fail after the TUI mounts. + +## Consequences + +A failed boot now costs one tree disposal (bounded at 2s) before exit, and the exit code stays 1. In exchange, a misconfigured `dsh` returns a usable shell instead of one needing `stty sane` or `reset`. + +The guarantee belongs to whichever bin owns the terminal: a surface that grabs terminal state and does not pass `release` reintroduces this defect. `installFailLoud` cannot detect that on its own, since it has no view of what a mounted plugin did to the process. + +## Testing + +`packages/ui/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS`, and a burst of rejections reports only the first while the release still completes. + +Those fake-process tests cannot observe the two failure modes that matter most — process exit code with a real event loop, and terminal state after exit — so the regression lives in `apps/cli/tests/tui-keyless-smoke.e2e.ts`. It boots the shipped tree in a real PTY over `fixtures/tui-invalid-provider.cordis.yml` (a list-shaped `providers`, the mistake users actually make), expects exit 1, and asserts the captured bytes contain both the labelled boot rejection (`dsh: plugin tree failed to load:`) and `ESC[?2004l`. The same case pins the boot path end to end: it caught the [HMR initial-scan boot deadlock](2026-08-03-hmr-initial-scan-boot-deadlock.md) that silently exited 13 with the terminal stranded. + +Testing policy requires a PTY case whenever terminal teardown changes, and this is it. The `/exit` path keeps its existing assertion that the same reset appears on a clean exit. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md new file mode 100644 index 0000000000..f75c21cf79 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md @@ -0,0 +1,59 @@ +# Agent Note:fail-loud 在退出前释放终端 + +Status: implemented + +[English](2026-07-31-fail-loud-releases-the-terminal.md) | 中文 + +## Problem + +配置校验失败的 `dsh` 启动会打印诊断信息,然后把用户丢回一个损坏的 shell:输入不可见,下一条命令还会被残留文本弄乱: + +``` +dsh: fatal load failure: ValidationError: invalid config: + - $.providers expected object but got [object Object] (at providers) +$ 1;2;4cecho hello +zsh: command not found: 4cecho +``` + +Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。 + +在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行释放部分构建的上下文;release 回调仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节释放这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。 + +`/exit` 路径从不受影响,因为它会释放整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。 + +## Decision + +`installFailLoud` 新增可选的 `release` 拆卸回调,在诊断信息与退出之间被等待: + +- 诊断信息在 release **之前**写出,因此卡住或失败的 disposer 无法吞掉失败原因。 +- 使用闩锁(latch)而非卸载监听器,来保证被报告的始终是第一个 rejection。若在拆卸期间移除监听器,第二个并发 rejection 就会变成未捕获错误,Node 会在拆卸中途杀死进程——恰好残留下本次要恢复的终端状态。后续 rejection(包括 release 自身的)都会落入已挂起的退出流程。 +- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。该定时器保持 **referenced**:一旦 `unref()`,Node 就会在事件循环清空后、恰恰在报告这次失败时以 0 退出,因为 `unhandledRejection` 监听器抑制了默认的致命退出。 +- 不传 `release` 时行为与此前完全一致,因此 ACP、JSON-RPC 和各 demo bin 均无变化。 + +`dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。 + +启动器在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值。rejection 到达时 `boot()` 尚未结算,因此在 `await` 之后赋值的 `app.current` 恰好在回调需要它的那一刻仍是 `undefined`。`prepare` 在 Loader 安装之后、任何配置树条目挂载之前运行,覆盖了条目可能 rejection 的整个窗口。 + +## Alternatives considered + +**在 fail-loud 处理函数里直接重置终端**(写 `ESC [ ? 2004 l`、弹出键盘协议、清除 raw 模式)。这会在一个并不拥有终端的包里重复 pi-tui 的拆卸逻辑,并随 pi-tui 启动序列的变化而漂移。它同样无法吸收尚未返回的 Device Attributes 响应——而这正是弄乱下一个提示符的原因,只有在 stdin 仍处于 raw 模式时排空它才能解决。 + +**在 TUI 中注册 `process.on('exit')` 终端重置。** exit 处理函数是同步的,无法等待 `drainInput()`,残留响应依旧会落到 shell;而且这把拆卸挂到全局钩子上,而非已经存在的释放路径。 + +**让 TUI 等整棵树结算后再启动。** 这会把刻意并发的 Loader 串行化,并为修复一条失败路径而拖慢每一次正常启动的首次绘制。 + +**调整配置顺序,让 `llm-pi-ai` 先于 `ui-tui` 挂载。** 顺序并不是 Loader 提供的保证,而且未来任何条目都可能在 TUI 挂载之后失败。 + +## Consequences + +启动失败现在会在退出前多付出一次树释放的代价(上限 2 秒),退出码仍为 1。作为交换,配置错误的 `dsh` 会交还一个可用的 shell,而不是需要 `stty sane` 或 `reset` 才能恢复的终端。 + +这项保证属于**拥有终端的那个 bin**:任何抢占终端状态却不传 `release` 的界面都会重新引入该缺陷。`installFailLoud` 自身无法察觉这一点,因为它看不到已挂载的插件对进程做了什么。 + +## Testing + +`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1;永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及一连串 rejection 只报告第一个,同时 release 仍能跑完。 + +这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `apps/cli/tests/tui-keyless-smoke.e2e.ts`。它在真实 PTY 中以 `fixtures/tui-invalid-provider.cordis.yml`(`providers` 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1,并断言捕获到的字节流同时包含带标签的启动 rejection(`dsh: plugin tree failed to load:`)与 `ESC[?2004l`。同一用例端到端钉住了启动路径:正是它发现了以 13 静默退出、终端状态被残留的 [HMR 初始扫描启动死锁](2026-08-03-hmr-initial-scan-boot-deadlock.md)。 + +测试规范要求:只要改动终端拆卸,就必须有 PTY 用例——这就是它。`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml new file mode 100644 index 0000000000..9ac8e49fe6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md +2026-08-02-message-fork-actions-require-completed-turn-tail.md: f2e7fd67b65a6ce4a86ba3f4405f78842be8f234 +2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: 2c3feeaa3ef01dbde67faa73257520918996f9c8 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md new file mode 100644 index 0000000000..f2e7fd67b6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md @@ -0,0 +1,29 @@ +# Agent Note: Message fork actions require a completed turn tail + +Status: implemented + +English | [中文](2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md) + +## Problem + +The Web conversation attached branch to the last assistant node with nonempty text in each turn. A later tool result, interrupted reasoning node, or terminal error did not take ownership because those rows have no content-text IconActions. The branch icon could therefore appear beneath an assistant response while more rows from the same turn remained below it. The Host correctly expanded that message anchor through the containing `turn/end`, but the placement made the action look like a message-level cut and the child visibly inherited the same-turn suffix. + +## Decision + +`ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and enables branch only when the boundary's last node is a user message, a durable steering message, or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node leaves branch unavailable on earlier messages. The unavailable control stays visible, focusable, and hoverable; `aria-disabled`, a tooltip, and `aria-describedby` explain the completed-tail requirement without sending a Host request. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged. + +This narrows the message eligibility established by the earlier [Web session fork action decision](../feature/2026-07-27-web-session-fork-actions.md). Session-row forking still selects the latest completed turn, and eligible message actions still pass their event seq through the shared client runtime operation. + +## Alternatives considered + +**Cut the event log at the clicked assistant message.** Rejected because an assistant message can sit inside an open step and can contain tool calls whose results occur later. A raw prefix at that seq is not a balanced turn and may not be a valid provider transcript. + +**Infer completion from `running` or the next user message.** Rejected because retry and steering turns need not align with the next visible user bubble, and a paged window may omit that later bubble. The durable `turn/end` event is the authoritative completion fact. + +**Hide branch from every interrupted turn.** Rejected because an aborted turn is durably closed and its final interrupted text can be the true transcript tail. Eligibility depends on the completed boundary and node order, not the outcome kind. + +**Hide ineligible message controls.** Rejected because a disappearing control does not explain the boundary requirement and shifts otherwise stable message chrome. A focusable unavailable control preserves the affordance while preventing the request. + +## Consequences + +An enabled branch icon denotes the same completed-turn boundary that the Host will copy. In the reported response → tool → interrupted Think shape, the response keeps copy, clock, and a disabled branch control that explains why it cannot act. This change deliberately does not provide same-turn transcript editing or a retry-before-turn operation; the Session-row action remains available when a reader wants to copy the latest completed turn in full. Runtime tests pin boundary projection and reference stability, while conversation tests cover assistant, user-only, and durable-steering tails plus unavailable controls caused by later tool and interrupted reasoning rows. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md new file mode 100644 index 0000000000..2c3feeaa3e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 消息 fork 操作要求消息位于已完成轮次尾部 + +Status: implemented + +[English](2026-08-02-message-fork-actions-require-completed-turn-tail.md) | 中文 + +## 问题 + +Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assistant 节点上。如果后面还有工具结果、被中断的推理(reasoning)节点或终态错误,这些行也不会接管操作,因为它们没有内容文本 IconActions。因此,分支图标可能出现在 assistant 响应下方,而同一轮次的更多行仍位于其后。Host 会正确地把该消息锚点扩展到其所在的 `turn/end`,但图标位置使操作看起来像在消息级截断,子会话又会明显继承同轮次的后缀。 + +## 决策 + +`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息、持久 steering(中途引导)消息或含内容的 assistant 消息时才启用分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作会保持不可用。不可用的控件仍然可见、可聚焦、可悬停;`aria-disabled`、tooltip 与 `aria-describedby` 会说明已完成尾部这一要求,且不会发送 Host 请求。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。 + +本决策收紧了较早的 [Web 会话 fork 操作决策](../feature/2026-07-27-web-session-fork-actions.md)所定义的消息资格。Session 行 fork 仍选择最新的已完成轮次;符合条件的消息操作仍通过共享 client 运行时操作传递其事件 seq。 + +## 考虑过的替代方案 + +**在点击的 assistant 消息处截断事件日志。** 不予采纳:assistant 消息可能位于尚未结束的步骤内,也可能包含结果随后才出现的工具调用。以该 seq 截取的原始前缀并不是结构完整的轮次,也可能不是有效的提供方 transcript。 + +**从 `running` 或下一条用户消息推断完成状态。** 不予采纳:重试轮次与 steering 轮次不一定和下一个可见用户气泡对齐,分页窗口也可能省略该气泡。持久 `turn/end` 事件才是权威的完成事实。 + +**对每个被中断轮次隐藏分支。** 不予采纳:已中止的轮次会持久关闭,其最终的中断文本可能正是真正的 transcript 尾部。资格取决于已完成边界与节点顺序,而非结果类别。 + +**隐藏不符合条件的消息控件。** 不予采纳:消失的控件无法说明边界要求,还会让本应稳定的消息 chrome 发生位移。保留可聚焦但不可用的控件,既能维持操作提示,也能阻止请求。 + +## 后果 + +启用的分支图标现在表示的已完成轮次边界与 Host 实际复制的边界一致。在所报告的「响应 → 工具 → 被中断的 Think」形态中,响应仍保留复制、时钟,以及一个说明无法操作原因的禁用分支控件。本变更刻意不提供同轮次 transcript 编辑,也不提供轮次前重试操作;当读者希望完整复制最新的已完成轮次时,仍可使用 Session 行操作。运行时测试固定边界投影和引用稳定性,会话测试则覆盖 assistant 尾部、纯用户消息尾部、持久 steering 尾部,以及后续工具行和被中断推理行导致的不可用控件。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.i18n.yaml new file mode 100644 index 0000000000..170627ea76 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md +2026-08-03-hmr-initial-scan-boot-deadlock.md: 4b3e259c216d258c321ab06c41225b33ed240d19 +2026-08-03-hmr-initial-scan-boot-deadlock.zh.md: ce1bc8396ac6e7fb6ecb1647fe2b29cdc788c7e1 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md new file mode 100644 index 0000000000..4b3e259c21 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md @@ -0,0 +1,41 @@ +# Agent Note: HMR's initial scan deadlocked a failing boot into a silent exit 13 + +Status: implemented + +English | [中文](2026-08-03-hmr-initial-scan-boot-deadlock.zh.md) + +## Problem + +A `dsh` launch whose config-tree failed validation exited 13 (unsettled top-level await) with no diagnostic at all, and left the TUI's terminal state stranded on the shell — the exact symptom the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) fixed, reintroduced through a different mechanism after the [transactional config reload](2026-07-20-config-hot-reload-resilience.md). + +Two defects compounded: + +1. **Concurrent Include applies corrupt the transactional group update.** The HMR main watcher's chokidar initial scan re-announces every existing file as `add`. Its `add` for the config file triggered `Include.refresh()` while the Include's initial apply was still in flight (`this.content`, the changed-content dedup key, commits only after apply). Two concurrent `EntryGroup.update` calls on one group interleave create and rollback on the same entries, and the Include fiber never settles — `loader.create` hangs, `boot()` neither resolves nor rejects, and Node exits 13 once the loop drains. +2. **Serialized applies alone deadlock the failure rollback.** With Include mutations queued, a failing initial apply rolls back by disposing every mounted entry — including `hmr`, whose teardown drains its refresh tasks. The scan-triggered refresh task sits in the Include queue behind the very apply whose rollback is disposing HMR: rollback waits on HMR, HMR waits on the refresh, the refresh waits on the apply. + +## Decision + +Both halves are fixed in the vendored packages (logged in `vendor/README.md`): + +- `include/src/index.ts` funnels every child-tree mutation — initial apply, refresh, and `internal/update` patch re-application — through one per-Include promise queue. The group's transactional `update` is not reentrant, so serialization is a correctness requirement, not a throughput choice. `refresh()` also reads inside the queue so its changed-content check compares against the predecessor's committed state. +- `hmr/src/index.ts` passes `ignoreInitial: true` to the main watcher. The initial scan only re-announces files boot has just consumed; suppressing it removes both the boot-time refresh and the spurious `add` events for already-loaded modules. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply exactly once. + +With both in place a failing boot follows the intended path: the single apply fails, the rollback disposes the tree (running the TUI's own shutdown, restoring the terminal), `loader.create` rejects, and `boot()` rethrows the labelled diagnostic with exit 1. + +## Alternatives considered + +**Only `ignoreInitial: true`.** Removes the trigger but leaves the corruption: any genuinely concurrent refresh (a config edit racing a slow apply) still interleaves two group updates and strands the fiber. + +**Only serialization.** Converts the corruption into the rollback deadlock described above; the process still exits 13 silently. + +**Cancel queued refreshes on HMR teardown.** Requires cancellation plumbing through `refreshConfig`'s task loop and the Include queue for a case `ignoreInitial` already removes from every boot; not worth the machinery until a real trigger remains. + +## Consequences + +A config file edit landing inside the watcher's startup scan window is now picked up by the next `change` event rather than the scan itself; steady-state reload behavior is unchanged. + +One latent gap remains: a config edit made during a *failing* initial apply can still queue a refresh that the rollback's HMR teardown waits on — the same deadlock shape with a human-scale trigger window of one failing boot. If that ever bites, the fix is refresh-task cancellation at HMR teardown. + +## Testing + +The `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts` pins the end-to-end contract: exit 1, the labelled `dsh: plugin tree failed to load:` diagnostic naming `$.providers`, and the bracketed-paste reset proving the tree was disposed. Before this fix the same case observed exit 13 with no diagnostic. Reload behavior stays covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/ui/app-boot/tests/hmr-config.spec.ts`. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.zh.md new file mode 100644 index 0000000000..ce1bc8396a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.zh.md @@ -0,0 +1,41 @@ +# Agent Note:HMR 初始扫描使失败的启动死锁为静默的 exit 13 + +状态:已实现 + +[English](2026-08-03-hmr-initial-scan-boot-deadlock.md) | 中文 + +## 问题 + +当 `dsh` 启动时配置树校验失败,进程以 13 退出(未结算的顶层 await),不输出任何诊断,并把 TUI 的终端状态残留在 shell 上——这正是 [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) 修复过的症状,在[事务化配置重载](2026-07-20-config-hot-reload-resilience.md)之后经由另一条机制重新出现。 + +两个缺陷叠加: + +1. **并发的 Include apply 破坏事务化的 group update。** HMR 主 watcher 的 chokidar 初始扫描会把每个已存在的文件重新宣告为 `add`。其中配置文件的 `add` 在 Include 的首次 apply 尚未结束时触发了 `Include.refresh()`(内容去重键 `this.content` 只在 apply 完成后才提交)。同一 group 上两个并发的 `EntryGroup.update` 会在相同条目上交错执行 create 与回滚,导致 Include fiber 永远无法结算:`loader.create` 挂起,`boot()` 既不 resolve 也不 reject,事件循环排空后 Node 以 13 退出。 +2. **仅序列化 apply 会让失败回滚死锁。** 将 Include 的变更排入队列后,首次 apply 失败时的回滚会释放每个已挂载条目——包括 `hmr`,而它的拆卸会等待自身的 refresh 任务排空。扫描触发的 refresh 任务正排在 Include 队列中、位于正在回滚的那次 apply 之后:回滚等 HMR,HMR 等 refresh,refresh 等 apply。 + +## 决定 + +两处修复都落在 vendored 包中(记录于 `vendor/README.md`): + +- `include/src/index.ts` 将每次子树变更——首次 apply、refresh、`internal/update` 补丁重应用——汇入每个 Include 一条的 promise 队列。group 的事务化 `update` 不可重入,因此序列化是正确性要求,而不是吞吐取舍。`refresh()` 也在队列内读取文件,使其内容变更判断与前一任务提交后的状态比较。 +- `hmr/src/index.ts` 给主 watcher 传入 `ignoreInitial: true`。初始扫描只会重新宣告启动刚刚消费过的文件;抑制它同时消除了启动期 refresh 和对已加载模块的多余 `add` 事件。`registerConfig()` 保留自己 `ignoreInitial: false` 的 watcher,因为注册时已存在的个人配置必须恰好应用一次。 + +两者齐备后,失败的启动走上预期路径:唯一一次 apply 失败,回滚释放整棵树(执行 TUI 自身的 shutdown、恢复终端),`loader.create` reject,`boot()` 重新抛出带标签的诊断并以 1 退出。 + +## 曾考虑的替代方案 + +**只加 `ignoreInitial: true`。** 消除了触发条件,但保留了破坏本身:任何真正并发的 refresh(配置编辑与缓慢的 apply 竞争)仍会交错两次 group update 并使 fiber 悬置。 + +**只做序列化。** 把破坏转化为上述回滚死锁;进程仍然静默地以 13 退出。 + +**在 HMR 拆卸时取消排队中的 refresh。** 需要在 `refreshConfig` 的任务循环和 Include 队列中铺设取消机制,而 `ignoreInitial` 已把该场景从每次启动中移除;在真实触发条件出现之前不值得引入这套机构。 + +## 后果 + +落在 watcher 启动扫描窗口内的配置文件编辑,现在由下一个 `change` 事件而非扫描本身拾取;稳态的重载行为不变。 + +仍留有一个潜在缺口:在一次*失败的*首次 apply 期间进行的配置编辑,仍可能排入一个被回滚的 HMR 拆卸所等待的 refresh——同样的死锁形态,但触发窗口缩小到一次失败启动的人力尺度。若它真的发生,修复方向是在 HMR 拆卸时取消 refresh 任务。 + +## 测试 + +`apps/cli/tests/tui-keyless-smoke.e2e.ts` 中 `dsh` 无效 provider 的 PTY 用例钉住了端到端契约:以 1 退出、带标签的 `dsh: plugin tree failed to load:` 诊断指明 `$.providers`、以及证明整棵树已被释放的 bracketed-paste 复位序列。此修复之前,同一用例观察到的是无诊断的 exit 13。重载行为仍由 `packages/ui/app-boot/tests/config-reload.spec.ts` 与 `packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 1f52492649..656a52d300 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-23-web-assistant-markdown.md: 38d193271d88b3a8f32ba1b191e8a6d432176281 -2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +2026-07-23-web-assistant-markdown.md: d5074e6090699229f5c43dd93eef0fdfbfedab76 +2026-07-23-web-assistant-markdown.zh.md: 31f0fd6835c9921f544f4b6217a0c834dff79859 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index 38d193271d..d5074e6090 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -20,7 +20,7 @@ The dependency is explicit in `ui-primitives`; because that pure library is seed ## Untrusted output policy -Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). +Assistant-authored link destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images follow the separate [remote-image policy](2026-07-30-web-remote-markdown-images.md). Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. @@ -32,7 +32,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. -**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies. +**Enable raw HTML with sanitization.** Raw HTML has no current product need and would enlarge the executable-content boundary, so it remains disabled rather than adding a sanitizer dependency. Remote images are governed by the later [image policy](2026-07-30-web-remote-markdown-images.md). **Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index be3cd041c6..31f0fd6835 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -20,7 +20,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd ## 不受信任输出策略 -assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 +assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片遵循独立的[远程图片策略](2026-07-30-web-remote-markdown-images.md)。由于流水线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 @@ -32,7 +32,7 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 -**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。 +**通过净化启用原始 HTML。** 原始 HTML 当前没有产品需求,并且会扩大可执行内容边界,因此保持禁用,无需增加净化器依赖。远程图片由后续的[图片策略](2026-07-30-web-remote-markdown-images.md)约束。 **移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 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/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml index 21eea20254..5268ea62f9 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.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-web-session-fork-actions.md -2026-07-27-web-session-fork-actions.md: 58960169a2e499d953840e5769e7689b5cd48047 -2026-07-27-web-session-fork-actions.zh.md: ea2f9030f672f00fb91bce3546836689a7d41004 +2026-07-27-web-session-fork-actions.md: 578e59ec92e003fe5c8cdfe951a595f3a7371ecf +2026-07-27-web-session-fork-actions.zh.md: d90124f6e6e164b0a1e0fce734f52976630cd848 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md index 58960169a2..578e59ec92 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md @@ -10,7 +10,9 @@ The Session store already provides a fork primitive that creates a child session ## Decision -The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn containing that event. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. +The message-eligibility portion of this decision is narrowed by the [completed-turn-tail decision](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md); the shared runtime action, injection ownership, title handling, and peer-list decisions remain current. + +The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; an eligible completed-turn-tail message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn ending at that message. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. `forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation. @@ -28,6 +30,6 @@ Session lineage is not projected into a list hierarchy. WorkSpace mode displays ## Consequences -Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. +Users can create forks from Session rows or eligible completed-turn-tail messages; both entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. -Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. +Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests pin eligible message `seq` forwarding, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md index ea2f9030f6..d90124f6e6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md @@ -10,7 +10,9 @@ Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 We ## Decision -Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在包含该事件的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 +本决策中的消息资格部分由[已完成轮次尾部决策](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)收紧;共享运行时操作、注入归属、标题处理和同级列表决策仍然有效。 + +Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;符合条件且位于已完成轮次尾部的消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在以该消息结束的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 `forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。 @@ -28,6 +30,6 @@ Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.se ## Consequences -用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,三处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 +用户可从 session 行或符合条件的已完成轮次尾部消息创建分支,两处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 -Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq`、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 +Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。包级测试固定符合条件的消息 `seq` 转发、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index dc59a0ccb1..abb388a410 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: b959fd35a4f5e2a6fa68deed8776ccbae86a0647 -2026-07-27-web-subagent-conversations.zh.md: dc0297d92acb5ab05dcdc5c682fd0a7fe2a2a18a +2026-07-27-web-subagent-conversations.md: 03c805d30dbdbbdb33d0b06ba8036ec181035ac6 +2026-07-27-web-subagent-conversations.zh.md: e83f07fb21f58ad175ab3e5638981648fa4cef05 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index b959fd35a4..03c805d30d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -33,7 +33,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha | The session header opens a compact child list. | The trigger aggregates the complete subagent-only descendant lineage; the tree shows every direct catalog entry in service order, including disabled diagnostics. | | Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. | | Nested agents expand progressively. | Each row carries a one-level `hasChildren` snapshot; disclosure reserves known direct-descendant rows immediately, then loads only that row's direct catalog and retains its own parent address. | -| Rows show labels, state, and relative time without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and time come from summaries. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | +| Rows show labels, state, usage, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title, durable token usage, and active-turn duration come from the list's retained projection values. Compact duration loses smaller units above one day, while hover and accessible naming retain exact whole seconds. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | ## Product contract @@ -41,6 +41,8 @@ The header action is absent only when a complete empty direct-catalog response a `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. +Healthy rows reuse the standard session projections retained in the list mirror. The token figure sums the four disjoint `tokenUsage` buckets across the durable log. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries same-cut `active.since` and `active.through` bounds for an open turn. Existing session events advance `active.through` while that turn remains open; the menu adds no separate timer or log read and advances its local clock only while a known descendant is running. Below one day it formats whole seconds; longer visual values retain at most two adjacent units, using approximate 30-day months and 365-day years, while hover and accessible naming preserve the exact day/hour/minute/second duration. An inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. Neither metric implies a durable outcome. + Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior. @@ -65,7 +67,7 @@ The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier ## Client object layer and presentation -The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, and transport selection. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address. +The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, transport selection, and a reference-stable map of each list row's current projection values. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address. Catalogs ride the standard `useSessions` snapshot. Component-local state owns menu visibility, expanded branches, and focus. `ui-conversation` declares the generic header-action list slot and dispatches the current conversation snapshot through its composer chain; it contains no subagent-specific takeover flag. `@deepseek-ai/dsh-client-ui-subagent` registers the catalog action and elects a reason-specific read-only composer from ordinary owner props. Components receive derived props and callbacks, never `ctx`. @@ -102,14 +104,14 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences -- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while membership refresh stays debounced and single-flight. +- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while usage and duration reuse projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight. - Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected. - A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path. - Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut. -- The UI has no child cancellation, durable outcome, activation duration, deletion, or independently interactive offline mode, and its text must not imply those capabilities. +- The UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index dc0297d92a..e83f07fb21 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -33,7 +33,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 | 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 | | 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 | | 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 | -| 条目显示 label、状态与相对时间,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与时间来自摘要。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | +| 条目显示 label、状态、token 用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与活跃轮次耗时来自列表保留的投影值。紧凑耗时从一天起省略更小的单位,而悬停和无障碍名称仍保留精确的整秒数。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | ## 产品契约 @@ -41,6 +41,8 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 +健康行会复用列表镜像中保留的标准会话投影。token 用量数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。该轮次保持未结束期间,现有会话事件会推进 `active.through`;菜单不会增加单独的计时器或日志读取,且仅在有已知后代处于运行状态时才推进其本地时钟。不足一天时,菜单会以整秒格式化时间;达到一天后的视觉值最多保留两个相邻单位,其中月份按近似 30 天计算,年份按近似 365 天计算,而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒耗时。对 inactive 行,菜单以 `active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。这两项指标都不蕴含持久化结果语义。 + 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。 @@ -65,7 +67,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 ## 客户端对象层与呈现 -不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示与传输选择。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。 +不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示、传输选择,以及每个列表行当前投影值的引用稳定映射。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。 目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支与焦点。`ui-conversation` 声明通用页头操作列表 slot,并通过其编辑器链分发当前对话快照;其中没有 subagent 专用的接管标记。`@deepseek-ai/dsh-client-ui-subagent` 注册目录操作,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。 @@ -102,14 +104,14 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 -- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而成员刷新保持去抖动和单次并发。 +- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而 token 用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。 - parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。 - child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。 - 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。 -- UI 不提供 child 取消、持久化结果、激活耗时、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。 +- UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml new file mode 100644 index 0000000000..338f90664d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md +2026-07-29-tui-hidden-mode-assistant-fold.md: e2bae1d4669fd0a4c8f9278c58f641704b425109 +2026-07-29-tui-hidden-mode-assistant-fold.zh.md: 583d6099f9cd5edfc592a9d510d260d7a4172013 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md new file mode 100644 index 0000000000..e2bae1d466 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md @@ -0,0 +1,25 @@ +# Agent Note: TUI hidden mode folds a turn's assistant steps into one message + +Status: implemented + +English | [中文](2026-07-29-tui-hidden-mode-assistant-fold.zh.md) + +## Problem + +Ctrl+O's hidden phase ([consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) drops tool cards so the transcript reads as a conversation, but each model step still rendered its own `Assistant` header. A multi-step turn (text → tools → text) therefore showed several consecutive `Assistant` blocks with nothing between them — the removed tool cards were the only thing that had justified the repeated headers. Codex-style conversation-only reading wants one assistant message per turn. + +## Decision + +Hidden mode is also a fold rule, applied purely as TUI presentation: per turn, the first step whose rendered content is visible (text, or reasoning while reasoning display is on) owns the turn's single `Assistant` header; every other step renders as a headerless continuation, and a step with no visible body renders nothing at all — a tool-only step neither consumes the header nor leaves a blank segment. Collapsed and expanded phases keep per-step headers; leaving hidden restores them. + +Mechanics: `StreamingAssistantComponent` carries its `StepPosition` and a `setFoldedContinuation` presentation flag; `createTuiChat` keeps a per-turn list of step components and re-derives the fold on Ctrl+O, on each streamed text/reasoning chunk, on message settle, and on retraction of a failed stream (which may hand the header to the next step). Transcript rebuild clears the map and replays the log, so resume, compaction replacement, resize, and theme swaps converge on the same fold. Step timing footers keep their per-step ownership and are unaffected. + +## Alternatives considered + +- **Merge steps into one component** — collides with per-step streaming lifecycle, retry retraction, and timing footers; the flag on existing components changes only the header/spacer. +- **Fold in the session log or `deriveMessages`** — mutates durable/model-visible history for a UI reading mode; the log stays step-shaped. +- **Always fold (all visibility phases)** — collapsed/expanded interleave tool cards between steps, where per-step headers delimit which output belongs to which step. + +## Consequences + +Hidden mode now reads as one assistant message per turn; turns stay separated by their headers. The fold is recomputed state, never stored, so no session or persistence format changes. Coverage: TUI unit specs for the Ctrl+O cycle header counts, tool-only first step header handoff, per-turn separation, and live streaming + rebuild convergence; keyless snapshot `tool-cards-hidden-folded` pins the folded frame. diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md new file mode 100644 index 0000000000..583d6099f9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md @@ -0,0 +1,25 @@ +# Agent Note: TUI 隐藏模式把一个轮次的 assistant 步骤折叠为一条消息 + +Status: implemented + +[English](2026-07-29-tui-hidden-mode-assistant-fold.md) | 中文 + +## 问题 + +Ctrl+O 的隐藏阶段([整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))去掉工具卡片,让 transcript(文本记录)读作一段对话,但每个模型步骤仍渲染自己的 `Assistant` 标题。因此一个多步骤轮次(文本 → 工具 → 文本)会显示多个连续、之间空无一物的 `Assistant` 区块——被移除的工具卡片正是重复标题曾经的唯一理由。Codex 风格的纯对话阅读需要每轮次一条 assistant 消息。 + +## 决定 + +隐藏模式同时也是一条折叠规则,且纯粹作为 TUI 展示实现:在每个轮次内,第一个渲染内容可见(有文本,或在 reasoning 显示开启时有 reasoning)的步骤拥有该轮次唯一的 `Assistant` 标题;其余步骤渲染为无标题的续段,没有可见正文的步骤则完全不渲染——仅有工具调用的步骤既不占用标题,也不留下空白段。折叠与展开阶段保留每步各自的标题;离开隐藏阶段会恢复它们。 + +机制:`StreamingAssistantComponent` 携带自己的 `StepPosition` 和一个 `setFoldedContinuation` 展示标志;`createTuiChat` 维护每轮次的步骤组件列表,并在 Ctrl+O、每个流式 text/reasoning chunk、消息结算,以及失败流被撤回(可能把标题移交给下一个步骤)时重新推导折叠。transcript 重建会清空该映射并重放日志,因此恢复、压缩替换、调整尺寸和主题切换收敛到同一折叠结果。步骤计时页脚保持按步骤归属,不受影响。 + +## 考虑过的替代方案 + +- **把多个步骤合并为一个组件**——与按步骤的流式生命周期、重试撤回和计时页脚冲突;在现有组件上加标志只改变标题与前导间距。 +- **在会话日志或 `deriveMessages` 中折叠**——为一种 UI 阅读模式改变持久 / 模型可见的历史;日志保持按步骤的形状。 +- **所有可见性阶段都折叠**——折叠 / 展开阶段在步骤之间穿插工具卡片,此时每步的标题用来划分哪段输出属于哪个步骤。 + +## 后果 + +隐藏模式现在每轮次读作一条 assistant 消息;轮次之间仍由各自的标题分隔。折叠是重新计算的状态,从不存储,因此会话与持久化格式没有变化。覆盖:TUI 单元测试覆盖 Ctrl+O 循环的标题计数、仅工具的首步骤标题移交、按轮次分隔,以及实时流式 + 重建收敛;无密钥快照 `tool-cards-hidden-folded` 固定折叠后的帧。 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml new file mode 100644 index 0000000000..5a1b64f6b3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-tui-details-command.md +2026-07-30-tui-details-command.md: fb7c4dfaedeff27c9cafd0ba82daf4739665f19c +2026-07-30-tui-details-command.zh.md: 5f9e033311d1999998ef08b8f340d71f751b11f6 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md new file mode 100644 index 0000000000..fb7c4dfaed --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -0,0 +1,34 @@ +# Agent Note: /details command for transcript detail state + +Status: implemented + +English | [中文](2026-07-30-tui-details-command.zh.md) + +## Problem + +The TUI's transcript detail state — tool-card visibility (`collapsed`/`expanded`/`hidden`, per the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) and reasoning-block display — was reachable only through the Ctrl+O cycle and the Ctrl+R toggle. A user who wants a specific mode must cycle through the others, cannot set both dimensions in one action, and has no way to query the current state; a terminal that swallows those control keys has no fallback at all. + +## Decision + +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values: Tab cycles the highlighted entry and applies the change immediately, so the transcript behind the dialog is the preview, and Enter, Esc, or Ctrl+C closes; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. + +A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. + +The reasoning rebuild exposed a replay defect that this change fixes in `renderEvent`: the live path cleared a settled `StreamingAssistantComponent` before a later `assistant/message` of the same step (so the second message got a fresh component), but `rebuildTranscript` replay reused the settled component and `settle()` overwrote its content, silently dropping the earlier message's text. The settled check now lives in `renderEvent`'s `assistant/message` case — one home for both paths — and the previously wrong `untrusted-controls` snapshot (an empty `Assistant` header where reasoning and text had been dropped) was re-recorded with the content present. + +## Alternatives considered + +**Cycle on bare `/details`, mirroring Ctrl+O.** Rejected: the command's value over the shortcut is naming an absolute state; a cycling command is the shortcut with more keystrokes, and bare invocation is more useful as the selector, which shows the current state while offering every target. + +**Bare `/details` as a text-only state report.** Shipped first, replaced by the selector: the report answered "where am I" but still required a second, argument-spelling invocation to change anything, while the selector shows the same state and applies a change in one interaction. The textual grammar remains for scripts, muscle memory, and combined two-dimension changes. + +**Separate `/tools` and `/reasoning` commands.** Rejected: both dimensions are one presentation concern ("how much detail does the transcript show"), and a single command keeps the registry and `/help` list small while allowing one combined invocation. + +**Config-key defaults per mode.** Out of scope: `showReasoning` already exists as config; the command is runtime state on top of it, matching the shortcuts. + +## Consequences + +- A user can jump to any detail mode, set both dimensions at once, and see the current state in the selector — including on terminals that intercept Ctrl+O/Ctrl+R. +- The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. +- The selector has no pending state or cancel: every Tab is a real, already-notified change, and closing never reverts. A user who over-cycles simply Tabs on to the wanted value. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open toggle right after a Tab applied `hidden` -> `collapsed`, including the restored tool card behind it. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md new file mode 100644 index 0000000000..5f9e033311 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 用于 transcript 细节状态的 /details 命令 + +Status: implemented + +[English](2026-07-30-tui-details-command.md) | 中文 + +## Problem + +TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`collapsed`/`expanded`/`hidden`,见[整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))与 reasoning 块显示——过去只能通过 Ctrl+O 循环和 Ctrl+R 切换来触达。想要某个特定模式的用户必须循环经过其他模式,无法一次操作同时设置两个维度,也无法查询当前状态;吞掉这些控制键的终端更是完全没有替代途径。 + +## Decision + +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值:Tab 循环高亮条目并立即应用变更,对话框背后的 transcript 即是预览,Enter、Esc 或 Ctrl+C 关闭;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 + +组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 + +reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修复:实时路径会在同一步骤的后续 `assistant/message` 之前清除已结算的 `StreamingAssistantComponent`(因此第二条消息获得新组件),但 `rebuildTranscript` 重放复用了已结算组件,`settle()` 覆盖其内容,静默丢掉了前一条消息的文本。已结算检查现在位于 `renderEvent` 的 `assistant/message` 分支——两条路径共用一个归属地——此前错误的 `untrusted-controls` 快照(reasoning 与文本被丢弃后只剩空 `Assistant` 标题)已重录为包含内容的版本。 + +## Alternatives considered + +**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为选择器更有用——它在展示当前状态的同时提供所有目标。 + +**裸 `/details` 仅输出文本状态报告。** 首版如此实现,后被选择器取代:报告回答了“我在哪”,但改变任何东西仍需第二次、拼写参数的调用;选择器展示同样的状态并在一次交互中应用变更。文本语法保留给脚本、肌肉记忆和两维组合变更。 + +**拆分 `/tools` 与 `/reasoning` 两个命令。** 否决:两个维度同属一个展示关注点(“transcript 显示多少细节”),单一命令让注册表与 `/help` 列表更小,同时允许一次组合调用。 + +**按模式提供配置键默认值。** 超出范围:`showReasoning` 已作为配置存在;命令是其上的运行时状态,与快捷键一致。 + +## Consequences + +- 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 +- 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 +- 选择器没有待定状态与取消:每次 Tab 都是已生效、已通知的真实变更,关闭从不回退。循环过头的用户继续 Tab 到想要的值即可。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定 Tab 将 `hidden` 应用为 `collapsed` 后仍打开的开关,包括其背后恢复显示的工具卡片。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml new file mode 100644 index 0000000000..afe776d402 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md +2026-07-30-web-remote-markdown-images.md: 23dc699aab87597b7b5bfee83d0d745d4798303e +2026-07-30-web-remote-markdown-images.zh.md: 54db9b25e9f558d77c84bb67c05fadea2c9086ac diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md new file mode 100644 index 0000000000..23dc699aab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md @@ -0,0 +1,29 @@ +# Agent Note: Remote Web Markdown images + +Status: implemented + +English | [中文](2026-07-30-web-remote-markdown-images.zh.md) + +## Problem + +Assistant Markdown can name diagrams and screenshots with standard image syntax, but the Web renderer replaces every image with italic alt text. Even absolute HTTP(S) destinations therefore lose ordinary Markdown behavior. + +## Decision + +`MarkdownText` renders absolute HTTP(S) image destinations as lazy, responsive `` elements with asynchronous decoding and `referrerPolicy="no-referrer"`. Relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain the existing alt-text fallback. Raw HTML stays disabled, so an assistant cannot bypass the Markdown image component with a hand-authored ``. + +The image component reuses the renderer's absolute-URL policy without adding a host proxy, local-file route, Session dependency, sanitizer, or image fetcher. Finalized history, streaming output, interrupted partials, and every other `MarkdownText` consumer receive the same behavior. + +## Alternatives considered + +**Keep all images as alt text.** This preserves the smallest network boundary but defeats the product need to inspect network-hosted visual artifacts inline. + +**Proxy remote images through the host.** A proxy could hide the browser's network address from the image origin, but it would make the host perform arbitrary outbound fetches and require a separate redirect, DNS, size, and content policy. Direct HTTP(S) loading keeps that request visible to browser controls; omitting the referrer limits conversation-origin disclosure. + +**Support local paths in the same change.** Web origins cannot directly load host files. A safe implementation needs a separately reviewed authority boundary, so relative paths, absolute local paths, and `file:` URLs remain disabled. + +**Allow `data:` images.** Large data URLs duplicate binary content into durable transcript text. The HTTP(S)-only policy covers the current need without expanding session logs. + +## Consequences + +Assistant replies display remote images during streaming and replay without changing session events or host protocols. Remote origins still observe the image request, client network address, and any credentials that browser policy permits for that origin. Local and unsupported destinations remain inert alt text. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md new file mode 100644 index 0000000000..54db9b25e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 中的远程 Markdown 图片 + +Status: implemented + +[English](2026-07-30-web-remote-markdown-images.md) | 中文 + +## 问题 + +assistant Markdown 可以使用标准图片语法引用图表和截图,但 Web 渲染器会把每张图片替换为斜体替代文本。因此,即使目标地址是绝对 HTTP(S) URL,也无法获得普通的 Markdown 图片行为。 + +## 决策 + +`MarkdownText` 将绝对 HTTP(S) 图片目标地址渲染为延迟加载的响应式 `` 元素,并使用异步解码与 `referrerPolicy="no-referrer"`。相对路径、绝对本地路径、`file:` URL 与不支持的协议继续沿用现有的替代文本回退。原始 HTML 保持禁用,因此 assistant 无法通过手写 `` 绕过 Markdown 图片组件。 + +图片组件复用渲染器的绝对 URL 策略,不新增主机代理、本地文件路由、Session 依赖、净化器或图片抓取器。已完成的历史消息、流式输出、被中断的部分输出以及其他所有 `MarkdownText` 消费方均获得同一行为。 + +## 考虑过的替代方案 + +**将所有图片都保留为替代文本。** 这种方案维持了最小的网络边界,但无法满足在行内查看网络托管的视觉产物这一产品需求。 + +**通过主机代理远程图片。** 代理可以向图片源站隐藏浏览器的网络地址,但这会让主机执行任意出站请求,并且需要单独制定重定向、DNS、大小与内容策略。直接加载 HTTP(S) 图片可让浏览器控制机制继续观察该请求;不发送 referrer 可减少对话来源信息的暴露。 + +**在同一变更中支持本地路径。** Web 源无法直接加载主机文件。安全的实现需要单独评审的权限边界,因此相对路径、绝对本地路径与 `file:` URL 保持禁用。 + +**允许 `data:` 图片。** 大型 data URL 会将二进制内容以文本形式重复写入持久化的 transcript(文本记录)。仅允许 HTTP(S) 的策略足以满足当前需求,且不会扩大会话日志。 + +## 后果 + +assistant 回复会在流式输出与回放期间显示远程图片,且不改变会话事件或主机协议。远程源站仍可观察到图片请求、客户端网络地址,以及浏览器策略允许发送给该源站的任何凭据。本地及不支持的目标地址仍只显示不会发起请求的替代文本。 diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000000..9427dc1d52 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,22 @@ +--- +name: Bug +about: 记录现有预期行为的失效 +title: '' +labels: '' +assignees: '' +type: Bug +--- + + +一句话说明错误结果。 + +
+复现、预期与验收 + +- 复现步骤: +- 实际结果: +- 预期结果: +- 环境: +- 验收条件: + +
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..8005e32267 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000000..2c65f5544d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,20 @@ +--- +name: Feature +about: 新增或有意改变可观察行为 +title: '' +labels: '' +assignees: '' +type: Feature +--- + + +一句话说明预期结果。 + +
+验收与细节 + +- 验收条件: +- 用户或模型可见变化: +- 测试证据: + +
diff --git a/.github/ISSUE_TEMPLATE/idea.md b/.github/ISSUE_TEMPLATE/idea.md new file mode 100644 index 0000000000..c8bf80402d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/idea.md @@ -0,0 +1,20 @@ +--- +name: Idea +about: 记录尚未承诺实施、但具有行动可能的想法 +title: '' +labels: '' +assignees: '' +type: Idea +--- + + +一句话说明价值假设。 + +
+价值与细节 + +- 价值假设: +- 需要验证: +- 可能的后续工作: + +
diff --git a/.github/ISSUE_TEMPLATE/research.md b/.github/ISSUE_TEMPLATE/research.md new file mode 100644 index 0000000000..8acfe2cc6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/research.md @@ -0,0 +1,21 @@ +--- +name: Research +about: 形成结论、证据或决策 +title: '' +labels: '' +assignees: '' +type: Research +--- + + +一句话说明待回答的问题。 + +
+问题与证据标准 + +- 核心问题: +- 证据标准: +- 交付结论: +- 可能的后续工作: + +
diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000000..376855c27c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,20 @@ +--- +name: Task +about: 明确的非 Feature、非 Bug 工作 +title: '' +labels: '' +assignees: '' +type: Task +--- + + +一句话说明要完成的工作。 + +
+验收与细节 + +- 验收条件: +- 交付物: +- 测试证据: + +
diff --git a/.github/issue-management/config.json b/.github/issue-management/config.json new file mode 100644 index 0000000000..41019f0aa2 --- /dev/null +++ b/.github/issue-management/config.json @@ -0,0 +1,17 @@ +{ + "organization": "deepseek-harness", + "repository": "deepseek-harness", + "projectNumber": 1, + "projectTitle": "DSH Issue Management", + "priorityField": "Priority", + "allowUnassignedOwner": true, + "statuses": [ + "Inbox", + "Backlog", + "Ready", + "In progress", + "In review", + "Done", + "No action" + ] +} diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs new file mode 100644 index 0000000000..bc8c881eda --- /dev/null +++ b/.github/issue-management/policy.mjs @@ -0,0 +1,545 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import config from './config.json' with { type: 'json' } + +const API_VERSION = '2026-03-10' +const BODY_LIMIT = 50 +const AUDIT_MARKER = '' +const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ +const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) +const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] + +/** + * Return Markdown outside balanced details elements. + * @param {string} body Markdown body. + * @returns {{text: string, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible source and details shape. + */ +export function extractOutsideDetails(body) { + const source = body.replace(//g, '') + const tag = /<\/?details\b[^>]*>/gi + let depth = 0 + let cursor = 0 + let balanced = true + let text = '' + let detailsCount = 0 + let allCollapsed = true + + for (const match of source.matchAll(tag)) { + const index = match.index ?? 0 + if (depth === 0) text += source.slice(cursor, index) + if (/^<\//.test(match[0])) { + if (depth === 0) balanced = false + else depth -= 1 + } else { + depth += 1 + detailsCount += 1 + if (/\sopen(?:\s|=|>)/i.test(match[0])) allCollapsed = false + } + cursor = index + match[0].length + } + + if (depth === 0) text += source.slice(cursor) + if (depth !== 0) balanced = false + return { text, balanced, detailsCount, allCollapsed } +} + +/** + * Count Chinese characters and contiguous Latin, numeric, or code tokens. + * @param {string} body Markdown body. + * @returns {{units: number, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible unit count and details shape. + */ +export function countVisibleUnits(body) { + const outside = extractOutsideDetails(body) + const visible = outside.text + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1') + .replace(/<((?:https?:\/\/|mailto:)[^>]+)>/gi, '$1') + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);/g, ' ') + .replace(/[\u0060*~\[\]{}()<>#!|]/g, ' ') + const han = visible.match(/\p{Script=Han}/gu)?.length ?? 0 + const tokens = visible.match(/[\p{Script=Latin}\p{Number}_./:@+-]+/gu)?.length ?? 0 + return { + units: han + tokens, + balanced: outside.balanced, + detailsCount: outside.detailsCount, + allCollapsed: outside.allCollapsed, + } +} + +function firstNonblankLine(body) { + return body + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) +} + +/** + * Validate body shape and Owner against assignees. + * @param {{body: string, assignees: string[], allowUnassignedOwner?: boolean}} input Body input. + * @returns {string[]} Validation errors. + */ +export function validateBody({ + body, + assignees, + allowUnassignedOwner = config.allowUnassignedOwner ?? false, +}) { + const errors = [] + const count = countVisibleUnits(body) + const owner = firstNonblankLine(body)?.match(OWNER_LINE)?.[1] ?? null + const normalized = [...new Set(assignees.map((login) => login.toLowerCase()))] + + if (!count.balanced) errors.push('details 标签必须成对闭合') + if (count.detailsCount === 0) errors.push('正文必须包含默认收起的
区域') + if (!count.allCollapsed) errors.push('details 必须默认收起,不得设置 open') + if (count.units > BODY_LIMIT) { + errors.push(`正文外露部分为 ${count.units} 单位,超过 50 单位`) + } + if (normalized.length >= 2 && !owner) { + errors.push('多个 Assignees 时首个非空行必须是 Owner: @login') + } else if (normalized.length >= 2 && !normalized.includes(owner.toLowerCase())) { + errors.push('Owner 必须属于 Assignees') + } else if ( + normalized.length < 2 && + owner && + !(normalized.length === 0 && allowUnassignedOwner) + ) { + errors.push('零或一个 Assignee 时不得写 Owner 行') + } + return errors +} + +/** + * Decide whether a PR has entered the human-review enforcement boundary. + * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state. + * @returns {boolean} Whether the PR policy is mandatory. + */ +export function requiresPullRequestPolicy({ + isDraft, + authorType, + reviewRequestCount, + reviewCount, +}) { + const automated = authorType === 'Bot' || authorType === 'App' + return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0) +} + +function stripIgnoredMarkdown(body) { + const lines = body.replace(//g, '').split(/\r?\n/) + const kept = [] + let fence = null + for (const line of lines) { + const marker = line.match(/^\s*([\u0060~]{3,})/) + if (marker) { + if (fence === null) fence = marker[1][0] + else if (marker[1][0] === fence) fence = null + continue + } + if (fence === null) kept.push(line) + } + return kept.join('\n').replace(/\u0060[^\u0060]*\u0060/g, ' ') +} + +/** + * Parse same-repository resolving and informational references. + * @param {{body: string, repository: string}} input PR body and repository. + * @returns {{all: number[], resolving: number[], related: number[]}} References. + */ +export function parseReferences({ body, repository }) { + const source = stripIgnoredMarkdown(body) + const expected = repository.toLowerCase() + const all = new Set() + const resolving = new Set() + const reference = + /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)/gi + const closing = + /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?)\s*:?\s+(?:(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+))/gi + + for (const match of source.matchAll(reference)) { + const explicit = (match[1] ?? match[3] ?? '').toLowerCase() + const number = Number(match[2] ?? match[4]) + if (!explicit || explicit === expected) all.add(number) + } + for (const match of source.matchAll(closing)) { + const explicit = (match[1] ?? match[3] ?? '').toLowerCase() + const number = Number(match[2] ?? match[4]) + if (!explicit || explicit === expected) { + all.add(number) + resolving.add(number) + } + } + return { + all: [...all].sort((left, right) => left - right), + resolving: [...resolving].sort((left, right) => left - right), + related: [...all].filter((number) => !resolving.has(number)).sort((a, b) => a - b), + } +} + +/** + * Validate one Issue with its Project status. + * @param {{title: string, body: string, assignees: string[], labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot. + * @returns {string[]} Validation errors. + */ +export function validateIssue(issue) { + const errors = validateBody(issue) + const status = issue.status + + if (!/\p{Script=Han}/u.test(issue.title)) errors.push('Issue 标题必须包含中文') + if ( + /^\s*(?:\[(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^\]]+)[^\]]*\]|(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^:: ]+)\s*[::-])/iu.test( + issue.title, + ) + ) { + errors.push('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀') + } + if (!TYPES.has(issue.type ?? '')) errors.push('Type 必须是五种原生英文 Type 之一') + if (!status || !config.statuses.includes(status)) errors.push('Issue 必须在 Project 中且具有合法 Status') + if (issue.priority !== null && !PRIORITIES.includes(issue.priority.toLowerCase())) { + errors.push('Priority 必须为空或为 P0–P3') + } + if (status === 'Done' && (issue.state !== 'closed' || issue.stateReason !== 'completed')) { + errors.push('Done 必须对应 Completed 关闭原因') + } + if ( + status === 'No action' && + (issue.state !== 'closed' || issue.stateReason !== 'not_planned') + ) { + errors.push('No action 必须对应 Not planned 关闭原因') + } + if (!['Done', 'No action'].includes(status ?? '') && issue.state !== 'open') { + errors.push(`${status} 必须对应开放 Issue`) + } + return errors +} + +/** + * Validate PR metadata and its referenced Issues. + * @param {{authorType: string, labels: string[], references: ReturnType, issues: Map}} input PR snapshot. + * @returns {string[]} Validation errors. + */ +export function validatePullRequest(input) { + if (!requiresPullRequestPolicy(input)) return [] + const errors = [] + const kinds = input.labels.filter((label) => label.startsWith('kind/')) + const priorities = input.labels.filter((label) => PRIORITIES.includes(label)) + const areas = input.labels.filter((label) => label.startsWith('area/')) + + if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue') + if (kinds.length !== 1) errors.push(`PR 必须恰好有一个 kind/*,当前为 ${kinds.length}`) + if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`) + if (areas.length === 0) errors.push('PR 必须至少有一个 area/*') + for (const number of input.references.all) { + if (!input.issues.has(number)) errors.push(`#${number} 不是同仓库 Issue`) + } + + const resolving = input.references.resolving + .map((number) => [number, input.issues.get(number)]) + .filter((entry) => entry[1]) + if (resolving.length === 0) return errors + + const issuePriorities = resolving + .map(([, issue]) => issue.priority?.toLowerCase()) + .filter((priority) => PRIORITIES.includes(priority)) + if (priorities.length === 0 && issuePriorities.length > 0) { + const highest = issuePriorities.sort( + (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right), + )[0] + errors.push(`PR Priority 应为 ${highest}`) + } else if (priorities.length === 1 && issuePriorities.length !== resolving.length) { + errors.push('有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority') + } else if (priorities.length === 1) { + const highest = issuePriorities.sort( + (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right), + )[0] + if (priorities[0] !== highest) errors.push(`PR Priority 应为 ${highest}`) + } + return errors +} + +function token() { + const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + if (!value) throw new Error('GH_TOKEN 或 GITHUB_TOKEN 未设置') + return value +} + +async function api(path, options = {}) { + const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token()}`, + 'X-GitHub-Api-Version': API_VERSION, + 'User-Agent': 'dsh-issue-policy', + ...options.headers, + }, + }) + if (options.allow404 && response.status === 404) return null + if (!response.ok) { + const body = await response.text() + throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status} ${body}`) + } + if (response.status === 204) return null + return response.json() +} + +async function graphql(query, variables) { + const result = await api('/graphql', { + method: 'POST', + body: JSON.stringify({ query, variables }), + headers: { 'Content-Type': 'application/json' }, + }) + if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; ')) + return result.data +} + +async function issueSnapshot(number, status = undefined) { + const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`) + if (issue.pull_request) return null + const values = await api( + `/repos/${config.organization}/${config.repository}/issues/${number}/issue-field-values?per_page=100`, + ) + const field = (name) => values.find((value) => value.issue_field_name === name) + return { + number, + nodeId: issue.node_id, + title: issue.title, + body: issue.body ?? '', + assignees: issue.assignees.map((assignee) => assignee.login), + labels: issue.labels.map((label) => label.name), + type: issue.type?.name ?? null, + priority: field(config.priorityField)?.single_select_option?.name ?? null, + status: status === undefined ? await projectStatus(number) : status, + state: issue.state, + stateReason: issue.state_reason ?? null, + } +} + +async function projectContext(number) { + const data = await graphql( + `query($organization: String!, $repository: String!, $number: Int!, $project: Int!) { + organization(login: $organization) { + projectV2(number: $project) { + id + title + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + repository(owner: $organization, name: $repository) { + issue(number: $number) { + id + projectItems(first: 20, includeArchived: true) { + nodes { + id + project { id } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name optionId } + } + } + } + } + } + }`, + { + organization: config.organization, + repository: config.repository, + number, + project: config.projectNumber, + }, + ) + const project = data.organization?.projectV2 + const issue = data.repository?.issue + if (!project || project.title !== config.projectTitle) throw new Error('目标 Project 不存在或标题不匹配') + if (!issue) throw new Error(`#${number} 不存在`) + const statusField = project.fields.nodes.find((field) => field?.name === 'Status') + if (!statusField) throw new Error('Project 缺少 Status 字段') + const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id) + return { project, issue, statusField, item } +} + +async function projectStatus(number) { + const context = await projectContext(number) + return context.item?.fieldValueByName?.name ?? null +} + +async function ensureProjectItem(number) { + const context = await projectContext(number) + if (context.item) return context + const data = await graphql( + `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { id } + } + }`, + { projectId: context.project.id, contentId: context.issue.id }, + ) + return { + ...context, + item: { id: data.addProjectV2ItemById.item.id, fieldValueByName: null }, + } +} + +async function setStatus(number, status) { + const context = await ensureProjectItem(number) + const option = context.statusField.options.find((candidate) => candidate.name === status) + if (!option) throw new Error(`Status 不存在:${status}`) + if (context.item.fieldValueByName?.name === status) return + await graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: {singleSelectOptionId: $optionId} + }) { projectV2Item { id } } + }`, + { + projectId: context.project.id, + itemId: context.item.id, + fieldId: context.statusField.id, + optionId: option.id, + }, + ) +} + +async function upsertAudit(number, errors) { + const comments = await api( + `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`, + ) + const existing = comments.find( + (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AUDIT_MARKER), + ) + if (errors.length === 0) { + if (existing) { + await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, { + method: 'DELETE', + }) + } + return + } + const body = `${AUDIT_MARKER}\n⚠️ Issue policy 未通过:\n\n${errors.map((error) => `- ${error}`).join('\n')}` + if (existing) { + if (existing.body === body) return + await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, { + method: 'PATCH', + body: JSON.stringify({ body }), + headers: { 'Content-Type': 'application/json' }, + }) + } else { + await api(`/repos/${config.organization}/${config.repository}/issues/${number}/comments`, { + method: 'POST', + body: JSON.stringify({ body }), + headers: { 'Content-Type': 'application/json' }, + }) + } +} + +async function auditIssue(number, extraErrors = [], status = undefined) { + const issue = await issueSnapshot(number, status) + if (!issue) return [] + const errors = [...extraErrors, ...validateIssue(issue)] + await upsertAudit(number, errors) + return errors +} + +async function pullRequestSnapshot(number) { + const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) + const [reviewRequests, reviews] = await Promise.all([ + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`), + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`), + ]) + const references = parseReferences({ + body: pull.body ?? '', + repository: `${config.organization}/${config.repository}`, + }) + const issues = new Map() + for (const issueNumber of references.all) { + const issue = await issueSnapshot(issueNumber, null) + if (issue) issues.set(issueNumber, issue) + } + return { + number, + isDraft: pull.draft, + authorType: pull.user?.type ?? 'User', + reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, + reviewCount: reviews.length, + labels: pull.labels.map((label) => label.name), + references, + issues, + } +} + +async function moveResolvingIssues(pull, from, to) { + for (const number of pull.references.resolving) { + const current = await issueSnapshot(number) + if (!current || current.status !== from) continue + await setStatus(number, to) + await auditIssue(number) + } +} + +async function runPullRequestCheck(event) { + const pull = await pullRequestSnapshot(event.pull_request.number) + const errors = validatePullRequest(pull) + if (errors.length > 0) { + for (const error of errors) process.stdout.write(`::error::${error}\n`) + throw new Error(`Issue policy 未通过,共 ${errors.length} 项`) + } + process.stdout.write( + requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : 'PR 尚未进入 Issue policy 强制范围。\n', + ) +} + +async function runLifecycle(eventName, event) { + if (eventName === 'issues') { + const number = event.issue.number + if (event.action === 'opened') await setStatus(number, 'Inbox') + if (event.action === 'closed') { + const target = event.issue.state_reason === 'not_planned' ? 'No action' : 'Done' + await setStatus(number, target) + } + if (event.action === 'reopened') { + await setStatus(number, 'Inbox') + } + await ensureProjectItem(number) + await auditIssue(number) + return + } + + if (eventName === 'pull_request' || eventName === 'pull_request_review') { + const pull = await pullRequestSnapshot(event.pull_request.number) + const errors = validatePullRequest(pull) + if (errors.length > 0) return + await moveResolvingIssues(pull, 'Ready', 'In progress') + if (pull.reviewRequestCount > 0 || pull.reviewCount > 0) { + await moveResolvingIssues(pull, 'In progress', 'In review') + } + } +} + +function readEvent() { + if (!process.env.GITHUB_EVENT_PATH) throw new Error('GITHUB_EVENT_PATH 未设置') + return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) +} + +async function main(argv) { + const [command] = argv + if (command === 'pr') await runPullRequestCheck(readEvent()) + else if (command === 'lifecycle') await runLifecycle(process.env.GITHUB_EVENT_NAME, readEvent()) + else throw new Error('用法:policy.mjs pr|lifecycle') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs new file mode 100644 index 0000000000..890247db29 --- /dev/null +++ b/.github/issue-management/policy.test.mjs @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + countVisibleUnits, + parseReferences, + requiresPullRequestPolicy, + validateBody, + validateIssue, + validatePullRequest, +} from './policy.mjs' + +const withDetails = (summary) => + `${summary}\n\n
验收与细节待补充。
` + +const legalIssue = { + title: '完成议题管理校验', + body: withDetails('完成议题管理校验。'), + assignees: [], + labels: [], + type: 'Idea', + priority: null, + status: 'In review', + state: 'open', + stateReason: null, +} + +test('counts only text outside details', () => { + assert.deepEqual(countVisibleUnits('支持 GitHub Project。
隐藏文字
'), { + units: 4, + balanced: true, + detailsCount: 1, + allCollapsed: true, + }) +}) + +test('requires a balanced default-collapsed details region', () => { + assert.deepEqual(validateBody({ body: '完成工作。', assignees: [] }), [ + '正文必须包含默认收起的
区域', + ]) + assert.deepEqual( + validateBody({ + body: '完成工作。\n\n
细节待补充。
', + assignees: [], + }), + ['details 必须默认收起,不得设置 open'], + ) + assert.deepEqual( + validateBody({ body: '完成工作。\n\n
细节', assignees: [] }), + ['details 标签必须成对闭合'], + ) +}) + +test('requires Owner for multiple assignees', () => { + assert.deepEqual( + validateBody({ + body: withDetails('完成工作。'), + assignees: ['tianyicui', 'tianyicui-bot'], + }), + ['多个 Assignees 时首个非空行必须是 Owner: @login'], + ) +}) + +test('accepts an intended Owner while assignment permission is pending', () => { + assert.deepEqual( + validateBody({ + body: withDetails('Owner: @octocat\n\n完成工作。'), + assignees: [], + }), + [], + ) + assert.deepEqual( + validateBody({ + body: withDetails('Owner: @octocat\n\n完成工作。'), + assignees: ['hubot'], + }), + ['零或一个 Assignee 时不得写 Owner 行'], + ) +}) + +test('allows optional metadata in every open Status', () => { + assert.deepEqual(validateIssue(legalIssue), []) + for (const status of ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review']) { + assert.deepEqual(validateIssue({ ...legalIssue, status }), []) + } +}) + +test('rejects metadata prefixes in an Issue title', () => { + const errors = validateIssue({ ...legalIssue, title: '[Bug] 修复恢复错误' }) + assert.ok(errors.includes('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀')) +}) + +test('keeps terminal Status aligned with the native close reason', () => { + assert.deepEqual( + validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }), + [], + ) + assert.deepEqual( + validateIssue({ + ...legalIssue, + status: 'No action', + state: 'closed', + stateReason: 'not_planned', + }), + [], + ) + assert.ok(validateIssue({ ...legalIssue, status: 'Done' }).includes('Done 必须对应 Completed 关闭原因')) +}) + +test('separates resolving and informational references', () => { + assert.deepEqual( + parseReferences({ + body: 'Fixes #12\nRelated to #4\nRefs deepseekharness/dsh-test#7', + repository: 'deepseekharness/dsh-test', + }), + { all: [4, 7, 12], resolving: [12], related: [4, 7] }, + ) +}) + +test('allows informational references without cross-object constraints', () => { + const errors = validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/cleanup', 'area/infra'], + references: { all: [4], resolving: [], related: [4] }, + issues: new Map([[4, { type: 'Bug', priority: 'P0', labels: ['area/web'] }]]), + }) + assert.deepEqual(errors, []) +}) + +test('enforces highest resolving Priority without Type or area synchronization', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 0, + reviewCount: 1, + labels: ['kind/cleanup', 'p0', 'area/web'], + references: { all: [2, 3], resolving: [2, 3], related: [] }, + issues: new Map([ + [2, { type: 'Feature', priority: 'P2', labels: ['area/web'] }], + [3, { type: 'Bug', priority: 'P0', labels: ['area/session'] }], + ]), + } + assert.deepEqual(validatePullRequest(pull), []) + assert.ok( + validatePullRequest({ ...pull, labels: ['kind/cleanup', 'p2', 'area/web'] }).includes( + 'PR Priority 应为 p0', + ), + ) +}) + +test('requires policy only after a human PR enters review', () => { + assert.equal( + requiresPullRequestPolicy({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + }), + true, + ) + assert.equal( + requiresPullRequestPolicy({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 0, + reviewCount: 0, + }), + false, + ) +}) + +test('exempts Draft, Bot, and App PRs', () => { + const invalid = { + isDraft: false, + labels: [], + references: { all: [], resolving: [], related: [] }, + issues: new Map(), + reviewRequestCount: 1, + reviewCount: 0, + } + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'Bot' }), []) + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'App' }), []) + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'User', isDraft: true }), []) + assert.ok(validatePullRequest({ ...invalid, authorType: 'User' }).length > 0) +}) + +test('requires repository PR labels in the enforcement scope', () => { + const errors = validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: [], + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), + }) + assert.ok(errors.includes('PR 必须恰好有一个 kind/*,当前为 0')) + assert.ok(errors.includes('PR 必须至少有一个 area/*')) +}) + +test('accepts repository-extensible kind labels', () => { + assert.deepEqual( + validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/dependency', 'area/infra'], + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), + }), + [], + ) +}) + +test('allows missing Priority only when resolving Issues are also unprioritized', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/feature', 'area/web'], + references: { all: [2], resolving: [2], related: [] }, + issues: new Map([[2, { priority: null }]]), + } + assert.deepEqual(validatePullRequest(pull), []) + assert.ok( + validatePullRequest({ ...pull, issues: new Map([[2, { priority: 'P2' }]]) }).includes( + 'PR Priority 应为 p2', + ), + ) + assert.ok( + validatePullRequest({ ...pull, labels: [...pull.labels, 'p2'] }).includes( + '有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority', + ), + ) +}) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..e960016ae8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ + + + + +关联 Issue: + +
+变更与验证 + +- 变更: +- 验证: + +
diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 32b767cb2c..5af1a32cbb 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -115,7 +115,6 @@ export async function runTui( ) process.exit(1) } - installFailLoud(NAME) // The bin already loaded the invoking directory's .env, and that is the // whole environment: $DSH_HOME/.env is credentials-local's writable store, // and hoisting it would make every stored key read as a read-only ambient @@ -142,6 +141,19 @@ export async function runTui( const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} + // The Loader mounts entries concurrently, so `ui-tui` can already hold the + // terminal (raw mode, bracketed paste, keyboard protocol) when something + // else fails. A config-tree failure settles through `boot`, which disposes + // the tree itself; this release covers the rejections `boot` cannot see — a + // plugin's detached async work rejecting while mounting is still in flight + // or after the tree settled. Disposing the tree runs the TUI's own shutdown, + // which stops the terminal and hands the shell back; without it such a + // failure returns to a corrupted prompt. `app.current` is captured from + // boot's `prepare` hook, so it holds the root context for the whole mounting + // window rather than only after boot resolves. + installFailLoud(NAME, process, async () => { + await app.current?.fiber.dispose() + }) // Resume always enters the default surface because meta rejects // parent options, including `--resume`. The resumed session already persists // its cwd. @@ -219,6 +231,10 @@ export async function runTui( bootConfig, patches, (hostCtx) => { + // Runs after the Loader installs and before any config-tree entry mounts, + // so the fail-loud release hook can reach the tree for the whole window in + // which an entry may reject. + app.current = hostCtx // The launcher owns session identity and the exit line: a config-mounted // app bundle reads both from these slots, so no cordis.yml key can drop // resume. diff --git a/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml b/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml new file mode 100644 index 0000000000..f03a58d5d7 --- /dev/null +++ b/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml @@ -0,0 +1,10 @@ +# An overlay whose `llm-pi-ai` config fails schema validation: `providers` is a +# dict keyed by provider name, and a list is the shape users reach for. The +# entry rejects while `ui-tui` — mounted concurrently by the Loader — already +# holds the terminal, which is the boot failure the fail-loud release hook +# exists for. +- id: llm-pi-ai + config: + providers: + - provider: openai + apiKey: keyless-invalid-shape diff --git a/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt b/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt index 0df46a7018..1d9c7048c6 100644 --- a/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt +++ b/apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 100x36 buffer=normal length=66 base=30 viewport=30 +terminal 100x36 buffer=normal length=68 base=32 viewport=32 lifecycle started=1 stopped=0 progress=inactive title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=7 viewportRow=35 bufferRow=65 +cursor hidden column=7 viewportRow=35 bufferRow=67 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -73,56 +73,60 @@ buffer style 0-64 dim 37| "/compact — Compact older conversation history " style 0-44 dim -38| "/exit — Exit after the active turn reaches idle " +38| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " + style 0-99 dim +39| "reasoning display " + style 0-16 dim +40| "/exit — Exit after the active turn reaches idle " style 0-46 dim -39| "/help — Show keyboard shortcuts and commands " +41| "/help — Show keyboard shortcuts and commands " style 0-43 dim -40| "/model [[provider/]model] — Show or switch this session's model " +42| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -41| "/palette — Show every color and attribute role this terminal renders " +43| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -42| "/quit — Exit after the active turn reaches idle " +44| "/quit — Exit after the active turn reaches idle " style 0-46 dim -43| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +45| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -44| "/resume — List this workspace's resumable sessions " +46| "/resume — List this workspace's resumable sessions " style 0-49 dim -45| "/status — Show session diagnostics, system prompt, and registered tools " +47| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -46| "/skill: [instructions] — load a skill into the conversation " +48| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -47| -48| "Context · snapshot-injector" +49| +50| "Context · snapshot-injector" style 0-26 dim -49| "Injected while compaction was running. " +51| "Injected while compaction was running. " style 0-37 dim -50| -51| "… earlier context was compacted … " - style 0-32 dim 52| -53| "You " +53| "… earlier context was compacted … " + style 0-32 dim +54| +55| "You " style 0-2 fg=bright-magenta bold underline -54| "Reply with exactly the word: TWO. No tools. " -55| -56| "Compacted 2 history items (~387 tokens). " - style 0-39 dim +56| "Reply with exactly the word: TWO. No tools. " 57| -58| "Assistant " +58| "Compacted 2 history items (~387 tokens). " + style 0-39 dim +59| +60| "Assistant " style 0-8 fg=bright-magenta bold underline -59| "Reasoning " +61| "Reasoning " style 0-8 dim italic -60| "The user wants me to reply with exactly the word \"TWO\" and no tools. " +62| "The user wants me to reply with exactly the word \"TWO\" and no tools. " style 0-67 dim italic -61| "TWO " -62| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " +63| "TWO " +64| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim -63| -64| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont" +65| +66| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont" style 0-49 fg=bright-magenta bold style 52-68 dim style 71-90 dim style 93-99 dim -65| " dsh ◍ " +67| " dsh ◍ " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index f61d37360b..70c9c0fa65 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -26,6 +26,9 @@ const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) // `--config` layers an overlay over the shared base, so the default surface // needs no config argument at all; these are the overlays under test. const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) +// An overlay whose `llm-pi-ai` config fails validation, so an entry rejects +// while the TUI already holds the terminal. +const invalidProviderConfigPath = fileURLToPath(new URL('./fixtures/tui-invalid-provider.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url)) const synchronizedFrameEnd = '\x1b[?2026l' @@ -414,6 +417,28 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) + // The Loader mounts entries concurrently, so `ui-tui` can already own the + // terminal when a sibling entry rejects on its config. Exiting without the + // tree's own teardown left raw mode and bracketed paste set on the user's + // shell, and the pending Device Attributes reply landed there as literal + // text. The transactional mount must settle (an HMR initial-scan refresh + // once deadlocked its rollback into a silent exit 13) so `boot` disposes + // the tree — reaching the TUI's own shutdown — and rejects with the + // labelled diagnostic. + it('restores the terminal when a sibling entry fails to validate during boot', async () => { + const output = await smoke({ + label: 'dsh invalid provider config', + tempDirPrefix: 'dsh-tui-invalid-config-', + configPath: invalidProviderConfigPath, + expectedExitCode: 1, + }) + expect(output).toContain('dsh: plugin tree failed to load:') + expect(output).toContain('$.providers') + // Bracketed paste is disabled again, which only `ProcessTerminal.stop()` + // writes — proof the tree was disposed rather than exited out from under. + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await smoke({ label: 'dsh conversation', diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts new file mode 100644 index 0000000000..717cebdd36 --- /dev/null +++ b/apps/web/tests/bash-abort-row.e2e.ts @@ -0,0 +1,82 @@ +// Web e2e scenario: a cancelled Bash call can settle without terminal-card +// material. Borrow the real cancellation fixture and prove the keyed Bash row +// still exposes the recorded command and full error without any model call. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/bash-abort-row', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'bash-abort-row-web-e2e' +const PROMPT = 'Run two shell commands: wait for cancellation, then write skipped.txt.' + +describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + const fixture = await readFile(FIXTURE, 'utf8') + expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, fixture, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('expands the aborted row to its command and full error', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-bash-abort-row')) + const row = page.locator('[data-sample="bash"]').first() + const call = row.locator('xpath=..') + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') + await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1) + await row.click() + + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') + await call.getByText('IN', { exact: true }).waitFor() + await call.getByText('OUT', { exact: true }).waitFor() + await call.getByText('Wait until cancellation', { exact: false }).waitFor() + await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor() + await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + // The borrowed fixture's UTC date is still the previous day in PDT; + // the disclosure golden must not depend on the runner timezone. + .replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}') + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts new file mode 100644 index 0000000000..8dce7f405b --- /dev/null +++ b/apps/web/tests/markdown-images.e2e.ts @@ -0,0 +1,205 @@ +// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session +// assembled through the Session API is seeded cold into the real web +// composition, then a separate image origin proves that the browser receives +// a real network image while local-path Markdown remains inert alt text. +import { createServer, type Server } from 'node:http' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-images', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-images/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-images-web-e2e' +const REMOTE_ALT = 'Remote test image' +const LOCAL_ALT = 'Local test image' +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) + +interface ImageOrigin { + server: Server + url: string + requests: Array<{ path: string | undefined; referer: string | undefined }> +} + +/** Start the deterministic remote image origin used by this browser scenario. */ +async function startImageOrigin(): Promise { + const requests: ImageOrigin['requests'] = [] + const server = createServer((request, response) => { + requests.push({ path: request.url, referer: request.headers.referer }) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': PNG.length, + 'content-type': 'image/png', + }) + response.end(PNG) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('image origin did not expose an IP socket') + } + return { + server, + url: `http://127.0.0.1:${String(address.port)}/image.png`, + requests, + } +} + +/** Stop one image origin after the browser and host release their requests. */ +async function stopServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} + +/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */ +function markdownImageFixture(remoteUrl: string): string { + const session = new Session(SessionId('markdown-image-source')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the Markdown image policy.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Markdown image policy', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## Markdown images', + '', + `![${REMOTE_ALT}](${remoteUrl})`, + '', + `![${LOCAL_ALT}](./local-image.png)`, + '', + 'REMOTE_IMAGE_DONE', + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const header = { + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + } + return [ + JSON.stringify(header), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: remote Markdown image rendering', () => { + let scaffold: WebScaffold + let imageOrigin: ImageOrigin + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + imageOrigin = await startImageOrigin() + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await stopServer(imageOrigin.server) + }) + + it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), { + timeout: 15_000, + }).toBe(1) + + const image = page.getByRole('img', { name: REMOTE_ALT }) + await image.waitFor({ timeout: 10_000 }) + await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), { + timeout: 10_000, + }).toBeGreaterThan(0) + expect(await image.evaluate((element) => { + const computed = getComputedStyle(element) + return { + borderRadius: computed.borderRadius, + decoding: element.getAttribute('decoding'), + loading: element.getAttribute('loading'), + maxWidth: computed.maxWidth, + referrerPolicy: element.getAttribute('referrerpolicy'), + } + })).toEqual({ + borderRadius: '8px', + decoding: 'async', + loading: 'lazy', + maxWidth: '100%', + referrerPolicy: 'no-referrer', + }) + expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0) + expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) + expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index bb58ccefc4..aa64816420 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -1,7 +1,7 @@ -// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history -// fixture (zero model calls) and pins the settled conversation aria after the -// user/assistant footers are focus-revealed — the surface package jsdom tests -// cannot substitute for (docs/testing.md snapshot rule). +// Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic +// completed-turn-tail fork case (zero model calls) and pins the settled +// conversation aria after the footers are focus-revealed — the surface package +// jsdom tests cannot substitute for (docs/testing.md snapshot rule). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -25,6 +25,48 @@ const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +const MID_TURN_TEXT = 'I will read both files before answering.' +const SECOND_PROMPT = 'Now give the final answer.' + +/** + * Adapt the borrowed recording into response -> tools -> interrupted Think, + * followed by one ordinary completed response. The first response keeps + * copy/clock but is not a legal branch point; the second is the real turn tail. + * @param raw - Recorded seeded-history JSONL. + * @returns A contiguous, closed two-turn fixture. + */ +function completedTailFixture(raw: string): string { + const kept: string[] = [] + for (const line of raw.trimEnd().split('\n')) { + const row = JSON.parse(line) as { + type: string + seq?: number + seq0?: number + data?: { content?: unknown[] } + } + const firstSeq = row.seq ?? row.seq0 + if (firstSeq !== undefined && firstSeq >= 101) break + if (row.type === 'assistant/message' && row.seq === 64) { + const content = row.data?.content + if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content') + content.splice(1, 0, { type: 'text', text: MID_TURN_TEXT }) + kept.push(JSON.stringify(row)) + } else { + kept.push(line) + } + } + const tail = [ + { type: 'step/end', seq: 101, time: 1784974102749, data: { turn: 1, step: 2 } }, + { type: 'turn/end', seq: 102, time: 1784974102750, data: { turn: 1, reason: { kind: 'aborted' } } }, + { type: 'turn/start', seq: 103, time: 1784974103000, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } }, + { type: 'user/message', seq: 104, time: 1784974103001, data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 105, time: 1784974103002, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 106, time: 1784974103003, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' }, + { type: 'step/end', seq: 107, time: 1784974103004, data: { turn: 2, step: 1 } }, + { type: 'turn/end', seq: 108, time: 1784974103005, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + return `${[...kept, ...tail.map(row => JSON.stringify(row))].join('\n')}\n` +} describe('web e2e: message IconActions and clocks on settled history', () => { let scaffold: WebScaffold @@ -38,8 +80,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') - const raw = await readFile(SEED, 'utf8') - expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + const raw = completedTailFixture(await readFile(SEED, 'utf8')) + expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT]) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -53,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => { + it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -61,16 +103,24 @@ describe('web e2e: message IconActions and clocks on settled history', () => { const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() + await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). User and each turn's last content assistant both - // have copy + branch. + // hover/focus-within). Every durable message footer keeps branch visible, + // but only the final assistant at a completed transcript tail enables it. const copyButtons = page.getByRole('button', { name: 'Copy' }) - await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4) await copyButtons.first().focus() - await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(2) + const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) + await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(4) + await expect.poll( + () => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))), + { timeout: 5_000 }, + ).toEqual(['true', 'true', 'true', null]) + await branchButtons.first().focus() + await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 }) + .toBe('Available only on the last message of a completed turn') await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0) }, 60_000) @@ -89,8 +139,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) - // Exercise the assistant action specifically; package coverage pins the - // user action separately at its own event seq. + // The last message action belongs to the completed second-turn assistant. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), 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..8a0c965a78 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -511,11 +511,19 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { .split(workspaceCwd).join('{{cwd}}') .split(base).join('{{workspace}}') .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}') - .replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}') + .replace( + /~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m \d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g, + duration => duration.startsWith('~') ? duration : '{{duration}}', + ) + .replace( + /约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|(?:\.\d+)?秒)/g, + duration => duration.startsWith('约') ? duration : '{{duration}}', + ) // Message IconActions clocks widen by calendar day/year; collapse every // 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(/(? {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted" +- button "Inspect" +- 'button "Failed Bash Error: tool call aborted before dispatch"': + - img + - text: "Failed Bash Error: tool call aborted before dispatch" +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 8f4c7e5bf2..c6799a2247 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -7,8 +7,9 @@ - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index e4d5ac8426..fc6d312821 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -7,8 +7,9 @@ - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index facb7b58cc..17df480cd9 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -7,8 +7,9 @@ - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 5965797c69..5e0b4f73bb 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -7,8 +7,9 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index d1e4d2bbef..d18e061a1a 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index fb9337e978..1a4aec678c 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index a5dfd08fb5..c50b440f86 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6380eaf5c6..d5df08c05e 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md new file mode 100644 index 0000000000..76e01397c2 --- /dev/null +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -0,0 +1,30 @@ +- banner: + - navigation "Session hierarchy": + - button "Markdown image policy" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Show the Markdown image policy. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- heading "Markdown images" [level=2] +- paragraph: + - img "Remote test image" +- paragraph: Local test image +- paragraph: REMOTE_IMAGE_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index bf67498178..3d085b0931 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -8,12 +8,19 @@ - button "Copy": - img - tooltip "Copy" -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- paragraph: I will read both files before answering. +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn 7/25 {{clock}} - button "Read a.txt": - img - img @@ -28,6 +35,12 @@ - img - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. +- text: Stopped Now give the final answer. 7/25 {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - paragraph: DONE - button "Copy": - img @@ -42,4 +55,4 @@ - text: deepseek-v4-flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok +- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 788b8a3233..3572409fc0 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -4,7 +4,8 @@ - button "Collapse calls": Calls - img - searchbox "Search trajectory" -- region "Trajectory timeline" +- region "Trajectory timeline": + - tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s" - table: - rowgroup: - row "SYSTEM, Initial System Prompt": diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 5be3f83247..108ca47986 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -8,8 +8,9 @@ - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 28297569ab..b5ed63a63e 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -7,8 +7,9 @@ - text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index b9dee060ac..bd44e33ad0 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index bfff0cd9f0..8df2ea2940 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index fe6f5b6087..74de289ee9 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -22,8 +23,9 @@ - text: {{clock}} Edited queue item {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index db860c30d1..7afdf3c8b4 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -7,8 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index b916a3add2..b4eddfc24e 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -7,8 +7,9 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index a168d5e2a3..4643c60f3b 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -7,8 +7,9 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 61f48ffbab..5b316f4efb 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -7,8 +7,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index be28e218d3..4d8d35a085 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -7,8 +7,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -24,8 +25,9 @@ - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img diff --git a/apps/web/tests/snapshots/subagent-conversation/branchless.expected.md b/apps/web/tests/snapshots/subagent-conversation/branchless.expected.md new file mode 100644 index 0000000000..1f51342ed6 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/branchless.expected.md @@ -0,0 +1,2 @@ +- tree "Subagent sessions": + - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]: example editor continuable · not running 0 tok {{duration}} diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index 88dd7ec974..e71d768153 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -1,8 +1,8 @@ - tree "Subagent sessions": - - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running just now" [expanded] [level=1]: + - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok ~6mo 12d + - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]: - button "Collapse event-sourcing researcher descendants": - img - - text: event-sourcing researcher Explain event sourcing in one · continuable · not running just now + - text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}} - group: - - treeitem "example editor continuable · not running just now" [level=2] - - treeitem "event-sourcing reviewer one-shot · not running just now" [level=1] + - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}} diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 14b397d9b4..9497cc36d3 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -12,8 +12,9 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -30,8 +31,9 @@ - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 4ff674462e..97ca405141 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -7,8 +7,9 @@ - text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md index baaaa6f3dc..47957dab82 100644 --- a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -4,7 +4,8 @@ - button "Home" - img - button "browse-golden" - - button "Edit path" + - button "Edit path": + - img - list: - listitem: - button "adopted": diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 53e61d829f..6f339cf9a9 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -20,6 +20,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) +const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) @@ -108,7 +109,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = childId = started.childId await waitForAgentToSettle(scaffold, childId) oneShotId = sessionId('recorded-one-shot') - const oneShotAt = Date.now() + const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000 + const oneShotAt = Date.now() - oneShotDurationMs await scaffold.ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id: oneShotId, @@ -146,10 +148,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () = { type: 'turn/end', seq: 3, - time: oneShotAt + 3, + time: oneShotAt + oneShotDurationMs, data: { turn: 1, reason: { kind: 'completed' } }, }, ] as SessionEvent[]) + await scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotId) grandchildId = sessionId('recorded-grandchild') const authoredAt = Date.now() await scaffold.ctx.sessionPersistence.create({ @@ -193,18 +196,19 @@ describe('web e2e: persisted subagent conversation and human continuation', () = data: { turn: 1, reason: { kind: 'completed' } }, }, ] as SessionEvent[]) + await scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildId) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([ - { - kind: 'child', id: childId, mode: 'continuable', label: LABEL, - activity: 'inactive', hasChildren: true, - }, { kind: 'child', id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false, }, + { + kind: 'child', id: childId, mode: 'continuable', label: LABEL, + activity: 'inactive', hasChildren: true, + }, ]) await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([ { @@ -299,7 +303,14 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await page.getByRole('button', { name: `Expand ${ONE_SHOT_LABEL} descendants`, }).count()).toBe(0) + const oneShotRow = page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }) + expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1) + expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s') await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() + const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) + const childLabel = await childRow.getAttribute('aria-label') + await page.waitForTimeout(1_100) + expect(await childRow.getAttribute('aria-label')).toBe(childLabel) await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() @@ -386,7 +397,15 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens an unavailable persisted grandchild after recording the available child', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) await page.getByRole('button', { name: '1 subagent' }).click() - await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click() + const tree = page.getByRole('tree', { name: 'Subagent sessions' }) + const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }) + expect(await nestedRow.locator(':scope > *').count()).toBe(1) + await compareOrRefreshGolden( + BRANCHLESS_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), + MODE, + ) + await nestedRow.click() await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 1ffcf6f490..b25320f488 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -1,6 +1,7 @@ // Web e2e scenarios: workspace management — adding a workspace through the // composed directory dialog (its own New folder affordance is the product's -// one creation route), same-basename directory adoption, the rename round +// one creation route), the dialog's path editor walking the panes with the +// typed draft, same-basename directory adoption, the rename round // trip over the real wire (workspace.rename RPC + durable registry), the // duplicate-name pre-check, the // flat "In one list" view with its persisted group-by preference, the session @@ -12,7 +13,7 @@ // seeded-history seed reused verbatim — no new recording). import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { join } from 'node:path' +import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' @@ -403,6 +404,43 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('walks the panes with the typed path: deeper past a separator, back up on erase, whole on a miss', async () => { + // 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/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9693ed1885..e224bb6f91 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -46,6 +46,7 @@ "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/markdown-images.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/permission-policy-context.e2e.ts", @@ -53,7 +54,8 @@ "tests/shipped-composition.e2e.ts", "tests/goal-bar.e2e.ts", "tests/startup-auto-selection.e2e.ts", - "tests/subagent-conversation.e2e.ts" + "tests/subagent-conversation.e2e.ts", + "tests/bash-abort-row.e2e.ts" ], "references": [ { diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cc85531d4d..f1b2a02397 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2081,6 +2081,8 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -2112,7 +2114,7 @@ export interface TuiThemeConfig { } ``` -Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) +Source: [`packages/ui/tui/src/config.ts:121`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 521a2fa735..8f4385938c 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -794,7 +794,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:158`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -811,7 +811,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:132`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -848,7 +848,7 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:149`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:151`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2f67906078..8e76c1c8de 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2072,7 +2072,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:163`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) @@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:242`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:244`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d1457a75af..b59b8733d5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:132`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 6687a583c1..8419646dca 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -737,6 +737,7 @@ flowchart TD pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_session_projection pkg_subagent --> pkg_session_query pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools @@ -832,13 +833,6 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -981,6 +975,15 @@ flowchart TD pkg_client_ui_plan --> pkg_client_ui_slots pkg_client_ui_plan --> pkg_invariants pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_goal @@ -1201,7 +1204,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1218,7 +1221,6 @@ flowchart TD | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1239,6 +1241,7 @@ flowchart TD | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 6f31ca3beb..c3850804ff 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: eca7db1f9b2d5c7e28fa86a363ca4408703b99df -README.zh.md: 6a2e8c6085d06a9f04c1270e5976452b995a7e77 +README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 +README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index eca7db1f9b..89e58f967f 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. ## Workspace and Session lists @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6a2e8c6085..960e2fceed 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Workspace 与 Session 列表 @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 31872356c8..46cc5b0391 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -331,6 +331,8 @@ export interface ConversationSnapshot { sessionId: SessionId /** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */ nodes: readonly ConversationNode[] + /** In-window completed turn number -> its `turn/end` event seq. */ + turnEnds: ReadonlyMap partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] /** diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 08f8361a92..4f26674420 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -3,10 +3,13 @@ // Orphaned lineage degrades to root level; cycles fail soft and emit as roots. import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' /** Host list summary enriched with the latest mux-projected durable title. */ export interface TitledSessionSummary extends SessionSummary { title?: string + /** Current host-computed projection values for list consumers. */ + projectionValues?: Readonly> } /** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */ @@ -21,6 +24,8 @@ export interface SessionListEntry { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' cwd?: string + /** Current host-computed projection values for list consumers. */ + projectionValues?: Readonly> /** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */ waitingApproval: boolean /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index afbbe9e6ad..b4bf12a0e3 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -846,10 +846,14 @@ export class SessionManager { const merged: TitledSessionSummary[] = this.summaries.map((summary) => { // List rows read the generic 'title' projection key (host-computed unit // value; the bespoke session/title frame is retired). - const title = this.projectionStores.get(summary.sessionId)?.get('title') - return typeof title === 'string' && title !== '' - ? { ...summary, title } - : summary + const projectionStore = this.projectionStores.get(summary.sessionId) + const title = projectionStore?.get('title') + const projectionValues = projectionStore?.values() + return { + ...summary, + ...(typeof title === 'string' && title !== '' ? { title } : {}), + ...(projectionValues === undefined ? {} : { projectionValues }), + } }) const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys())) const items = fresh.map((entry) => { @@ -860,6 +864,7 @@ export class SessionManager { && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.waitingApproval === entry.waitingApproval + && prev.projectionValues === entry.projectionValues ) return prev this.entryCache.set(entry.sessionId, entry) return entry diff --git a/packages/client/runtime/src/client/sessions/projection-store.ts b/packages/client/runtime/src/client/sessions/projection-store.ts index 4e6e7dd626..32401146af 100644 --- a/packages/client/runtime/src/client/sessions/projection-store.ts +++ b/packages/client/runtime/src/client/sessions/projection-store.ts @@ -74,6 +74,7 @@ interface Channel { export class ProjectionValueStore { private readonly rows = new Map() private readonly channels = new Map() + private valuesCache: Readonly> | undefined /** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */ private readonly anyNotifier = new Notifier(() => {}) @@ -98,6 +99,19 @@ export class ProjectionValueStore { return this.rows.get(key)?.value } + /** + * Read every current projection value as one reference-stable snapshot. + * @returns The same frozen value map until a row changes. + */ + values(): Readonly> { + if (this.valuesCache === undefined) { + this.valuesCache = Object.freeze(Object.fromEntries( + [...this.rows].map(([key, row]) => [key, row.value]), + )) + } + return this.valuesCache + } + /** * Subscribe to any-key changes (microtask-batched) — the manager's list * rebuild channel. @@ -160,6 +174,7 @@ export class ProjectionValueStore { } private changed(key: string): void { + this.valuesCache = undefined this.channels.get(key)?.notifier.markDirty() this.anyNotifier.markDirty() } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 087a07b02d..47ee641053 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -25,6 +25,7 @@ import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionFace } from '../contract/session.ts' @@ -57,6 +58,8 @@ export interface SessionSummary { */ blank: boolean updatedAt: number + /** Current host-computed projection values retained by the object layer. */ + projectionValues?: Readonly> } /** @@ -613,6 +616,9 @@ export class SessionsService implements ISessions { waitingApproval: entry.waitingApproval, blank: entry.blank, updatedAt: entry.updatedAt, + ...(entry.projectionValues === undefined + ? {} + : { projectionValues: entry.projectionValues }), ...(entry.title !== undefined ? { title: entry.title } : {}), ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 58d3028fba..cce554f3ad 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -113,6 +113,11 @@ export class Session implements SessionFace { private pendingCache: { rev: number; value: PendingInteraction[] } | null = null private derivedRev = 0 private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null + /** Completed turn boundaries retained from the raw window so presentation + * actions never infer a safe fork point from transcript content alone. */ + private turnEnds = new Map() + private turnEndsRev = 0 + private turnEndsCache: { rev: number; value: ReadonlyMap } | null = null /** Authoritative stream-only inbox snapshot; pending work never hits history. */ private queued: QueuedMessage[] = [] private queueRev = 0 @@ -821,6 +826,8 @@ export class Session implements SessionFace { return } case 'turn/end': { + this.turnEnds.set(event.data.turn, event.seq) + this.turnEndsRev++ if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') { this.settleScheduledRetry('cancelled', event.data.turn) } @@ -911,6 +918,8 @@ export class Session implements SessionFace { this.callsRev++ this.derivedNodes = [] this.derivedRev++ + this.turnEnds = new Map() + this.turnEndsRev++ this.codeDispatches = new Map() this.dispatchesRev++ for (let i = 0; i < this.events.length; i++) { @@ -942,6 +951,9 @@ export class Session implements SessionFace { if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } } + if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) { + this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) } + } if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } @@ -955,6 +967,7 @@ export class Session implements SessionFace { return { sessionId: this.sessionId, nodes, + turnEnds: this.turnEndsCache.value, partial, runningCalls: this.callsCache.value, pending: this.pendingCache.value, diff --git a/packages/client/runtime/tests/projection-store.spec.ts b/packages/client/runtime/tests/projection-store.spec.ts index eea43b67f3..143da92348 100644 --- a/packages/client/runtime/tests/projection-store.spec.ts +++ b/packages/client/runtime/tests/projection-store.spec.ts @@ -86,6 +86,17 @@ describe('ProjectionValueStore semantics', () => { const store = new ProjectionValueStore() expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks')) }) + + it('publishes one reference-stable whole-value snapshot until a row changes', () => { + const store = new ProjectionValueStore() + const empty = store.values() + expect(store.values()).toBe(empty) + store.apply('test/marks', { marks: ['a'] }, 1) + const populated = store.values() + expect(populated).toEqual({ 'test/marks': { marks: ['a'] } }) + expect(populated).not.toBe(empty) + expect(store.values()).toBe(populated) + }) }) describe('Session tail-page seeding', () => { @@ -167,6 +178,36 @@ describe('manager frame routing', () => { expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() }) + it('projects every retained value into list rows with stable snapshot identity', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + api.onList = () => Promise.resolve(ok({ + items: [{ + sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, + projections: { + asOfSeq: 2, + values: { 'test/marks': { marks: ['baseline'] } }, + }, + }], + }) as never) + await manager.refreshList() + const baseline = manager.getListSnapshot().items[0]?.projectionValues + expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } }) + expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline) + + manager.handleMuxEnvelope({ + rpcId: 'p2' as never, + payload: { + type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', + value: { marks: ['live'] }, seq: 3, + } as never, + }) + await Promise.resolve() + expect(manager.getListSnapshot().items[0]?.projectionValues) + .toEqual({ 'test/marks': { marks: ['live'] } }) + expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline) + }) + it('drops the projection store with the removed session', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 7f5f793948..6e75593e3a 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -426,6 +426,7 @@ describe('live event path', () => { feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() + expect(snapshot.turnEnds.get(1)).toBe(10) const frozen = snapshot.nodes.at(-1) expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] }) // Ordered inside the flow: after the user message (seq 7), before any later turn. @@ -1215,6 +1216,7 @@ describe('reference stability (the memo contract)', () => { expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) + expect(after.turnEnds).toBe(before.turnEnds) // And a mutation on the tracked domain swaps that array. feed(ev.toolResult(11, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 2544870ace..d033669215 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -46,6 +46,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot return { sessionId, nodes: [], + turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d131e31202..09e17a4371 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: e610b990dd89204fd7e22e8b86f807d10b8ba439 -README.zh.md: 268e05a806db1468ba689608c178646af132fe25 +README.md: e1dbe7d4d5992b6b5b029fddfc9d9857ccae7443 +README.zh.md: fd82ad65f8e903a6f7106e8b8ff8eccbf1435957 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e610b990dd..e1dbe7d4d5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -20,7 +20,7 @@ A Think row stays collapsed by default and exposes live reasoning throughput wit Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. -A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). +A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). @@ -38,7 +38,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, restores Copy and Fork from the durable node, and survives reconnect from the same authority. +The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. @@ -63,8 +63,8 @@ None; this package neither assembles nor sends a provider request. - **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected. -- **Sent user messages cannot be edited** — the user bubble's IconActions row carries clock / copy / branch only, and branching from the message is the nearest gesture. The control returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). +- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 268e05a806..fd82ad65f8 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,11 +14,11 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 -Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理吞吐:当 reasoning block 是流式尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整 reasoning 进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 +Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 -声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 @@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会从持久节点恢复复制与 fork 操作,并能在重连后从同一权威恢复。 +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 @@ -63,8 +63,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu - **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。 -- **已发送的 user 消息无法编辑**:user 气泡的 IconActions 行只有时钟/复制/分支,从该消息分支是最接近的手势。该控件要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 +- **已发送的 user 消息无法编辑**:user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index f5cbadef8a..3f87e40e8b 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,9 +4,10 @@ // view groups them into tool rows through its keyed toolview slot (figma // step-summary flow). Shared by finalized nodes and the streaming partial; // the turn-level loading dots live in the chat view's tail, not here. -// Finalized turn-tail content (text) nodes append IconActions once streaming -// ends (`time` is omitted for mid-turn narration); Think / tool-head-only -// nodes stay chrome-free. +// Finalized content (text) nodes append IconActions once streaming ends +// (`time` is omitted for mid-turn narration); their branch action is enabled +// only when the node is also the completed turn's transcript tail. Think / +// tool-head-only nodes stay chrome-free. import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -28,8 +29,10 @@ export interface AssistantMarkdownProps { time?: number | undefined /** Event sequence used as the fork boundary; omitted while streaming. */ seq?: number | undefined - /** Fork the session through the turn containing this finalized message. */ + /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined + /** The message is not the transcript tail of a completed turn. */ + forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -76,7 +79,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, seq, onFork, t, + blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -120,6 +123,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ time={time} clock="end" onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }} + branchUnavailable={forkUnavailable} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 989f56bba2..076da6c6ab 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -236,6 +236,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + const turnEnds = useSession(s => s.turnEnds) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -257,6 +258,7 @@ export function ChatView({ // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) + const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -413,6 +415,7 @@ export function ChatView({ time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} onFork={forkAt} + forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) @@ -428,6 +431,7 @@ export function ChatView({ node={node} retryActive={node.kind === 'model-retry' && node.seq === activeRetry} onFork={forkAt} + forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css index b247b7e2bf..8a43b42f83 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css @@ -43,3 +43,23 @@ background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } + +/* Unavailable stays focusable and hoverable so Tooltip can explain why. */ +.action[data-unavailable] { + cursor: default; + opacity: 0.4; +} + +.action[data-unavailable]:hover { + background: transparent; + color: var(--dsw-alias-label-tertiary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index 91ba066544..6d77d4db68 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,7 +1,7 @@ // Shared IconActions chrome for user, steering, and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { useCallback } from 'react' +import { useCallback, useId } from 'react' import { IconBranchOutline16, IconCopyOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -17,9 +17,11 @@ export interface MessageIconActionsProps { time?: number | undefined /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' - /** Fork the session at this message. */ + /** Fork the session at this message; omission hides the branch action. */ onBranch?: (() => void) | undefined - /** Whether to render the branch action; defaults to true. */ + /** The message is not a completed transcript tail, so branch stays visible but unavailable. */ + branchUnavailable?: boolean | undefined + /** Additional branch visibility gate for transient message chrome; defaults to true. */ showBranch?: boolean | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined @@ -33,9 +35,10 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, clock, onBranch, showBranch = true, className, t, + text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t, }: MessageIconActionsProps) { const day = useCalendarDay() + const reasonId = useId() const onCopy = useCallback(() => { void writeClipboard(text) }, [text]) @@ -52,13 +55,25 @@ export function MessageIconActions({ - {showBranch && ( - - )} + {showBranch && onBranch !== undefined && branchUnavailable && ( + {t('message.branchUnavailable')} + )} {clock === 'end' ? clockEl : null} ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 6e542bf468..df04ac19c2 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -26,8 +26,10 @@ export interface MessageItemProps { | TurnErrorNode | UnknownSurfaceNode retryActive?: boolean - /** Fork the session through the turn containing this message (user-bubble branch action). */ + /** Fork through this message's completed turn when eligible. */ onFork?: (seq: number) => void + /** The message is not the transcript tail of a completed turn. */ + forkUnavailable?: boolean /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -220,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: { } export const MessageItem = memo(function MessageItem({ - node, retryActive = false, onFork, t, + node, retryActive = false, onFork, forkUnavailable = false, t, }: MessageItemProps) { const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { @@ -236,6 +238,7 @@ export const MessageItem = memo(function MessageItem({ time={node.time} clock="start" onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }} + branchUnavailable={forkUnavailable} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 4958894154..9de9876d89 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -5,8 +5,8 @@ * reuse the first notice's row while projecting the latest retry turn. * Item identity keys are stable across snapshots so the list parent can * subscribe to keys only while rows subscribe to content. IconActions ownership - * (last content assistant per turn) is derived here too so ChatView and the - * flow share one gate. + * and completed-turn branch points are derived here too so ChatView and the + * flow share their gates. */ import type { AssistantBlock, ConversationNode, ToolResultNode, @@ -47,6 +47,39 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } +/** + * Seq set of message rows that may fork: the last transcript node of a + * completed turn, when that node owns message chrome. A later tool, reasoning, + * error, or other transcript node leaves the earlier message's branch action + * unavailable because the Host would include the whole turn. + * @param nodes - snapshot nodes in event order. + * @param turnEnds - completed turn boundaries retained from the event window. + * @returns Message seq values whose visible position matches the fork boundary. + */ +export function messageBranchSeqs( + nodes: readonly ConversationNode[], + turnEnds: ReadonlyMap, +): ReadonlySet { + const result = new Set() + const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1]) + let nodeIndex = 0 + for (const [turn, endSeq] of boundaries) { + let tail: ConversationNode | undefined + while (nodeIndex < nodes.length) { + const candidate = nodes[nodeIndex] + if (candidate === undefined || candidate.seq > endSeq) break + tail = candidate + nodeIndex++ + } + if (tail?.kind === 'user' + || (tail?.kind === 'steering' && tail.turn === turn) + || (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) { + result.add(tail.seq) + } + } + return result +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes in human-transcript and durable-notice order. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 52dc8dd2d9..20a791c556 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -461,7 +461,7 @@ export interface ChatViewInjected { /** Last recorded offset, or null when pinned or never recorded. */ read: () => number | null } - /** Fork the session through the turn containing the message at `seq`, then open the child. */ + /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 87252fbc77..7bc01b4d95 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -59,6 +59,7 @@ export const zh = { 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', 'message.branch': '在新对话中分支', + 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', 'message.retry.started': '已重试模型请求', @@ -166,6 +167,7 @@ export const en = { 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', + 'message.branchUnavailable': 'Available only on the last message of a completed turn', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', 'message.retry.started': 'Retried model request', 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-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index ef9f246dd7..d4323309d4 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -20,6 +20,63 @@ border: 1px solid var(--dsw-alias-border-l1); } +/* A bash execution error can settle without terminal-card material (for + example, command cancellation). Preserve ToolRow's bounded IN/OUT fallback + so the original command and full error remain available from this keyed row. */ +.ioCard { + display: flex; + flex-direction: column; + margin: 4px 0 4px 4px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); + font: var(--dsw-font-markdown-code-block-small); +} + +.ioSection { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 14px; + align-items: baseline; + padding: 12px 16px; + max-height: 150px; + overflow-y: auto; +} + +.ioSection::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 6px; +} + +.ioSection::-webkit-scrollbar-track { + margin: 6px 0; +} + +.ioLabel { + position: sticky; + top: 0; + align-self: start; + color: var(--dsw-alias-label-caption); +} + +.ioDivider { + flex: none; + height: 1px; + background: var(--dsw-alias-border-l2); +} + +.ioText { + min-width: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--dsw-alias-label-secondary); +} + +.ioText[data-error] { + color: var(--dsw-alias-state-error-primary); +} + /* ToolRow's unified expand interaction, replicated per the registrant posture: pointer on the expandable row (the icon→chevron hover preview is the affordance, no row fill). */ diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 5a3e3f40fe..fbe05e0a1b 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -2,8 +2,10 @@ // (ctx.slots.register + ToolRowProps only — never imports the chat domain). // Product chrome matches ToolRow / Think (figma: Bash · {description}). // -// A bash call declares the terminal render intent, so this row renders the -// command's own output through TerminalBlock — expand-gated exactly like +// A bash call normally declares the terminal render intent, so this row renders +// the command's own output through TerminalBlock. Execution failures that +// settle without terminal material use the bounded generic IN/OUT fallback — +// both are expand-gated exactly like // ToolRow's unified interaction: collapsed by default, the whole summary row // is the toggle (click / Enter / Space, icon→chevron hover preview; the // summary stays inline while open), @@ -48,7 +50,7 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { /** * Bash row: icon + Bash · {description} in the shared ToolRow chrome, the - * whole row toggling the command's terminal card (ToolRow's unified + * whole row toggling the command's terminal or generic error card (ToolRow's unified * expand interaction, replicated locally per the registrant posture). */ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) { @@ -64,7 +66,13 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: : model.state const status = stateStatus(state, t) const [expanded, setExpanded] = useState(false) - const expandable = terminal !== null + // Execution failures (for example cancellation before the process reports a + // terminal result) use the generic presenter. Keep their recorded args and + // full error reachable instead of collapsing the row to the first line. + const genericError = terminal === null + && model.state === 'error' + && (model.body !== null || model.output !== null) + const expandable = terminal !== null || genericError const open = expanded && expandable const failureLine = model.state === 'error' ? model.errorSummary : null const toggleExpand = () => { @@ -109,16 +117,40 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: {failureLine ?? terminal?.description ?? model.summary} - {terminal !== null && open && ( + {open && ( /* Same hover-Inspect posture as ToolRow's expanded body, replicated locally per the registrant posture. */
- + {terminal !== null + ? ( + + ) + : ( +
+ {model.body !== null && ( +
+ IN + {model.body} +
+ )} + {model.body !== null && model.output !== null && ( + + )} + {model.output !== null && ( +
+ OUT + + {model.output} + +
+ )} +
+ )} {inspect !== undefined && ( + , + ) + 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-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 65f99af124..a519393a5a 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: f6b3fa2e9cdf1479a739e0b4eab15a5423e878e4 -README.zh.md: fdfba385e9188cd42bd973b6f32bc01fe8d004f2 +README.md: cb210b219a8c66985eb4e1370468372eed9614b4 +README.zh.md: 7b87fa1095c404eda96066189b1e4480cd6d4c3c diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index f6b3fa2e9c..cb210b219a 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. -The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). @@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens. ## Known Limitations and Deferred Work -- **The catalog has coarse activity only** — it cannot show durable outcome, elapsed time, Activation identity, or an authority-safe cancel button. +- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button. - **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index fdfba385e9..7b87fa1095 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -4,7 +4,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 -页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 @@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录) ## 已知限制与暂缓事项 -- **目录只有粗粒度活动状态**:它不能显示持久化结果、耗时、Activation 身份或具备安全授权的取消按钮。 +- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开具备安全授权的取消按钮。 - **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 6dc3f9bd7d..9e6b120c1e 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -48,6 +48,8 @@ "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -59,6 +61,8 @@ "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index e2133e6ce9..239081c59c 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -173,15 +173,28 @@ } .summary, -.time { +.metrics { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; } -.time { +.metrics { + display: grid; + grid-template-rows: 18px 16px; flex: none; - margin-top: 16px; + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.metricToken { + grid-row: 1; + line-height: 18px; +} + +.metricDuration { + grid-row: 2; } .children { diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 4953b89591..76a216ebd8 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, } from 'react' import type { - SessionId, SessionListState, SessionSummary, SubagentAddress, SubagentCatalogSnapshot, + SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress, + SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, @@ -10,6 +11,8 @@ import { import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-subagent/client' +import type {} from '@deepseek-ai/dsh-token-meter/client' import css from './SubagentCatalogAction.module.css' type CatalogEntry = SubagentCatalogSnapshot['entries'][number] @@ -57,23 +60,115 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] { : Array.from(root.querySelectorAll('[role="treeitem"]:not([aria-disabled="true"])')) } -/** Compact trailing activity time for a catalog row. */ -function relativeTime( - updatedAt: number | undefined, +/** Compact token count shared in shape with the conversation stats strip. */ +function formatTokens(value: number): string { + const scaled = (next: number): string => next >= 100 + ? String(Math.round(next)) + : String(Math.round(next * 10) / 10) + if (value < 1_000) return String(value) + if (value < 1_000_000) return `${scaled(value / 1_000)}K` + return `${scaled(value / 1_000_000)}M` +} + +/** Sum the four disjoint durable provider-usage buckets. */ +function tokenTotal( + usage: SessionProjectionMap['tokenUsage'] | undefined, +): number | undefined { + return usage === undefined + ? undefined + : usage.uncachedInputTokens + usage.outputTokens + + usage.cacheReadTokens + usage.cacheWriteTokens +} + +/** Exact whole-second active-turn duration for one catalog row. */ +function activityDuration( + summary: SessionSummary | undefined, + activity: 'running' | 'inactive', now: number, - t: TranslateNS, -): string | undefined { - if (updatedAt === undefined) return undefined - const minute = 60_000 - const hour = 60 * minute - const day = 24 * hour - const diff = Math.max(0, now - updatedAt) - if (diff < minute) return t('time.justNow') - if (diff < hour) return t('time.minutes', { n: Math.floor(diff / minute) }) - if (diff < day) return t('time.hours', { n: Math.floor(diff / hour) }) - if (diff < 30 * day) return t('time.days', { n: Math.floor(diff / day) }) - if (diff < 365 * day) return t('time.months', { n: Math.floor(diff / (30 * day)) }) - return t('time.years', { n: Math.floor(diff / (365 * day)) }) +): number | undefined { + if (summary === undefined) return undefined + const timing: SessionProjectionMap['subagentTiming'] | undefined + = summary.projectionValues?.subagentTiming + if (timing === undefined) return undefined + if (timing.active === undefined) return timing.settledMs + const end = activity === 'running' + ? now + : timing.active.through + return timing.settledMs + Math.max(0, end - timing.active.since) +} + +interface DurationParts { + seconds: number + minutes: number + hours: number + days: number + totalMinutes: number + totalHours: number +} + +function splitDuration(ms: number): DurationParts { + const totalSeconds = Math.floor(Math.max(0, ms) / 1_000) + const totalMinutes = Math.floor(totalSeconds / 60) + const totalHours = Math.floor(totalMinutes / 60) + return { + seconds: totalSeconds % 60, + minutes: totalMinutes % 60, + hours: totalHours % 24, + days: Math.floor(totalHours / 24), + totalMinutes, + totalHours, + } +} + +/** Format a duration with decreasing visual precision at larger scales. */ +function formatDuration(ms: number, t: TranslateNS): string { + const { seconds, minutes, hours, days, totalMinutes, totalHours } = splitDuration(ms) + if (days >= 365) { + const years = Math.floor(days / 365) + const months = Math.floor((days % 365) / 30) + return months === 0 + ? t('duration.years', { years }) + : t('duration.yearsMonths', { years, months }) + } + if (days >= 30) { + const months = Math.floor(days / 30) + const remainingDays = days % 30 + return remainingDays === 0 + ? t('duration.months', { months }) + : t('duration.monthsDays', { months, days: remainingDays }) + } + if (days > 0) { + return hours === 0 + ? t('duration.days', { days }) + : t('duration.daysHours', { days, hours }) + } + if (totalHours > 0) { + return t('duration.hours', { + hours: totalHours, + minutes: String(minutes).padStart(2, '0'), + seconds: String(seconds).padStart(2, '0'), + }) + } + if (totalMinutes > 0) { + return t('duration.minutes', { + minutes: totalMinutes, + seconds: String(seconds).padStart(2, '0'), + }) + } + return t('duration.seconds', { seconds }) +} + +/** Preserve exact whole seconds for hover and accessible naming. */ +function formatExactDuration(ms: number, t: TranslateNS): string { + const { seconds, minutes, hours, days } = splitDuration(ms) + return days === 0 + ? formatDuration(ms, t) + : t('duration.exactDays', { + days, + hours: String(hours).padStart(2, '0'), + minutes: String(minutes).padStart(2, '0'), + seconds: String(seconds).padStart(2, '0'), + }) } /** Aggregate the complete subagent-only descendant subtree from flat summaries. */ @@ -142,6 +237,9 @@ function CatalogRows({ openChild, refresh, toggleBranch, closeCatalog, t, }: CatalogRowsProps & { t: TranslateNS }) { const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 + const reserveDisclosure = catalog.entries.some( + entry => entry.kind === 'child' && entry.hasChildren, + ) return ( <> {emptyLoading && ( @@ -178,7 +276,7 @@ function CatalogRows({ className={`${css.row} ${css.disabled}`} title={reason} > - + {reserveDisclosure && } {entry.id} @@ -201,7 +299,24 @@ function CatalogRows({ const secondary = [summary?.title, mode, activity] .filter(value => value !== undefined) .join(' · ') - const time = relativeTime(summary?.updatedAt, now, t) + const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage) + const durationMs = activityDuration( + summary, + entry.activity, + now, + ) + const tokenMetric = totalTokens === undefined + ? undefined + : `${formatTokens(totalTokens)} tok` + const durationMetric = durationMs === undefined + ? undefined + : { + compact: formatDuration(durationMs, t), + exact: formatExactDuration(durationMs, t), + } + const metrics = [tokenMetric, durationMetric?.exact] + .filter(value => value !== undefined) + .join(' · ') const open = (): void => { openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) @@ -233,14 +348,14 @@ function CatalogRows({ role="treeitem" tabIndex={0} aria-level={level} - aria-label={[label, secondary, time].filter(value => value !== undefined).join(' ')} + aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')} {...knownLeaf ? {} : { 'aria-expanded': isExpanded }} className={css.row} onClick={open} onKeyDown={handleKey} > {knownLeaf - ? + ? reserveDisclosure && : (
{isExpanded && !knownLeaf && ( @@ -313,6 +440,7 @@ export function SubagentCatalogAction({ const summaries = useSessions(state => state.byId) const catalog = catalogs[sessionId] const [open, setOpen] = useState(false) + const [now, setNow] = useState(() => Date.now()) const [expanded, setExpanded] = useState>(() => new Set()) const rootRef = useRef(null) const triggerRef = useRef(null) @@ -355,7 +483,10 @@ export function SubagentCatalogAction({ const changeOpen = (next: boolean, restoreFocus = false): void => { setOpen(next) - if (next) observeCatalog(sessionId, true) + if (next) { + setNow(Date.now()) + observeCatalog(sessionId, true) + } else closeAllCatalogs() if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() }) } @@ -395,6 +526,12 @@ export function SubagentCatalogAction({ return () => { document.removeEventListener('pointerdown', closeOutside) } }, [open]) + useEffect(() => { + if (!open || !descendants.running) return + const timer = setInterval(() => { setNow(Date.now()) }, 1_000) + return () => { clearInterval(timer) } + }, [open, descendants.running]) + useEffect(() => () => { for (const parentSessionId of observedCatalogs.current) { setCatalogOpenRef.current(parentSessionId, false) @@ -471,7 +608,7 @@ export function SubagentCatalogAction({ summaries={summaries} expanded={expanded} level={1} - now={Date.now()} + now={now} openChild={openChild} refresh={refresh} toggleBranch={toggleBranch} diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 86562534b0..b3897d7216 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -8,12 +8,17 @@ export const zh = { 'diagnostic.corrupt': '会话记录损坏', 'diagnostic.unsupported': '子代理记录版本不受支持', 'diagnostic.unavailable': '会话记录暂不可用', - 'time.justNow': '刚刚', - 'time.minutes': '{n}分钟', - 'time.hours': '{n}小时', - 'time.days': '{n}天', - 'time.months': '{n}个月', - 'time.years': '{n}年', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分{seconds}秒', + 'duration.hours': '{hours}小时{minutes}分{seconds}秒', + 'duration.days': '{days}天', + 'duration.daysHours': '{days}天{hours}小时', + 'duration.months': '约{months}个月', + 'duration.monthsDays': '约{months}个月{days}天', + 'duration.years': '约{years}年', + 'duration.yearsMonths': '约{years}年{months}个月', + 'duration.exactDays': '{days}天{hours}小时{minutes}分{seconds}秒', + 'duration.exactTitle': '总活跃耗时:{duration}', 'loading.label': '正在加载子代理…', 'loading.aria': '正在加载子代理', 'load.error': '无法加载子代理', @@ -40,12 +45,17 @@ export const en: Record = { 'diagnostic.corrupt': 'corrupted session record', 'diagnostic.unsupported': 'unsupported subagent record version', 'diagnostic.unavailable': 'session record temporarily unavailable', - 'time.justNow': 'just now', - 'time.minutes': '{n}m', - 'time.hours': '{n}h', - 'time.days': '{n}d', - 'time.months': '{n}mo', - 'time.years': '{n}y', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m {seconds}s', + 'duration.days': '{days}d', + 'duration.daysHours': '{days}d {hours}h', + 'duration.months': '~{months}mo', + 'duration.monthsDays': '~{months}mo {days}d', + 'duration.years': '~{years}y', + 'duration.yearsMonths': '~{years}y {months}mo', + 'duration.exactDays': '{days}d {hours}h {minutes}m {seconds}s', + 'duration.exactTitle': 'Total active duration: {duration}', 'loading.label': 'Loading subagents…', 'loading.aria': 'Loading subagents', 'load.error': 'Unable to load subagents', diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 2a696786ad..b6c02fcfb7 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot, @@ -13,6 +13,7 @@ import { zh } from '../src/client/locales.ts' afterEach(() => { cleanup() + vi.useRealTimers() vi.restoreAllMocks() }) @@ -150,6 +151,7 @@ describe('SubagentCatalogAction', () => { expect(diagnostic.getAttribute('aria-disabled')).toBe('true') expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy() expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeNull() + expect(screen.getByRole('treeitem', { name: /reviewer/ }).children).toHaveLength(2) fireEvent.click(screen.getByRole('treeitem', { name: /worker/ })) expect(input.openChild).toHaveBeenCalledWith({ @@ -176,6 +178,19 @@ describe('SubagentCatalogAction', () => { expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 }) }) + it('removes the disclosure column from branchless catalog levels', () => { + const input = props(catalog({ + entries: [{ + kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', + activity: 'running', hasChildren: false, + }], + })) + render() + fireEvent.click(screen.getByRole('button', { name: /1 个子代理/ })) + + expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(1) + }) + it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => { const input = props(catalog()) render() @@ -239,42 +254,99 @@ describe('SubagentCatalogAction', () => { }) }) - it('renders compact activity times across every unit and clamps future timestamps', () => { + it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => { const now = 2_000_000_000_000 - vi.spyOn(Date, 'now').mockReturnValue(now) const minute = 60_000 const hour = 60 * minute const day = 24 * hour + vi.useFakeTimers() + vi.setSystemTime(now) const rows = [ - ['future', now + minute], - ['minutes', now - 2 * minute], - ['hours', now - 2 * hour], - ['days', now - 2 * day], - ['months', now - 60 * day], - ['years', now - 2 * 365 * day], + ['running', 'running', 65_000, now - 5_000, now - 1_000, now], + ['finished', 'inactive', 3_723_000, undefined, undefined, now - 60_000], + ['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000, now + 60_000], + ['days', 'inactive', 12 * day + 5 * hour + 6 * minute + 7_000, undefined, undefined, now], + ['whole-day', 'inactive', day, undefined, undefined, now], + ['months', 'inactive', 192 * day, undefined, undefined, now], + ['whole-month', 'inactive', 30 * day, undefined, undefined, now], + ['years', 'inactive', 832 * day, undefined, undefined, now], + ['whole-year', 'inactive', 365 * day, undefined, undefined, now], ] as const - const entries = rows.map(([id]) => ({ + const usageById = { + running: { + uncachedInputTokens: 1_000, + outputTokens: 200, + cacheReadTokens: 3_000, + cacheWriteTokens: 400, + }, + finished: { + uncachedInputTokens: 123, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + interrupted: { + uncachedInputTokens: 123_000_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + } as const + const entries = rows.map(([id, activity]) => ({ kind: 'child' as const, id: id as SessionId, mode: 'continuable' as const, label: id, - activity: 'inactive' as const, + activity, hasChildren: false, })) - const summaries = Object.fromEntries(rows.map(([id, updatedAt]) => [ - id, - summary(id as SessionId, updatedAt), - ])) as Record + const summaries = Object.fromEntries(rows.map(([ + id, activity, settledMs, activeSince, activeThrough, updatedAt, + ]) => { + const childId = id as SessionId + return [id, { + ...summary(childId, updatedAt), + parentId: PARENT, + origin: 'subagent' as const, + running: activity === 'running', + projectionValues: { + subagentTiming: { + settledMs, + ...(activeSince === undefined || activeThrough === undefined + ? {} + : { active: { since: activeSince, through: activeThrough } }), + }, + tokenUsage: id in usageById + ? usageById[id as keyof typeof usageById] + : undefined, + }, + }] + })) as Record const input = props(catalog({ entries }), {}, summaries) render() - fireEvent.click(screen.getByRole('button', { name: /6 个子代理/ })) + fireEvent.click(screen.getByRole('button', { name: /9 个子代理/ })) - expect(screen.getByRole('treeitem', { name: /future.*刚刚/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /minutes.*2分钟/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /hours.*2小时/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /days.*2天/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /months.*2个月/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /years.*2年/ })).toBeTruthy() + const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ }) + const runningMetrics = within(runningRow) + const tokenMetric = runningMetrics.getByText('4.6K tok') + const durationMetric = runningMetrics.getByText('1分10秒') + expect(tokenMetric.parentElement).toBe(durationMetric.parentElement) + expect(tokenMetric.nextElementSibling).toBe(durationMetric) + expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /days.*12天05小时06分07秒/ })).toBeTruthy() + expect(screen.getByText('12天5小时').getAttribute('title')) + .toBe('总活跃耗时:12天05小时06分07秒') + expect(screen.getByText('1天')).toBeTruthy() + expect(screen.getByText('约6个月12天')).toBeTruthy() + expect(screen.getByText('约1个月')).toBeTruthy() + expect(screen.getByText('约2年3个月')).toBeTruthy() + expect(screen.getByText('约1年')).toBeTruthy() + + await vi.advanceTimersByTimeAsync(1_000) + expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy() }) it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => { diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index 84c310fc56..27d06d16c0 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -29,6 +29,12 @@ { "path": "../ui-slots" }, + { + "path": "../../llm/token-meter" + }, + { + "path": "../../subagent/subagent" + }, { "path": "../../support/invariants" } 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() }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index f7043e2403..ceac4245a5 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 9aea27a0f150d90a41d9a7cb4cd422a75e6107fe -README.zh.md: 3f0b534deae53b8d5aff2765974050f26b931953 +README.md: e54f0b98ec3649cec428a47026e6657a9749608b +README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 9aea27a0f1..e54f0b98ec 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -92,6 +92,8 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. +When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn. While that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. + `registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately. ## Collection model diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 3f0b534dea..1624fa5985 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -92,6 +92,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 +当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。 + `registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 ## 收集模型 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 863f04c117..8cd78c5526 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -15,17 +15,25 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", @@ -35,6 +43,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -46,6 +55,9 @@ "@deepseek-ai/dsh-session-query": { "optional": true }, + "@deepseek-ai/dsh-session-projection": { + "optional": true + }, "@deepseek-ai/dsh-tasks": { "optional": true } @@ -59,6 +71,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/subagent/subagent/src/client.ts b/packages/subagent/subagent/src/client.ts new file mode 100644 index 0000000000..928637dc7a --- /dev/null +++ b/packages/subagent/subagent/src/client.ts @@ -0,0 +1,7 @@ +/** + * Browser-safe subagent projection vocabulary. + * + * @module @deepseek-ai/dsh-subagent/client + */ + +export type { SubagentTimingProjection } from './projection-types.ts' diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 19ad74ff3a..975d07ce4c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -65,6 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t import { listChildren as listSubagentChildren } from './list-children.ts' import type { SubagentListEntry } from './list-children.ts' import { snapshotSubagentDescriptor } from './descriptor.ts' +import { subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' @@ -117,6 +118,7 @@ export type { export type { ContinuableSetupContribution } from './activation-setup-registry.ts' export type { SubagentListEntry } from './list-children.ts' export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' +export type { SubagentTimingProjection } from './projection-types.ts' declare module 'cordis' { interface Context { @@ -186,6 +188,9 @@ export class SubagentService extends Service { if (this.continuations === manager) this.continuations = undefined }, 'subagents.continuationBinding()') }) + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) + }) } /** diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts new file mode 100644 index 0000000000..c5a23b03b8 --- /dev/null +++ b/packages/subagent/subagent/src/projection-types.ts @@ -0,0 +1,25 @@ +/** + * Pure client-safe subagent projection vocabulary. + * + * @module @deepseek-ai/dsh-subagent/projection-types + */ + +/** Durable active-turn timing for one descriptor-backed child session. */ +export interface SubagentTimingProjection { + /** Milliseconds accumulated across completed turns after the child's own descriptor. */ + settledMs: number + /** Same-cut bounds of the currently open turn, when one has not reached `turn/end`. */ + active?: { + /** Start of the open turn. */ + since: number + /** Latest event time folded into this projection cut. */ + through: number + } +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Active-turn duration for a descriptor-backed subagent session. */ + subagentTiming: SubagentTimingProjection + } +} diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts new file mode 100644 index 0000000000..ffdcb4fd09 --- /dev/null +++ b/packages/subagent/subagent/src/projection.ts @@ -0,0 +1,82 @@ +/** + * Pure session projection for subagent active-turn duration. + * + * @module @deepseek-ai/dsh-subagent/projection + */ + +import { z } from 'zod' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { SubagentTimingProjection } from './projection-types.ts' + +interface TimingState { + /** Milliseconds accumulated across completed post-descriptor turns. */ + settledMs: number + /** Current open interval kept paired inside the fold. */ + active?: { since: number; through: number } + /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */ + pendingTurnStart?: number + /** Whether the fold has crossed a descriptor in this logical log. */ + descriptorSeen: boolean +} + +// Zod's optional output includes explicit `undefined`; with +// exactOptionalPropertyTypes the public interface permits omission only. +const projectionSchema = z.object({ + settledMs: z.number().int().nonnegative(), + active: z.object({ + since: z.number().int().nonnegative(), + through: z.number().int().nonnegative(), + }).strict().optional(), +}).strict() as unknown as z.ZodType + +/** + * Fold turn boundaries around the child's own durable descriptor. + * + * A fork seed may contain an ancestor descriptor and completed turns. Every + * descriptor therefore resets the accumulated state; the healthy catalog + * admits only a child with exactly one descriptor in its own suffix, making + * the final reset the child's authoritative timing origin. + */ +export const subagentTimingProjectionDefinition: +ProjectionDefinition<'subagentTiming', TimingState> = { + key: 'subagentTiming', + schema: projectionSchema, + init: () => ({ descriptorSeen: false, settledMs: 0 }), + apply: (state, event) => { + if (event.type === 'turn/start') { + return state.descriptorSeen + ? { ...state, active: { since: event.time, through: event.time } } + : { ...state, pendingTurnStart: event.time } + } + if (event.type === 'subagent/descriptor') { + const activeSince = state.active?.since ?? state.pendingTurnStart + return { + descriptorSeen: true, + settledMs: 0, + ...(activeSince === undefined + ? {} + : { active: { since: activeSince, through: event.time } }), + } + } + if (event.type === 'turn/end') { + if (!state.descriptorSeen) { + if (state.pendingTurnStart === undefined) return state + const { pendingTurnStart: _closed, ...next } = state + return next + } + if (state.active === undefined) return state + const { active, ...rest } = state + return { + ...rest, + settledMs: state.settledMs + Math.max(0, event.time - active.since), + } + } + if (state.active === undefined) return state + return { ...state, active: { ...state.active, through: event.time } } + }, + view: state => ({ + settledMs: state.settledMs, + ...(state.active === undefined ? {} : { active: state.active }), + }), + stateVersion: 2, +} diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts new file mode 100644 index 0000000000..e9a3be43ea --- /dev/null +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SubagentService from '../src/index.ts' +import { subagentTimingProjectionDefinition } from '../src/projection.ts' + +function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent { + return { type, seq, time, data: {} } as SessionEvent +} + +function fold(events: SessionEvent[]) { + let state = subagentTimingProjectionDefinition.init() + for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item) + return subagentTimingProjectionDefinition.view(state) +} + +describe('subagent timing projection', () => { + it('registers with the optional session projection registry', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const serviceFiber = await ctx.plugin(SubagentService) + + expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming) + .toEqual({ settledMs: 0 }) + await serviceFiber.dispose() + expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming) + .toBeUndefined() + }) + + it('resets inherited seed timing at the child descriptor and sums later completed turns', () => { + expect(fold([ + event('turn/start', 0, 100), + event('subagent/descriptor', 1, 110), + event('turn/end', 2, 300), + event('turn/start', 3, 1_000), + event('subagent/descriptor', 4, 1_100), + event('turn/end', 5, 4_100), + event('turn/start', 6, 10_000), + event('turn/end', 7, 12_000), + ])).toEqual({ settledMs: 5_100 }) + }) + + it('exposes an open turn start and never subtracts time for reversed boundaries', () => { + expect(fold([ + event('turn/start', 0, 1_000), + event('subagent/descriptor', 1, 1_100), + event('turn/end', 2, 900), + event('turn/start', 3, 2_000), + event('assistant/chunk', 4, 2_500), + ])).toEqual({ settledMs: 0, active: { since: 2_000, through: 2_500 } }) + }) + + it('ignores completed pre-descriptor turns and unrelated events', () => { + const initial = subagentTimingProjectionDefinition.init() + expect(subagentTimingProjectionDefinition.apply( + initial, + event('assistant/chunk', 0, 1), + )).toBe(initial) + expect(subagentTimingProjectionDefinition.apply( + initial, + event('turn/end', 1, 2), + )).toBe(initial) + const descriptor = subagentTimingProjectionDefinition.apply( + initial, + event('subagent/descriptor', 2, 3), + ) + expect(subagentTimingProjectionDefinition.apply( + descriptor, + event('turn/end', 3, 4), + )).toBe(descriptor) + expect(fold([ + event('turn/start', 0, 100), + event('turn/end', 1, 200), + event('subagent/descriptor', 2, 300), + ])).toEqual({ settledMs: 0 }) + }) +}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 1c3a5fb6de..612330c646 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../session-query/session-query" }, + { + "path": "../../session-projection/session-projection" + }, { "path": "../../tasks/tasks" }, diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 65a471f69a..d565f6f11c 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 2eb9e904d574df39b0884558fc0a53f9dc04cdc1 -README.zh.md: 78bd99943fcadebf42a5d772d49f3bfcf6a8790a +README.md: 7e0466c40583e6f5b22e0d5ef25d211d595c3216 +README.zh.md: abb796aaa9fd6f8e6ee0578423382ed7f23909ab diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 2eb9e904d5..7e0466c405 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,7 +8,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | -| `installFailLoud(binName, proc?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | +| `installFailLoud(binName, proc?, release?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller (for tests) | +| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | @@ -22,6 +23,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c Loader settlement rejects import and lifecycle failures with the failing entry and stage; `boot()` disposes the partial context and wraps that failure with the bin name. Entries settlement leaves behind are audited separately: `assertEntriesLoaded` turns an enabled fiber-less entry into a rejection naming every unresolved plugin, and `assertEntriesActivated` awaits each failed fiber to include its original stack in the startup rejection and names each pending entry's unresolved services. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. +The Loader mounts entries concurrently, so a surface can already own the terminal when something else fails: exiting without the tree's own teardown would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A config-tree failure settles through `boot()`, whose disposal of the partial context runs the surface's own shutdown before the labelled rejection. For the rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting — a terminal-owning bin passes `release` to dispose the tree before the exit commits; `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value so the hook covers the whole mounting window. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown. + Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 78bd99943f..abb796aaa9 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,7 +8,8 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | -| `installFailLoud(binName, proc?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | +| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数(供测试使用) | +| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | @@ -22,6 +23,8 @@ Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 +Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先释放部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前释放整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection,后续 rejection(包括拆卸自身的)会被吞掉,而不会变成未捕获错误、在拆卸中途杀死进程。 + 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index eb5003f72b..7f3579cda1 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -391,6 +391,11 @@ export interface FailLoudProcess { on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown stderr: { write(chunk: string): unknown } + /** + * Terminate the process. Callers treat this as the end of the run, as + * `process.exit` is; a fake that returns lets the caller continue, which only + * a test observes. + */ exit(code: number): void } @@ -421,24 +426,81 @@ async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Pr } } +/** + * How long {@link installFailLoud} waits for its `release` hook before exiting + * anyway. A wedged disposer must delay the fatal exit, never cancel it. + */ +export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000 + /** * Install before boot to turn a late unhandled plugin-init rejection into one * labelled stderr diagnostic and `exit(1)`. A rejection already included by * {@link assertEntriesActivated} is ignored during its process checkpoint; * every other rejection remains fatal. Stdout remains untouched for ACP; the * returned function removes the handler. + * + * The Loader mounts entries concurrently, so a surface that owns the terminal + * can already hold it when a sibling entry rejects. Exiting straight from the + * handler would strand raw mode, bracketed paste, and the keyboard protocol on + * the user's shell, and leave an in-flight terminal query's reply to land as + * literal text at the next prompt. `release` is the terminal owner's chance to + * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose + * timer stays referenced so a never-settling disposer cannot let Node reach an + * empty event loop and exit 0 instead of failing. + * + * The diagnostic is written before the release so a hanging or failing disposer + * cannot swallow the reason. The handler stays installed while the release runs + * — removing it would let a second concurrent rejection become uncaught and kill + * the process mid-teardown, stranding exactly the terminal state this restores — + * so a latch keeps the first rejection the reported one and lets later + * rejections (including the release's own) fall through to the pending exit. * @param binName - the diagnostic prefix on the fatal-failure line. * @param proc - the process slice to register on; tests inject a fake. + * @param release - optional teardown awaited before exit, used by a + * terminal-owning surface to restore the terminal. Its own failure is + * swallowed because the pending fatal exit already owns the outcome. * @returns the uninstaller that removes the rejection handler. */ -export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void { +export function installFailLoud( + binName: string, + proc: FailLoudProcess = process, + release?: () => Promise | void, +): () => void { + let exiting = false const handler = (err: unknown): void => { if (assembledActivationRejections.has(err)) return + // A release in flight already owns the exit. Swallow later rejections + // (teardown's own included) rather than reporting a second failure over the + // real one or letting Node kill the process before the terminal is back. + if (exiting) return + exiting = true proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) - proc.exit(1) + if (release === undefined) { + proc.exit(1) + return + } + void (async () => { + // Definitely assigned: the timeout promise's executor runs synchronously + // while the race is being constructed, before the first await. + let timer!: ReturnType + try { + await Promise.race([ + (async () => release())(), + new Promise((resolve) => { + timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS) + }), + ]) + } catch { + // The terminal release failed; the fatal exit below is the outcome that + // matters, and no reporter runs after it. + } + clearTimeout(timer) + proc.exit(1) + })() } + const uninstall = (): void => void proc.off('unhandledRejection', handler) proc.on('unhandledRejection', handler) - return () => void proc.off('unhandledRejection', handler) + return uninstall } /** diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 610f93b3d5..96cad31ea3 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -5,7 +5,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { - addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, + addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, + FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' @@ -109,16 +110,22 @@ describe('installFailLoud', () => { expect(proc.exits).toEqual([1]) }) + // One rejection is reported per install: the first is the diagnosis, so each + // formatting case needs its own handler rather than reusing a latched one. it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => { - const proc = fakeProc() - installFailLoud(NAME, proc) - proc.handlers[0]!('plain failure') - expect(proc.written[0]).toContain('plain failure') + const plain = fakeProc() + installFailLoud(NAME, plain) + plain.handlers[0]!('plain failure') + expect(plain.written[0]).toContain('plain failure') + expect(plain.exits).toEqual([1]) + const stackless = new Error('no stack') delete (stackless as { stack?: string }).stack - proc.handlers[0]!(stackless) - expect(proc.written[1]).toContain('no stack') - expect(proc.exits).toEqual([1, 1]) + const bare = fakeProc() + installFailLoud(NAME, bare) + bare.handlers[0]!(stackless) + expect(bare.written[0]).toContain('no stack') + expect(bare.exits).toEqual([1]) }) it('returns an uninstaller that removes the handler (and defaults to the real process)', () => { @@ -162,6 +169,64 @@ describe('installFailLoud', () => { proc.handlers[0]!(error) expect(proc.exits).toEqual([1]) }) + + // The Loader mounts entries concurrently, so a terminal-owning surface can + // already hold raw mode when a sibling entry rejects. Exiting without running + // its teardown strands the terminal on the user's shell. + it('awaits the release hook before exiting so the terminal owner can restore it', async () => { + const proc = fakeProc() + const order: string[] = [] + installFailLoud(NAME, proc, async () => { + await Promise.resolve() + order.push('released') + }) + proc.handlers[0]!(new Error('sibling entry rejected')) + expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `) + // The release is in flight, so the exit has not committed yet. + expect(proc.exits).toEqual([]) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(order).toEqual(['released']) + }) + + it('still exits when the release hook rejects', async () => { + const proc = fakeProc() + installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed'))) + proc.handlers[0]!(new Error('boom')) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + }) + + it('exits without waiting when a release hook never settles', async () => { + vi.useFakeTimers() + try { + const proc = fakeProc() + installFailLoud(NAME, proc, () => new Promise(() => {})) + proc.handlers[0]!(new Error('boom')) + expect(proc.exits).toEqual([]) + await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS) + expect(proc.exits).toEqual([1]) + } finally { + vi.useRealTimers() + } + }) + + // Loader failures arrive in bursts, and teardown's own disposers may reject. + // Only the first rejection is the diagnosis; the handler must stay installed + // so a later one cannot become uncaught and kill the process mid-teardown. + it('reports only the first rejection and keeps handling later ones during the release', async () => { + const proc = fakeProc() + let released = false + installFailLoud(NAME, proc, async () => { + await Promise.resolve() + released = true + }) + proc.handlers[0]!(new Error('first rejection')) + proc.handlers[0]!(new Error('second rejection')) + expect(proc.handlers).toHaveLength(1) + expect(proc.written).toHaveLength(1) + expect(proc.written[0]).toContain('first rejection') + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(released).toBe(true) + }) }) describe('assertEntriesLoaded', () => { diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 1715dfa776..043d13739c 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: 7e32af76b3cc7a3d5e99b4a587e66acd31d02496 -README.zh.md: be1cdcc2775536cf442d3e7cccaf0e93eea65ab9 +README.md: 4861498410a41db2b6fb103b9097633130c8eceb +README.zh.md: 00d3eedd8af17b65f56a187e4a4a91e775463ffa diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 7e32af76b3..4861498410 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. During a live standalone compaction bracket, a fixed `Context being compacted ` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: ` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. During a live standalone compaction bracket, a fixed `Context being compacted ` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: ` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. @@ -57,6 +57,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `76` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | +| `detailsDialogWidth` | `72` | Transcript-details selector width in columns | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index be1cdcc277..00d3eedd8a 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,提示词上方会显示固定的 `Context being compacted ` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: `,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md))。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,提示词上方会显示固定的 `Context being compacted ` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: `,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md))。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 @@ -57,6 +57,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `questionDialogMaxHeight` | `20` | 问题面板最大行数 | | `modelDialogWidth` | `76` | 模型选择器宽度(列数) | | `modelDialogMaxHeight` | `20` | 模型选择器最大行数 | +| `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) | | `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 | | `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | 遍历和直接补全时忽略的目录 basename | diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index b6968d1acb..02ab4eac0f 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -28,6 +28,7 @@ import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' import { dialogSelectTheme, type Palette } from './theme.ts' +import type { ToolCardVisibility } from './transcript.ts' import { renderTuiPromptTemplate, type TuiPromptTemplateToken, @@ -426,6 +427,79 @@ export class ModelDialog implements Component { } } +/** Both transcript-detail dimensions, applied immediately on each Tab. */ +export interface DetailsSelection { + readonly visibility: ToolCardVisibility + readonly showReasoning: boolean +} + +const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden'] + +/** + * Keyboard toggle over the two transcript-detail entries — tool-card + * visibility and reasoning display. Tab cycles the highlighted entry's value + * and applies it immediately, so the transcript behind the dialog is the live + * preview; Enter, Esc, or Ctrl+C closes. + */ +export class DetailsDialog implements Component { + private readonly list: SelectList + private readonly toolsItem: SelectItem + private readonly reasoningItem: SelectItem + + constructor( + private visibility: ToolCardVisibility, + private showReasoning: boolean, + private readonly palette: Palette, + private readonly apply: (selection: DetailsSelection) => void, + private readonly close: () => void, + ) { + this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility } + this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() } + this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette)) + this.list.onSelect = close + } + + private reasoningLabel(): string { + return this.showReasoning ? 'shown' : 'hidden' + } + + /** Cycle the highlighted entry one step and apply the new state. */ + private cycle(): void { + const selected = this.list.getSelectedItem() + /* v8 ignore next -- the two-entry list always has a selection. */ + if (selected === null) return + if (selected.value === 'tools') { + const index = TOOL_CARD_PHASES.indexOf(this.visibility) + this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility + this.toolsItem.description = this.visibility + } else { + this.showReasoning = !this.showReasoning + this.reasoningItem.description = this.reasoningLabel() + } + this.apply({ visibility: this.visibility, showReasoning: this.showReasoning }) + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close() + else if (matchesKey(data, Key.tab)) this.cycle() + else this.list.handleInput(data) + this.invalidate() + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + return renderDialog('Transcript details', [ + ...this.list.render(innerWidth), + '', + this.palette.dim('↑/↓ move • Tab toggle • Enter/Esc close'), + ], width, this.palette) + } +} + /** A resume selector row summarizing one session from metadata and its folded title. */ export interface ResumeCandidate { record: SessionRecord diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index c8ba061166..300b39fe08 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -149,20 +149,28 @@ export class UserMessageComponent extends Container { } } -/** Children of a settled assistant message: optional reasoning block then the response text. */ +/** + * Children of a settled assistant message: optional reasoning block then the + * response text. A folded continuation (a later step of a turn while tool cards + * are hidden) drops the `Assistant` header and renders nothing when it has no + * visible body, so tool-only steps leave no blank segment behind. + */ function assistantMessageChildren( content: readonly ContentBlock[], showReasoning: boolean, + foldedContinuation: boolean, palette: Palette, mdTheme: MarkdownTheme, ): Component[] { const reasoning = displayText(textBlocks(content, 'reasoning').trim()) const text = displayText(textBlocks(content, 'text').trim()) - const children: Component[] = [ - new Spacer(1), - new Text(messageHeader('Assistant', palette.accent, palette), 0, 0), - ] - if (reasoning && showReasoning) { + const showsReasoning = reasoning !== '' && showReasoning + if (foldedContinuation && !showsReasoning && text === '') return [] + const children: Component[] = [new Spacer(1)] + if (!foldedContinuation) { + children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0)) + } + if (showsReasoning) { children.push( new Text(palette.italic(palette.dim('Reasoning')), 0, 0), new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }), @@ -220,6 +228,7 @@ interface StreamingBlock { export class StreamingAssistantComponent extends Container { private readonly blocks = new Map() private settledContent: readonly ContentBlock[] | undefined + private foldedContinuation = false /** * The step's timing footer. The renderer keeps it at the tail of the chat so * it trails any tool cards the step appends after this assistant message; it @@ -228,7 +237,8 @@ export class StreamingAssistantComponent extends Container { readonly timing: StepTimingComponent constructor( - position: StepPosition, + /** The step's turn/step coordinates, used to group steps into their turn. */ + readonly position: StepPosition, events: () => readonly SessionEvent[], now: () => number, private showReasoning: boolean, @@ -299,18 +309,49 @@ export class StreamingAssistantComponent extends Container { this.rebuild() } - private rebuild(): void { - this.clear() - const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()] + /** + * Mark this step as a folded continuation of its turn: no `Assistant` header, + * and no output at all while the step has no visible body. Used while tool + * cards are hidden so a turn reads as one assistant message. + * @param folded - Whether to render as a headerless continuation. + */ + setFoldedContinuation(folded: boolean): void { + if (this.foldedContinuation === folded) return + this.foldedContinuation = folded + this.rebuild() + } + + /** + * Whether the step currently renders visible reasoning or text. + * @returns `true` when a header-owning render would show a body. + */ + hasVisibleBody(): boolean { + const content = this.presentedContent() + return textBlocks(content, 'text').trim() !== '' + || (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '') + } + + /** The settled content when available, otherwise the streamed blocks in model order. */ + private presentedContent(): readonly ContentBlock[] { + return this.settledContent ?? [...this.blocks.entries()] .sort(([left], [right]) => left - right) .flatMap(([, block]) => { if (block.type === 'text') return [{ type: 'text', text: block.text }] if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] return [] }) - for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) { - this.addChild(child) - } + } + + private rebuild(): void { + this.clear() + const children = assistantMessageChildren( + this.presentedContent(), + this.showReasoning, + this.foldedContinuation, + this.palette, + this.mdTheme, + ) + for (const child of children) this.addChild(child) } } diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index 010fe3285c..568bc46dee 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -48,6 +48,8 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -71,6 +73,7 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(76) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const detailsDialogWidthSchema = z.number().step(1).min(20).default(72) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) @@ -102,6 +105,7 @@ const tuiConfigSchemaFields = { questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + detailsDialogWidth: detailsDialogWidthSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -142,6 +146,7 @@ export const Config: z = z.object({ questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + detailsDialogWidth: tuiConfigSchemaFields.detailsDialogWidth, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -171,6 +176,7 @@ export interface ResolvedTuiConfig { questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number + detailsDialogWidth: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -196,6 +202,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 76, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + detailsDialogWidth: config?.detailsDialogWidth ?? 72, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a2cf047e0a..eb5e346ea7 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -105,6 +105,7 @@ import { } from './components/transcript.ts' import { compactTargetLabel, + DetailsDialog, diagnosticMeter, formatDiagnosticCount, formatDiagnosticNumber, @@ -113,6 +114,7 @@ import { StatusCardComponent, PromptContextComponent, targetLabel, + type DetailsSelection, type StatusCardRow, } from './components/dialogs.ts' import { @@ -337,6 +339,10 @@ export function createTuiChat( let toolsVisibility: ToolCardVisibility = 'collapsed' let streaming: StreamingAssistantComponent | undefined let completedStreaming: StreamingAssistantComponent | undefined + // Assistant step components in model order per turn, for hidden-mode folding: + // with tool cards hidden, a turn keeps one Assistant header and later steps + // render as headerless continuations (see applyTurnFolding). + const assistantSteps = new Map() let runningStatus: RunningStatus | undefined let fadingStatus: FadingStatus | undefined /** @@ -646,6 +652,35 @@ export function createTuiChat( return card } + /** + * Re-derive hidden-mode folding for one turn: the first step with a visible + * body owns the turn's single Assistant header, every other step renders as a + * headerless continuation (empty ones render nothing). Any other visibility + * restores the per-step headers. + */ + const applyTurnFolding = (turn: number): void => { + const steps = assistantSteps.get(turn) + if (steps === undefined) return + let headerSeen = false + for (const step of steps) { + if (toolsVisibility !== 'hidden') { + step.setFoldedContinuation(false) + } else if (!headerSeen && step.hasVisibleBody()) { + headerSeen = true + step.setFoldedContinuation(false) + } else { + step.setFoldedContinuation(true) + } + } + } + + const registerAssistantStep = (component: StreamingAssistantComponent): void => { + const steps = assistantSteps.get(component.position.turn) ?? [] + steps.push(component) + assistantSteps.set(component.position.turn, steps) + applyTurnFolding(component.position.turn) + } + const removeStreaming = (current: StreamingAssistantComponent | undefined): void => { if (current === undefined) return for (const child of [current, current.timing]) { @@ -653,6 +688,15 @@ export function createTuiChat( /* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */ if (index >= 0) chat.children.splice(index, 1) } + const steps = assistantSteps.get(current.position.turn) + /* v8 ignore next -- every attached streaming component is registered in the fold map. */ + if (steps === undefined) return + const index = steps.indexOf(current) + /* v8 ignore next -- registration precedes attachment, so the component is present until this removal. */ + if (index < 0) return + steps.splice(index, 1) + // A retracted step may have owned the turn's hidden-mode header. + applyTurnFolding(current.position.turn) } /** @@ -691,6 +735,7 @@ export function createTuiChat( palette, mdTheme, ) + registerAssistantStep(streaming) chat.addChild(streaming) chat.addChild(streaming.timing) } @@ -754,12 +799,22 @@ export function createTuiChat( startAssistantStep(event.data) break case 'assistant/chunk': - if (options.renderChunks) streaming?.update(event.data.chunk) + if (options.renderChunks && streaming !== undefined) { + streaming.update(event.data.chunk) + // The first streamed text/reasoning may make this step the turn's + // hidden-mode header owner (or a continuation with a visible body). + applyTurnFolding(streaming.position.turn) + } break case 'assistant/message': completedStreaming = undefined - if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) - streaming?.settle(event.data.message.content) + // A settled component stays attached but never absorbs a later message + // of the same step; both the live and replay paths start a new one. + if (streaming === undefined || streaming.isSettled() || !chat.children.includes(streaming)) startAssistantStep(event.data) + if (streaming !== undefined) { + streaming.settle(event.data.message.content) + applyTurnFolding(streaming.position.turn) + } break case 'llm/retry': { retractFailedStreaming() @@ -870,6 +925,7 @@ export function createTuiChat( toolCards.clear() allToolCards.clear() contextCards.clear() + assistantSteps.clear() streaming = undefined todo.update([]) const transcriptCalls = transcriptToolCallIds(agent.session) @@ -979,32 +1035,99 @@ export function createTuiChat( // same reason. ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {}) - const toggleTools = (): void => { - // The cycle order puts the two common reading modes adjacent: preview -> - // full detail -> conversation-only, then back to the preview default. - toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded' - : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed' + const setToolsVisibility = (next: ToolCardVisibility): void => { + toolsVisibility = next for (const card of allToolCards) card.setVisibility(toolsVisibility) // Context cards carry injected instructions rather than tool traffic, so // they never hide: the hidden phase reads as their collapsed preview. for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded') + // Hidden mode folds each turn's steps into one assistant message; other + // modes restore the per-step Assistant headers. + for (const turn of assistantSteps.keys()) applyTurnFolding(turn) appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`) } - const toggleReasoning = (): void => { - showReasoning = !showReasoning + const toggleTools = (): void => { + // The cycle order puts the two common reading modes adjacent: preview -> + // full detail -> conversation-only, then back to the preview default. + setToolsVisibility(toolsVisibility === 'collapsed' ? 'expanded' + : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed') + } + + const setReasoning = (show: boolean): void => { + showReasoning = show const activeStreaming = streaming rebuildTranscript(false) /* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */ if (activeStreaming !== undefined) { streaming = activeStreaming streaming.setShowReasoning(showReasoning) + registerAssistantStep(activeStreaming) chat.addChild(activeStreaming) chat.addChild(activeStreaming.timing) } appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) } + const toggleReasoning = (): void => { setReasoning(!showReasoning) } + + // The selector and the argument grammar mutate the same closure state the + // Ctrl+O cycle and Ctrl+R toggle drive, so every entry converges. + let detailsOverlay: TuiOverlaySession | undefined + const showDetailsSelector = (): void => { + void detailsOverlay?.close() + const session = overlayManager.open({ + create: () => new DetailsDialog( + toolsVisibility, + showReasoning, + palette, + // Each Tab applies immediately; one dimension changes per call. + (selection: DetailsSelection) => { + if (selection.showReasoning !== showReasoning) setReasoning(selection.showReasoning) + if (selection.visibility !== toolsVisibility) setToolsVisibility(selection.visibility) + }, + () => { void session.close() }, + ), + options: { width: resolved.detailsDialogWidth, anchor: 'center', margin: 1 }, + }) + detailsOverlay = session + void session.closed.then(() => { + if (detailsOverlay === session) detailsOverlay = undefined + }) + requestRender() + } + + // `/details` names the same transcript-detail state the Ctrl+O cycle and + // Ctrl+R toggle mutate, so a user can jump to a mode without cycling. + const runDetails = (rawInput: string): CommandResult => { + const tokens = rawInput.split(/\s+/u).filter(token => token !== '') + if (tokens.length === 0) { + showDetailsSelector() + return { kind: 'success' } + } + let visibility: ToolCardVisibility | undefined + let reasoning: boolean | undefined + for (let token = tokens.shift(); token !== undefined; token = tokens.shift()) { + if (token === 'collapsed' || token === 'expanded' || token === 'hidden') { + visibility = token + } else if (token === 'reasoning') { + const value = tokens[0] + if (value === 'on' || value === 'off') { + tokens.shift() + reasoning = value === 'on' + } else { + reasoning = !showReasoning + } + } else { + return { kind: 'error', text: `Unknown /details argument "${token}". Usage: /details [collapsed|expanded|hidden] [reasoning [on|off]]` } + } + } + // Reasoning first: its transcript rebuild would drop the visibility notice. + if (reasoning !== undefined) setReasoning(reasoning) + if (visibility !== undefined) setToolsVisibility(visibility) + return { kind: 'success' } + } + const showHelp = (): void => { const commandLines = ctx.commands.list(agent).map((command) => { const input = command.input === undefined ? '' : ` ${command.input.hint}` @@ -1190,6 +1313,12 @@ export function createTuiChat( description: 'Clear the transcript view (session history is unchanged)', handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, }) + commandCtx.commands.register({ + name: 'details', + description: 'Select tool-card visibility and reasoning display', + input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' }, + handler: ({ rawInput }) => runDetails(rawInput), + }) commandCtx.commands.register({ name: 'palette', description: 'Show every color and attribute role this terminal renders', @@ -1529,7 +1658,6 @@ export function createTuiChat( if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn - if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined // Track live standalone compaction state. if (event.type === 'compact/start' && event.data.turn === null) { if (compacting === undefined) { diff --git a/packages/ui/tui/tests/snapshots/details-command.expected.txt b/packages/ui/tui/tests/snapshots/details-command.expected.txt new file mode 100644 index 0000000000..4d971c88df --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-command.expected.txt @@ -0,0 +1,42 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=17 bufferRow=17 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Running the check now. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Inspect the renderer. " +10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 " + style 0-46 dim +11| +12| "Reasoning blocks hidden. " + style 0-23 dim +13| +14| "Tool cards hidden. " + style 0-17 dim +15| +16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +17| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +18-39| diff --git a/packages/ui/tui/tests/snapshots/details-selector.expected.txt b/packages/ui/tui/tests/snapshots/details-selector.expected.txt new file mode 100644 index 0000000000..69ab18bddd --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-selector.expected.txt @@ -0,0 +1,72 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=39 bufferRow=39 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Running the check now. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Inspect the renderer. " +10| +11| "Assistant " + style 0-8 fg=bright-magenta bold underline +12| +13| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +14| "$ pnpm run test:coverage " + style 0-23 dim +15| "/workspace/project " + style 0-17 dim +16| "… +4 lines (Ctrl+O to expand) " + style 0-28 dim +17| "[exit 0] ╭ Transcript details ──────────────────────────────────────────────────╮ " + style 0-7 dim + style 14-85 fg=bright-magenta +18| "Model wait 0.0│ → Tool cards collapsed │ " + style 0-13 dim + style 14-14 fg=bright-magenta + style 16-58 fg=bright-magenta inverse + style 85-85 fg=bright-magenta +19| " │ Reasoning hidden │ " + style 14-14 fg=bright-magenta + style 27-55 dim + style 85-85 fg=bright-magenta +20| "Reasoning bloc│ │ " + style 0-13 dim + style 14-14 fg=bright-magenta + style 85-85 fg=bright-magenta +21| " │ ↑/↓ move • Tab toggle • Enter/Esc close │ " + style 14-14 fg=bright-magenta + style 16-54 dim + style 85-85 fg=bright-magenta +22| "Tool cards hid╰──────────────────────────────────────────────────────────────────────╯ " + style 0-13 dim + style 14-85 fg=bright-magenta +23| +24| "Tool and context cards collapsed. " + style 0-32 dim +25| +26| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +27| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +28-39| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index a6a73566d6..1ade76a5aa 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 92x32 buffer=normal length=37 base=5 viewport=5 +terminal 92x32 buffer=normal length=39 base=7 viewport=7 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=31 bufferRow=36 +cursor visible column=0 viewportRow=31 bufferRow=38 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,48 +29,52 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/exit — Exit after the active turn reaches idle " +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " + style 0-91 dim +15| "reasoning display " + style 0-16 dim +16| "/exit — Exit after the active turn reaches idle " style 0-46 dim -15| "/help — Show keyboard shortcuts and commands " +17| "/help — Show keyboard shortcuts and commands " style 0-43 dim -16| "/model [[provider/]model] — Show or switch this session's model " +18| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -17| "/palette — Show every color and attribute role this terminal renders " +19| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -18| "/quit — Exit after the active turn reaches idle " +20| "/quit — Exit after the active turn reaches idle " style 0-46 dim -19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -20| "/resume — List this workspace's resumable sessions " +22| "/resume — List this workspace's resumable sessions " style 0-49 dim -21| "/status — Show session diagnostics, system prompt, and registered tools " +23| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -22| "/skill: [instructions] — load a skill into the conversation " +24| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -23| -24| "provider stream failed after partial output " - style 0-42 fg=red 25| -26| "The previous process ended during this turn. " - style 0-43 fg=yellow +26| "provider stream failed after partial output " + style 0-42 fg=red 27| -28| "Turn stopped: the agent was disposed. " - style 0-36 fg=yellow +28| "The previous process ended during this turn. " + style 0-43 fg=yellow 29| -30| "Turn ended: plugin-policy. " - style 0-25 fg=yellow +30| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow 31| -32| "Unknown command: /unknown-advanced-command " - style 0-41 fg=yellow +32| "Turn ended: plugin-policy. " + style 0-25 fg=yellow 33| -34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +34| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +35| +36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -35| " dsh > " +37| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -36| +38| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index b8b62bca11..7da75336e3 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,7 +1,7 @@ -terminal 92x32 buffer=normal length=36 base=4 viewport=4 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=31 bufferRow=35 +cursor hidden column=7 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,47 +29,51 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/exit — Exit after the active turn reaches idle " +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " + style 0-91 dim +15| "reasoning display " + style 0-16 dim +16| "/exit — Exit after the active turn reaches idle " style 0-46 dim -15| "/help — Show keyboard shortcuts and commands " +17| "/help — Show keyboard shortcuts and commands " style 0-43 dim -16| "/model [[provider/]model] — Show or switch this session's model " +18| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -17| "/palette — Show every color and attribute role this terminal renders " +19| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -18| "/quit — Exit after the active turn reaches idle " +20| "/quit — Exit after the active turn reaches idle " style 0-46 dim -19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -20| "/resume — List this workspace's resumable sessions " +22| "/resume — List this workspace's resumable sessions " style 0-49 dim -21| "/status — Show session diagnostics, system prompt, and registered tools " +23| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -22| "/skill: [instructions] — load a skill into the conversation " +24| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -23| -24| "provider stream failed after partial output " - style 0-42 fg=red 25| -26| "The previous process ended during this turn. " - style 0-43 fg=yellow +26| "provider stream failed after partial output " + style 0-42 fg=red 27| -28| "Turn stopped: the agent was disposed. " - style 0-36 fg=yellow +28| "The previous process ended during this turn. " + style 0-43 fg=yellow 29| -30| "Turn ended: plugin-policy. " - style 0-25 fg=yellow +30| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow 31| -32| "Unknown command: /unknown-advanced-command " - style 0-41 fg=yellow +32| "Turn ended: plugin-policy. " + style 0-25 fg=yellow 33| -34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +34| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +35| +36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -35| " dsh > " +37| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt new file mode 100644 index 0000000000..d4da5ab39c --- /dev/null +++ b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt @@ -0,0 +1,46 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=20 bufferRow=20 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Inspecting the renderer first. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Refactor the renderer. " +10| "Model wait 0.0s · Completed 2026-07-29 22:30:00 " + style 0-46 dim +11| +12| "The renderer is sound; no refactor needed. " +13| "Model wait 0.0s · Completed 2026-07-29 22:30:00 " + style 0-46 dim +14| +15| "Tool and context cards expanded. " + style 0-31 dim +16| +17| "Tool cards hidden. " + style 0-17 dim +18| +19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +20| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +21-39| diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index 74cdd416d9..e5a625afc0 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -13,46 +13,41 @@ buffer 3| 4| "Assistant " style 0-8 fg=bright-magenta bold underline -5| -6| "You " +5| "Reasoning " + style 0-8 dim italic +6| "Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-61 dim italic +7| "Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +8| "Model wait 0.0s " + style 0-14 dim +9| +10| "You " style 0-2 fg=bright-magenta bold underline -7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -8| -9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +11| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +12| +13| "Assistant " + style 0-8 fg=bright-magenta bold underline +14| +15| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 0-81 fg=green -10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +16| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-59 dim -11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +17| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-52 dim -12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +18| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-58 dim -13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " +19| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " style 0-56 fg=red -14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " +20| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " style 0-46 dim -15| -16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +21| +22| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 0-61 dim -17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +23| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-59 dim -18| -19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +24| +25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-62 fg=red -20-21| -22| "Plan" - style 0-3 fg=bright-magenta bold -23| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" - style 2-2 fg=yellow -24| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" - style 0-17 fg=bright-magenta bold - style 18-31 dim - style 34-50 dim - style 53-57 dim - style 60-69 dim -25| " dsh > " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse 26| " " 27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 2-90 dim diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 13ddadb879..9d06acc984 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -44,6 +44,9 @@ const CHECKPOINTS = [ 'cordis-tools-pending', 'advanced-cards-collapsed', 'advanced-cards-expanded', + 'tool-cards-hidden-folded', + 'details-command', + 'details-selector', 'untrusted-controls', 'question-dialog', 'question-dialog-single-option', @@ -610,6 +613,71 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins the hidden phase folding a multi-step turn into one assistant message', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 29, 22, 30, 0).getTime()) + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + await renderAfter(harness, () => { + appendUser(harness.session, 'Refactor the renderer.') + appendAssistant(harness.session, [{ type: 'text', text: 'Inspecting the renderer first.' }]) + appendToolCalls(harness.session, [ + { id: 'fold-1', name: 'bash', arguments: { command: 'pnpm run test' } }, + ]) + appendToolResult(harness.session, 'fold-1', [{ type: 'text', text: 'all tests pass' }]) + harness.session.append('step/end', { turn: 1, step: 1 }) + harness.session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(harness.session, [{ type: 'text', text: 'The renderer is sound; no refactor needed.' }], undefined, { turn: 1, step: 2 }) + harness.session.append('step/end', { turn: 1, step: 2 }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + // collapsed -> expanded -> hidden: one Assistant header, no tool card. + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await checkpoint('tool-cards-hidden-folded', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) + }) + + it('pins /details jumping card visibility and reasoning display to named states', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 30, 18, 0, 0).getTime()) + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + await renderAfter(harness, () => { + appendUser(harness.session, 'Inspect the renderer.') + appendAssistant(harness.session, [ + { type: 'reasoning', text: 'The tool card and this block vanish under /details hidden reasoning off.' }, + { type: 'text', text: 'Running the check now.' }, + ]) + appendToolCalls(harness.session, [ + { id: 'details-1', name: 'bash', arguments: { command: 'pnpm run test' } }, + ]) + appendToolResult(harness.session, 'details-1', [{ type: 'text', text: 'all tests pass' }]) + harness.session.append('step/end', { turn: 1, step: 1 }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + await renderAfter(harness, () => { + harness.terminal.send('/details hidden reasoning off') + harness.terminal.send('\r') + }) + await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + // Bare /details opens the two-entry toggle seeded with the current + // hidden/reasoning-off state; one Tab immediately cycles tool cards + // hidden -> collapsed, so the frame pins the applied notice, the restored + // tool card behind the dialog, and the updated entry value together. + await renderAfter(harness, () => { + harness.terminal.send('/details') + harness.terminal.send('\r') + harness.terminal.send('\t') + }) + await checkpoint('details-selector', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) + }) + it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime()) const tools = { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e780f88624..d1797124ee 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -193,6 +193,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 76, modelDialogMaxHeight: 20, + detailsDialogWidth: 72, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -217,6 +218,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -233,6 +235,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -2915,6 +2918,94 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('/details sets card visibility and reasoning display from arguments', async () => { + const result = await setup() + const run = async (line: string): Promise => { + result.terminal.send(line) + result.terminal.send('\r') + await tick() + } + + await run('/details hidden') + expect(result.terminal.output).toContain('Tool cards hidden.') + + await run('/details expanded reasoning off') + expect(result.terminal.output).toContain('Tool and context cards expanded.') + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + await run('/details reasoning on') + expect(result.terminal.output).toContain('Reasoning blocks shown.') + + // Bare `reasoning` toggles: shown -> hidden. + const toggleOutput = result.terminal.output.length + await run('/details reasoning') + expect(result.terminal.output.slice(toggleOutput)).toContain('Reasoning blocks hidden.') + await run('/details collapsed') + expect(result.terminal.output.slice(toggleOutput)).toContain('Tool and context cards collapsed.') + + await run('/details bogus') + expect(result.terminal.output).toContain('Unknown /details argument "bogus"') + + await dispose(result) + }) + + it('bare /details opens the transcript-details toggle and Tab applies immediately', async () => { + const result = await setup() + const open = async (): Promise => { + const from = result.terminal.output.length + result.terminal.send('/details') + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.terminal.output.slice(from)).toContain('Transcript details') }) + return from + } + + const opened = await open() + expect(result.terminal.output.slice(opened)).toContain('Tool cards') + expect(result.terminal.output.slice(opened)).toContain('Reasoning') + + // A second /details while the selector is open replaces the overlay + // instead of stacking a second one behind it. + await result.ctx.commands.execute(result.agent, '/details', new AbortController().signal) + await tick() + + // Each Tab applies one step immediately while the dialog stays open: + // collapsed -> expanded -> hidden -> collapsed (wraparound). + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool and context cards expanded.') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool cards hidden.') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool and context cards collapsed.') + + // The reasoning entry toggles the same way. + result.terminal.send('\x1b[B') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + // Enter closes without further changes. + const entered = result.terminal.output.length + result.terminal.send('\r') + await tick() + expect(result.terminal.output.slice(entered)).not.toContain('Reasoning blocks') + + // Esc and Ctrl+C also close; the reopened dialog shows the live values. + const reopened = await open() + expect(result.terminal.output.slice(reopened)).toContain('collapsed') + expect(result.terminal.output.slice(reopened)).toContain('hidden') + result.terminal.send('\x1b') + await tick() + const ctrlCOutput = await open() + result.terminal.send('\x03') + await tick() + expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks') + + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() @@ -5395,6 +5486,134 @@ describe('tool cards and surface replay', () => { expect(mounted).not.toContain('stored model-only payload') await dispose(result) }) + + /** The last repainted frame, with CSI/OSC escapes and carriage returns stripped. */ + const lastFrame = (terminal: FakeTerminal): string => terminal.output + .slice(terminal.output.lastIndexOf('\x1b[2J')) + .replaceAll(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\r/g, '') + + const countAssistantHeaders = (frame: string): number => frame.split('\n') + .filter(row => row.trim() === 'Assistant').length + + /** One turn with text -> tool call/result -> text across two steps. */ + const appendTwoStepTurn = (session: Awaited>['session']): void => { + appendUser(session, 'fold me') + appendAssistant(session, [{ type: 'text', text: 'first step text' }]) + session.append('tool/call', { turn: 1, step: 1, callId: 'fold-1' as never, name: 'bash', arguments: '{}' }) + session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'fold-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(session, [{ type: 'text', text: 'second step text' }], undefined, { turn: 1, step: 2 }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + } + + it('folds a turn to one Assistant header in hidden mode and restores headers on cycle', async () => { + const result = await setup({ tools }) + appendTwoStepTurn(result.session) + await tick() + + // Collapsed (default): each step keeps its own header. + result.terminal.send('\x0c') + await tick() + expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2) + + // collapsed -> expanded -> hidden. + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + expect(countAssistantHeaders(hidden)).toBe(1) + expect(hidden).toContain('first step text') + expect(hidden).toContain('second step text') + expect(hidden).not.toContain('Tool / bash') + // The fold keeps model order: header text precedes the continuation. + expect(hidden.indexOf('first step text')).toBeLessThan(hidden.indexOf('second step text')) + + // hidden -> collapsed restores per-step headers. + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2) + await dispose(result) + }) + + it('gives the hidden-mode header to the first step with a visible body and keeps turns separate', async () => { + const result = await setup({ tools }) + // Turn 1, step 1 is tool-only; step 2 carries the turn's text. + appendUser(result.session, 'tool-only first step') + appendAssistant(result.session, [{ type: 'tool-call', id: 'only-1' as never, name: 'bash', arguments: '{}' }]) + result.session.append('tool/call', { turn: 1, step: 1, callId: 'only-1' as never, name: 'bash', arguments: '{}' }) + result.session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'only-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false, + }), + }, { surfaceOp: 'append' }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(result.session, [{ type: 'text', text: 'late turn-one text' }], undefined, { turn: 1, step: 2 }) + result.session.append('step/end', { turn: 1, step: 2 }) + result.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // Turn 2 keeps its own header. + result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendUser(result.session, 'next turn') + result.session.append('step/start', { turn: 2, step: 1 }) + appendAssistant(result.session, [{ type: 'text', text: 'turn-two text' }], undefined, { turn: 2, step: 1 }) + result.session.append('step/end', { turn: 2, step: 1 }) + result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + await tick() + + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + // One header per turn: the tool-only step neither renders a blank segment + // nor consumes turn one's header, which the late text step owns. + expect(countAssistantHeaders(hidden)).toBe(2) + expect(hidden).toContain('late turn-one text') + expect(hidden).toContain('turn-two text') + const rows = hidden.split('\n').map(row => row.trim()) + const turnOneHeader = rows.indexOf('Assistant') + expect(rows[turnOneHeader + 1]).toBe('late turn-one text') + await dispose(result) + }) + + it('folds live hidden-mode streaming once a later step shows text', async () => { + const result = await setup({ tools, status: 'running' }) + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live first' } }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'live second' } }) + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + expect(countAssistantHeaders(hidden)).toBe(1) + expect(hidden).toContain('live first') + expect(hidden).toContain('live second') + + // A transcript rebuild (resize) recomputes the same fold from the log. + result.terminal.resize(89) + await tick() + const rebuilt = lastFrame(result.terminal) + expect(countAssistantHeaders(rebuilt)).toBe(1) + expect(rebuilt).toContain('live second') + await dispose(result) + }) }) describe('TUI user-interaction dialogs', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7053f6738..0b634898ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1903,6 +1903,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4888,6 +4894,10 @@ importers: version: link:../../../vendor/cordis packages/subagent/subagent: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4910,6 +4920,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query diff --git a/tsconfig.host.json b/tsconfig.host.json index 6e0860317c..70f5854c69 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -33,6 +33,7 @@ "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/web-search-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", + "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", @@ -41,6 +42,7 @@ "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", + "apps/web/tests/bash-abort-row.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", diff --git a/vendor/README.md b/vendor/README.md index 3872faa753..c05a65e28f 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -42,6 +42,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. +13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic and the TUI's terminal state stranded. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply once. Covered by the `dsh` invalid-provider PTY case in `apps/cli/tests/tui-keyless-smoke.e2e.ts`. ## Sync procedure diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 65ce923dc3..2484d0152a 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -209,6 +209,14 @@ class Hmr extends Service { ...this.config, cwd: this.baseDir, ignored: path => match(relative(this.baseDir, path)), + // The initial scan re-announces files the boot just consumed: an `add` + // for a config file refreshes an include whose initial apply may still + // be in flight, and a failing apply then rolls this plugin back while + // the scan-triggered refresh waits on that apply — a teardown deadlock + // that strands boot without a diagnostic. Only events after the scan + // matter here; `registerConfig` keeps its own initial scan because a + // personal config present at registration must apply once. + ignoreInitial: true, }) // Collect externals: framework modules reachable from the main entry. diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index a13d273bc2..4a9fd6be86 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -171,6 +171,7 @@ export class Include extends EntryTree { private content?: string private data?: EntryOptions[] private writeTask?: NodeJS.Timeout + private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { super(ctx) @@ -186,12 +187,29 @@ export class Include extends EntryTree { ctx.on('internal/update', async (config, _, next) => { if (config.path !== this.config.path) return next() - const data = this.applyPatches(this.data!, config.patches) - await this.root.update(data) - this.config = config + await this.enqueue(async () => { + const data = this.applyPatches(this.data!, config.patches) + await this.root.update(data) + this.config = config + }) }) } + /** + * Serialize one child-tree mutation behind every earlier one. The group's + * transactional `update` is not reentrant: two concurrent applies (the init + * apply racing an HMR-triggered refresh from the watcher's initial scan) + * interleave create and rollback on the same entries and strand the include + * fiber without settling, so every apply path funnels through this queue. + * A predecessor's failure is its own caller's outcome and never gates the + * next task. + */ + private enqueue(task: () => Promise): Promise { + const run = this.applyQueue.then(task, task) + this.applyQueue = run.then(() => {}, () => {}) + return run + } + private async checkAccess() { if (!this.type) return try { @@ -262,12 +280,20 @@ export class Include extends EntryTree { * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds. */ async refresh() { - const candidate = await this.read() - if (!candidate) return - await this.apply(candidate) + // Read inside the queue so the changed-content check compares against the + // predecessor's committed state, not a mid-apply snapshot. + await this.enqueue(async () => { + const candidate = await this.read() + if (!candidate) return + await this._apply(candidate) + }) } - private async apply(candidate: ReadCandidate) { + private apply(candidate: ReadCandidate) { + return this.enqueue(() => this._apply(candidate)) + } + + private async _apply(candidate: ReadCandidate) { const data = this.applyPatches(candidate.data, this.config.patches) await this.root.update(data) this.content = candidate.content