diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml similarity index 65% rename from .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml index a96ce55a01..725855f6c3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.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/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md -2026-07-31-composer-text-layers-share-one-scrollport.md: 4989ab3bcedcf0ea29b95b565b2e0756ab9d5b62 -2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 95b6d3055775f733f6c700823ce307869d1c0961 +2026-07-31-composer-text-layers-share-one-scrollport.md: eb50673bb5fac50e12b0325c22c67072e130efb6 +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: f0af130d34682fcdfe145eb73b18187ca316c0d2 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md similarity index 97% rename from .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md rename to .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md index 4989ab3bce..eb50673bb5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +++ b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md @@ -1,6 +1,7 @@ # Agent Note: The composer's two text layers share one scrollport Status: implemented +Archived: 2026-08-20 English | [中文](2026-07-31-composer-text-layers-share-one-scrollport.zh.md) @@ -48,7 +49,7 @@ Revealing the caret is the one thing that now depends on the browser rather than **Scroll both layers from JavaScript, with the textarea `overflow: hidden` and a wheel handler assigning both offsets in one task.** No divergence during wheel gestures, since nothing scrolls without us. Rejected because it replaces native scrolling — momentum, trackpad rubber-banding, scrollbar dragging, keyboard scrolling — with a hand-written approximation, and the caret-reveal path (the browser setting the textarea's own offset) still lands asynchronously. -**Keep the cap on the mirror and just wrap the existing structure in a scroller.** The layers would stay window-sized, not draft-sized: `inset: 0` on an absolutely positioned child resolves against the scrollport's padding box, not its scrollable overflow area, so both layers would scroll away from the content that is supposed to be underneath them. The stack has to be the full draft height for the arrangement to mean anything. +**Keep the cap on the mirror and just wrap today's structure in a scroller.** The layers would stay window-sized, not draft-sized: `inset: 0` on an absolutely positioned child resolves against the scrollport's padding box, not its scrollable overflow area, so both layers would scroll away from the content that is supposed to be underneath them. The stack has to be the full draft height for the arrangement to mean anything. **Give the backdrop `overflow: auto` and let it scroll itself.** It would then have an offset of its own to keep in step, which is the same problem plus a second scrollbar painted over the input. The backdrop is a projection of the textarea, not an independently navigable surface. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md similarity index 97% rename from .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md rename to .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md index 95b6d30557..f0af130d34 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md @@ -1,6 +1,7 @@ # Agent Note: composer 的两层文本共用同一个滚动容器 Status: implemented +Archived: 2026-08-20 [English](2026-07-31-composer-text-layers-share-one-scrollport.md) | 中文 @@ -48,7 +49,7 @@ Safari 的原生文本控件存在一个引擎例外:跨过软换行阈值的 **两层都由 JavaScript 驱动滚动:textarea 设 `overflow: hidden`,滚轮处理器在同一个任务里给两个偏移赋值。** 滚轮手势期间不会分离,因为没有我们就没有东西会滚动。被否决,是因为它用手写近似替换了原生滚动——惯性、触控板回弹、拖拽滚动条、键盘滚动——而且光标回视路径(浏览器设置 textarea 自己的偏移)仍然是异步落地的。 -**把上限留在镜像层上,只在既有结构外面套一个滚动容器。** 那样两层仍是「窗口大小」而非「草稿大小」:绝对定位子元素的 `inset: 0` 是相对滚动容器的 padding box 解析的,而不是相对其可滚动溢出区域,于是两层会从本该垫在它们下面的内容上滚开。栈必须与整份草稿等高,这套排布才有意义。 +**把上限留在镜像层上,只在今天的结构外面套一个滚动容器。** 那样两层仍是「窗口大小」而非「草稿大小」:绝对定位子元素的 `inset: 0` 是相对滚动容器的 padding box 解析的,而不是相对其可滚动溢出区域,于是两层会从本该垫在它们下面的内容上滚开。栈必须与整份草稿等高,这套排布才有意义。 **给 backdrop 加 `overflow: auto`,让它自己滚动。** 那样它就有了一个自己的偏移需要保持同步,即同一个问题再加一条画在输入框上的滚动条。backdrop 是 textarea 的投影,不是一个可独立导航的界面。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml similarity index 67% rename from .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml index ad6629e43f..804fdb4fb7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.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/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md -2026-08-13-safari-textarea-soft-wrap-reflow.md: fb264a8e6fbe24369584f2427bbb0c462b450ecf -2026-08-13-safari-textarea-soft-wrap-reflow.zh.md: f4cdf9b38ca8ca093cf388973d40e652d76a2f27 +2026-08-13-safari-textarea-soft-wrap-reflow.md: 45cb3f39c50c72b44b8ae952ce3a861210e9f00a +2026-08-13-safari-textarea-soft-wrap-reflow.zh.md: 37409b010c0009edf3c944076ba8c3db1b140de9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md rename to .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md index fb264a8e6f..45cb3f39c5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md +++ b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md @@ -1,6 +1,7 @@ # Agent Note: Safari textarea soft-wrap shrink recovery Status: implemented +Archived: 2026-08-20 English | [中文](2026-08-13-safari-textarea-soft-wrap-reflow.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md rename to .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md index f4cdf9b38c..37409b010c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md @@ -1,6 +1,7 @@ # Agent Note: Safari textarea 软换行收缩恢复 Status: implemented +Archived: 2026-08-20 [English](2026-08-13-safari-textarea-soft-wrap-reflow.md) | 中文 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml similarity index 66% rename from .agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml index 51652f6906..0e622c3944 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.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/bug-fix/2026-08-20-composer-edit-range-from-selection.md -2026-08-20-composer-edit-range-from-selection.md: f836fe4de297746d35f7343cd215e8522a0116d0 -2026-08-20-composer-edit-range-from-selection.zh.md: 86e65e4567c6d061b458e77be856aa1942cf0fe3 +2026-08-20-composer-edit-range-from-selection.md: 46eaa0add61bdab9fdcb4fcfd0ec08b44481126d +2026-08-20-composer-edit-range-from-selection.zh.md: 73a3903ad6c7c0aad55a35aacc5e1396084b6e7a diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md rename to .agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.md index f836fe4de2..46eaa0add6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.md @@ -1,6 +1,7 @@ # Agent Note: Composer edits carry the range they applied to Status: implemented +Archived: 2026-08-20 English | [中文](2026-08-20-composer-edit-range-from-selection.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md similarity index 96% rename from .agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md rename to .agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md index 86e65e4567..73a3903ad6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md @@ -1,6 +1,7 @@ # Agent Note: 输入框的编辑自带它所作用的范围 Status: implemented +Archived: 2026-08-20 [English](2026-08-20-composer-edit-range-from-selection.md) | 中文 @@ -14,7 +15,7 @@ Status: implemented 此时草稿看上去仍然正确,却已不携带任何结构化引用,提交走的是无 occurrence 的那条路,把草稿原样发出。宿主收到的是给人看的标签而不是所有者的模型形式,什么也解析不出来。专为阻止这种降级而存在的序列化守卫从不运行,因为它只在还有 occurrence 需要序列化时才触发。 -这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.zh.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。 +这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。 ## 决策 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml similarity index 66% rename from .agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml index a2996211d9..95cb9c6827 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.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/bug-fix/2026-08-20-composer-reference-decoration-keys.md -2026-08-20-composer-reference-decoration-keys.md: db565e89e1c8addcd1669e295b3be4433083d6bd -2026-08-20-composer-reference-decoration-keys.zh.md: 7af596189ca90890cb81d099f77ae86e2a62e532 +2026-08-20-composer-reference-decoration-keys.md: 316d45841c658d3d65246fb7425526b10e2f6bf3 +2026-08-20-composer-reference-decoration-keys.zh.md: 90ac7c8011bb7f7f45312c25ffccb7dbbbb70505 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.md b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.md rename to .agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.md index db565e89e1..316d45841c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.md +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.md @@ -1,6 +1,7 @@ # Agent Note: Composer reference decorations key by draft-order ordinal Status: implemented +Archived: 2026-08-20 English | [中文](2026-08-20-composer-reference-decoration-keys.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md rename to .agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md index 7af596189c..90ac7c8011 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md @@ -1,6 +1,7 @@ # Agent Note: 输入框引用装饰按草稿顺序序号取 key Status: implemented +Archived: 2026-08-20 [English](2026-08-20-composer-reference-decoration-keys.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index f15182f0bc..479a150d56 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -88,6 +88,9 @@ "bug-fix/2026-07-30-web-details-default-closed.i18n.yaml": "sha256:2af5559d727f3e4afdd4946eaf89ac212c81db611db78dbd9bfabb1c4661db17", "bug-fix/2026-07-30-web-details-default-closed.md": "sha256:27a280a817c8048718bb22927e7d9572cf99ffd0c044631e99e0fd6ea236876f", "bug-fix/2026-07-30-web-details-default-closed.zh.md": "sha256:e047c7d02cf4b95b0c7f78f4b79af254091294b05cc75e98a8bb860ae2074189", + "bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml": "sha256:36fc626dcbf1e276a36713e85860752cef0b36a5881f2493bdeb6d9654621b02", + "bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md": "sha256:3ece47f91ee5f7354ef73ca0562aafeec19f89a19d9a64c9e0a565fe6d8c2049", + "bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md": "sha256:578e772ecbc1a4a39bddbb0a9f3fdbf67c70ff8c8952cc80d2caa3d8b76e9b36", "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml": "sha256:42218a762ce0141d3cb43deb6c688d3705cdc4405e03851d486c78f3d25b70ef", "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md": "sha256:a40992e89736131f5c487e5357848f14accd06e135dbec9ce242c968a5b11d43", "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md": "sha256:e0cc576bc1c196affc9220ddabf15d735c347029c530c56454f0e585979101e1", @@ -112,6 +115,15 @@ "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.i18n.yaml": "sha256:3ce4f6e39e173fc304bf64deca9c95bcddc1dbb492e065ca8c267a7a40788588", "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.md": "sha256:7b169aa4543edfc965de5a8b7b9e60aa9d9d5218693cd0b57908e2d482280723", "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md": "sha256:88db36c698800bf55c3c7531d6f92665576d978c29c15ff7d74215fb93376cb1", + "bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml": "sha256:23c26323f92a2172fd30fd724177b84d012cf4e18f1eff79ab092d4e0687ad4e", + "bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md": "sha256:f9edea8501df36d444d84790ef9b0ae5bed4283a9cc5a5403800b80908f3db39", + "bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md": "sha256:ddcf6bb67823d19dc98964fcb9d663a10bcf663573394cfd7b235d9801d6525a", + "bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml": "sha256:c91ed2d9cb2a9891011fcbbe46885bc1808e36831279d56bc5cea0b9b1515b55", + "bug-fix/2026-08-20-composer-edit-range-from-selection.md": "sha256:e36920dee0318a35eaf49bff8c574698902f3be51d6115d40a3f97fa1436bd47", + "bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md": "sha256:41f44adc93797cf9073f19f954b6ac87147a2e6806f1ad051c80c3423f0175ae", + "bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml": "sha256:cadf1de336aa2756d1bc1c20c7679449390b0a7a4217fe9602996801b6bc1958", + "bug-fix/2026-08-20-composer-reference-decoration-keys.md": "sha256:0093eabd710f10ae1faca53be01c9404a9d63cf6a2cf4dbf226e458e6315e201", + "bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md": "sha256:a5fb2a748cf6ff8353d536448a5469e731157ccc2d0bb43210ea5dc44dd8ed31", "bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml": "sha256:f7a20bddd4544738ec0dbbfc52ea931f42317defa1674beb9a3c0daebd52fc2d", "bug-fix/2026-08-24-system-prompt-section-order-ties.md": "sha256:108a97346eb7a62f1ab01f48dbb9fdd965e8991f53e382b0f501b916af0e9e23", "bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md": "sha256:3deaddfcf9736b3ff8d61b51093d7e46fdcc86103705033e4aa4c9d043794b16", diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index dac9ce6b5c..98850aed4c 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.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-06-11-content-block-vocabulary.md -2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b -2026-06-11-content-block-vocabulary.zh.md: da387b179816cda64791e71ca7affa1fbdfd195b +2026-06-11-content-block-vocabulary.md: d7d3f6b43a3f65d1421f026e5b6c2cc1ba1eadd2 +2026-06-11-content-block-vocabulary.zh.md: ed4f915dff6dcb6dbc91400f9bfa5384253aea7b diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md index a31df6a7d1..d7d3f6b43a 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -25,4 +25,4 @@ In-session context injection (`context/message`) and mid-turn steering originall - Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md). - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Notes. - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. -- IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. +- IDs that cross package boundaries are branded (`ToolCallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index da387b1798..ed4f915dff 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -25,4 +25,4 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 - 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md)。 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 -- 跨包边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 +- 跨包边界的 ID 使用品牌类型(`ToolCallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index d929b209e3..1e1545bb12 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.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-06-20-branded-ids.md -2026-06-20-branded-ids.md: dda97bbf546ef99083cbe3bd2c7da39070407e04 -2026-06-20-branded-ids.zh.md: 82b79dff9d5018e2ea9f9969148eb25225f6d727 +2026-06-20-branded-ids.md: 6443608c76fe42be74a2b8fe8a27669b09951a49 +2026-06-20-branded-ids.zh.md: f13d999aadf4dba7f2c7d31bb2739deae4a0991f diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md index dda97bbf54..6443608c76 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md @@ -6,13 +6,13 @@ English | [中文](2026-06-20-branded-ids.zh.md) ## Problem -The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker. +The harness brands `ToolCallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker. **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-job id is a plain `string`: `BashTask.id: string` (`packages/shell/shell/src/types.ts`), carried as `string` through the whole executor seam (`ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/shell/shell/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateJobId`, `assertTaskAccess`, the `job_id` schema arg in `packages/shell/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/shell/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash job id and a session id are trivially swappable at a call site and the compiler says nothing. It is a model-facing id (the model passes `job_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. The bash **owner token** is the related sub-case: `ShellExecRequest.owner?: string` and `ShellExecSpec.owner: string | undefined` (`packages/shell/shell/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's shared `Agent.id`/`SessionId` (`callerToken = (exec) => exec.agent?.id` in `packages/shell/tool-bash/src/index.ts`) wearing a different seam-local name. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the shared id alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md). -**Gap 2 — brand erosion at the boundaries of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), tool-presentation call-id maps, ACP's session records, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. +**Gap 2 — brand erosion at the boundaries of the *already-branded* IDs.** Even `ToolCallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), tool-presentation call-id maps, ACP's session records, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. ## Decision @@ -22,7 +22,7 @@ A type-only change. Brands are zero-cost casts; nothing about runtime behavior, - **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/shell/shell/src/types.ts`; type `ShellExecRequest.owner` / `ShellExecSpec.owner` / `ShellExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's shared `id` (`SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash Service Definition never imports `dsh-session`. (Rationale in the next section.) -- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the change and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. +- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the change and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. Illustrative shape (the factory pattern is identical to the three existing brands): @@ -60,7 +60,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Verification -The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-shell` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing tool) with no `dsh-shell` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `job_id`), never as scattered `as` casts. +The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-shell` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing tool) with no `dsh-shell` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`ToolCallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `job_id`), never as scattered `as` casts. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md index 82b79dff9d..f13d999aad 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 仍能通过类型检查器。 +harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `ToolCallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 仍能通过类型检查器。 **缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 job id 是普通 `string`:`BashTask.id: string`(`packages/shell/shell/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/shell/shell/src/index.ts` 中的 `ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateJobId`、`assertTaskAccess`、`packages/shell/tool-bash/src/index.ts` 中 `job_id` 的 schema 参数)。它由每执行器计数器生成——`packages/shell/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash job id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。它是面向模型的 id(模型会把 `job_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 和 `ShellExecSpec.owner: string | undefined`(`packages/shell/shell/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/shell/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)覆盖的共享 id 别名。 -**缺口 2:*已经 brand* 的 ID 在边界处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP(Agent Client Protocol)的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 +**缺口 2:*已经 brand* 的 ID 在边界处被侵蚀。** 就连 `ToolCallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP(Agent Client Protocol)的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 ## 决策 @@ -22,7 +22,7 @@ bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` - **铸造独立的 `OwnerToken` brand。** 在 `packages/shell/shell/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `ShellExecRequest.owner` / `ShellExecSpec.owner` / `ShellExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 共享的 `id`(`SessionId`)cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash Service Definition 从不导入 `dsh-session`。(理由见下一节。) -- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map`、`Map`、`get(id: SessionId)`、`Map`、ACP 的 `SessionId` surface、协调器的 `Map`。这是变更中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。 +- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map`、`Map`、`get(id: SessionId)`、`Map`、ACP 的 `SessionId` surface、协调器的 `Map`。这是变更中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。 示意形状(工厂模式与已有的三个 brand 完全一致): @@ -60,7 +60,7 @@ export function OwnerToken(id: string): OwnerToken { ## 验证 -已落地的不变式如下:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-shell` 中,并端到端贯穿 Service Definition、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的工具,且 `dsh-shell` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`CallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界(提供方 call id、ACP 会话 id、模型提供的 `job_id`)都通过 cast 工厂构造 brand,而不是散落的 `as` cast。 +已落地的不变式如下:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-shell` 中,并端到端贯穿 Service Definition、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的工具,且 `dsh-shell` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`ToolCallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界(提供方 call id、ACP 会话 id、模型提供的 `job_id`)都通过 cast 工厂构造 brand,而不是散落的 `as` cast。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 7b145bd906..f1135aa2b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.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-08-tool-output-spill-files.md -2026-07-08-tool-output-spill-files.md: e14607e388c634c4e2679c993c1b720be0a3a9f3 -2026-07-08-tool-output-spill-files.zh.md: 372c9c6cadf3cd64c3de97a8c305b8909f03caab +2026-07-08-tool-output-spill-files.md: 915e22f1245adb6f7cfc7d358e9d5802531bab63 +2026-07-08-tool-output-spill-files.zh.md: 8d08b05483a302f4188506531da6f507931bea9c diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index e14607e388..915e22f124 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -35,7 +35,7 @@ interface SpillStore { interface SpillSource { toolName: string - callId: CallId + callId: ToolCallId label: string } diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md index 372c9c6cad..8d08b05483 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -35,7 +35,7 @@ interface SpillStore { interface SpillSource { toolName: string - callId: CallId + callId: ToolCallId label: string } diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 103ded6ad6..6e66ed20f5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 1dea7fcff9882f701a3335687c8a2ab8a47e2dfb -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: dda9a43c41fc9bec4c26b19c2349f07ec4fc6a31 +2026-07-25-web-input-machine-and-slash-pipeline.md: 3508de5e8a3980a87c344c5b76c060f6119ee686 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: eebfdae780157dfd0dace1386169c5fee8c1d564 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 1dea7fcff9..3508de5e8a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-25-web-input-machine-and-slash-pipeline.zh.md) -> Scope: the input state machine (the occurrence table + claim watch + the submit transaction), the hub/facade and send orchestration, the three scoped bail events for cross-plugin input rewrites, `/` and `@` trigger detection and the menu pipeline (ui-input-trigger), and the slot system around the composer. It depends on the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md)'s sctx / provide / session-maybe and blank entity model; command knowledge (the three kinds, the directory, popups) is untouched here — that is the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md)'s territory. +> Scope: the input state machine (the claim watch + the submit transaction), the hub/facade and send orchestration, the three scoped bail events for cross-plugin input rewrites, `/` and `@` trigger detection and the menu pipeline (ui-input-trigger), and the slot system around the composer. It depends on the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md)'s sctx / provide / session-maybe and blank entity model; command knowledge (the three kinds, the directory, popups) is untouched here — that is the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md)'s territory. The editing surface half of the original decision — the textarea + occurrence table, the self-managed undo log, the paste-match pipeline, and the backdrop decorations — was superseded by [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md); this note remains the owner of the machine's submit plane, the bail-event contract, the trigger pipeline, and the composer slots. ## Problem @@ -21,32 +21,17 @@ Hard constraints: components mount through slots only; presentation artifacts ne ## Decision -### The input state machine (`InputMachine`) +### The input state machine (now `SubmitMachine`) A pure state machine, events in / effects out, clock injected. Four phases (plain / adjudicating / claimed / submitting). Command mode is **never derived from the draft**; the pick paths establish it explicitly at discrete moments; the claim is watched by `draft.startsWith(token)`, with a backspace break releasing automatically; the claim shape is `{token, hint?}` (hint feeds ghost text). -The event surface (`dispatch(ev)` is the single write entry; one transaction per event): +The machine's editing half — the occurrence table, the self-managed undo log, the paste-match attempt pipeline, and `set-invalid` — moved into the Lexical document tree and its history ([the Lexical composer note](2026-08-20-web-composer-lexical-editor.md) owns document identity, position mapping, and the three projections). What remains here is the submit plane, unchanged in semantics: -- `draft-changed {draft, editRange?}` — the textarea's full draft; editRange narrows the occurrence-shift computation, defaulting to a shared prefix/suffix scan. -- `newline {selection}` — the Ctrl+Enter line break (not via the browser's execCommand: under self-managed undo a browser write forks two histories). -- `begin-command {claim, span}` / `insert-ref {reference, span}` / `consume-token {guard}` — the machine side of the three bail events; span CAS = draftRev equality. -- `set-invalid {invalidIds}` — the style bit for owner-resolution results (not a transaction). -- `undo` / `redo` — the self-managed transaction log (a ring of 100; single-character typing merges within injected-clock windows; a successful submit clears the log). -- `paste-begin {text, selection, components?, generation?}` — the paste plus hot-snapshot synchronously matched components in one transaction (one Undo returns to before the paste); opens a PasteMatchAttempt. -- `paste-upgrade {attemptId, span, reference}` — an asynchronous match upgrade as its own transaction (Undo in two steps); the attempt stays current, and insertedRange shrinks with each upgrade. -- `invalidate-paste` — attempt-ending gestures observed at the DOM layer (caret/selection operations and the like). -- `enter {mode}` / `adjudicated` / `adjudication-failed` / `submit-settled` / `release` — the submit-transaction plane: a SubmitAttempt (seq + AbortSignal) blocks backwash; success commits and clears the draft; failure rolls back under the drift guard (the enter-time snapshot is backfilled only while the live draft still equals it; if the user has typed again, only a notice fires). +- `draft-changed {draft}` — the clipboard projection of the editor document, feeding the claim watch. +- `claim {claim}` / `release` and the span-CAS rewrite events applied at the editor layer — the machine side of the bail events; span CAS = draftRev equality. +- `enter {draft}` / `adjudicated` / `adjudication-failed` / `submit-settled {draft}` / `send-committed` — the submit-transaction plane: a SubmitAttempt (seq + AbortSignal) blocks backwash; success commits and clears the draft prefix; failure rolls back under the drift guard (the enter-time snapshot is backfilled only while the live draft still equals it; if the user has typed again, only a notice fires). -The effect surface (executed by the shell): `adjudicate` (calls InputTriggerController.adjudicate), `begin-submit` (the claim.submit transaction), `default-sink` (ordinary messages, hub-orchestrated), `notice`. - -The occurrence table and the chip's three projections: - -- Each reference occupies one `U+FFFC` in the draft; a table entry is `{occurrenceId, source, ref, offset, label, clipboardText, invalid?}`; same-named chips stay independent through occurrenceId. -- Every edit updates the draft and the table in one transaction: ranges shift; a deletion/replacement intersecting a placeholder acts on the whole chip. -- The single-character placeholder makes keyboard atomicity mostly hold natively (the caret has no interior position; Backspace / arrow keys / Shift extension natively take the whole chip); a mouse click on a chip goes backdrop hit → whole-chip setSelectionRange. -- The visual projection = label: the backdrop renders the chip at the placeholder offset (the textarea glyph is invisible), with invalid taking the invalid style. -- The clipboard/persistence projection = clipboardText: copy/cut expands placeholders inside the selection; the draft-persistence mirror writes the same projection (the chat store always holds plain text; the refresh seed semantics = select-all copy → reopen → paste, with chips degrading to text across a refresh). -- The model projection = generated per chip at submit through the source's `codec.serialize` (owned by the submit attempt's signal and stale guard; a missing owner / failure / cancel means no send, never a downgrade to `/name`). +The effect surface (executed by the shell): `adjudicate` (calls InputTriggerController.adjudicate), `begin-submit` (the claim.submit transaction), `default-sink` (ordinary messages, hub-orchestrated), `commit-draft` (prefix removal with suffix retention), `notice`. ### Cross-plugin input rewrites: three scoped bail events @@ -56,21 +41,21 @@ The contract is declared in ui-input-trigger (the bottom of the dependency chain - `slash/input-insert-reference` `{reference, span}` — reference chip insertion (dispatched by the InputTriggerController). - `slash/input-consume-token` `{guard: span | bare-token}` — consuming the command token after business success (dispatched by the downstream command surfaces). -Calls that stay un-evented (registry registration → explicit call → await): Input's own draft/submit, asynchronous Enter adjudication, the reference serializer, the asynchronous paste matcher. `@mode bail` has entered the JSDoc parser and the cordis catalog gate (scripts/jsdoc.ts). +Calls that stay un-evented (registry registration → explicit call → await): Input's own draft/submit, asynchronous Enter adjudication, the reference serializer. `@mode bail` has entered the JSDoc parser and the cordis catalog gate (scripts/jsdoc.ts). ### The slash pipeline (ui-input-trigger: a root `InputTriggerService` + a per-session `InputTriggerController`) A trigger/menu/pick pipeline with zero knowledge of "commands": - The service holds only the source registry (`InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects). -- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's textarea selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. +- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the composer surface, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's composer selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. - Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core. ### hub / facade: the resident shell and the strict-session input body - The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-input-trigger or the command surfaces, input still sends and receives normally — graceful degradation. - Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears. -- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. +- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the composer surface DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. - ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`: a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. - Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current. @@ -82,14 +67,14 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes. - Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast. -- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan. +- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. - Sending is the literal text (no more `` serialization); on the bubble side MessageItem decorates both shapes (the legacy `` tag + plain-text tokens). -- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Decoration reactivity: InputBar subscribes to the shell's lexicon source (uSES), so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render. +- Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render. ### Per-session provide contributions and the private keyboard surface - ui-conversation (the hub doubling as a contributor) supplies through `sessions.provide` the `'input'` hook (machine state + the queue overlay) plus the `inputActions` prop (`setDraft`/`submit`, stable void callbacks). -- The public/private boundary: the public provide carries only React-vocabulary members; the keyboard/DOM command surface (track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror — synchronous return values, disposer semantics) is InputBar-exclusive, passed privately in-package through the InputBar entry's own inject, never leaving the plugin boundary. +- The public/private boundary: the public provide carries only React-vocabulary members; the keyboard/DOM command surface (`ComposerKeyboard`: the shell-owned editor, arbitrate/space/paste/dismissPopup/caretSpan — synchronous return values, disposer semantics) is InputBar-exclusive, passed privately in-package through the InputBar entry's own inject, never leaving the plugin boundary. ### The slot system @@ -97,7 +82,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - `conversation.session.header` (single) — strict-session breadcrumb, view tabs, and header actions above the resident scrollport. - `conversation.session` (single) — the strict-session view ring and draft mirror inside the resident scrollport. Header and body share the same session-scoped chat store; each is rebuilt when the session id switches. -- `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking textarea DOM survival. +- `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking composer-surface DOM survival. - `conversation.input.overlay` — the floating-overlay anchor inside the input card; registrants' inject resolves each one's own per-session controller by the slot sessionId. - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. - `conversation.composer.dock` — the stats band on the composer's top edge. @@ -116,9 +101,9 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ | An ActiveCommand intermediate state / a registerMode mode registry / deriving command mode from the draft | Claims are established explicitly by the pick paths — no table, no derivation | | Direct bindTarget/bindDraft object wiring | Reverse coupling plus root-singleton cross-session mispairing; scoped bail events preserve dependency inversion with structurally correct routing | | A unified slash/input-apply, or eventing everything | Three independent payloads cover the cross-plugin rewrites; asynchronous paths stay registry-based explicit calls | -| contenteditable / a rich-text tree | Poor compatibility; textarea + U+FFFC + the occurrence table covers the full interaction contract | +| contenteditable / a rich-text tree | Poor compatibility; textarea + U+FFFC + the occurrence table covers the full interaction contract — reversed later by [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md) once the styling and identity costs of the textarea stack were measured | | Dual draft persistence {text, occurrences} | The mirror writing the clipboard projection adds zero new concepts; chip degradation across refresh is acceptable | -| The native textarea undo stack | Unreliable under controlled + programmatic writes; the paste two-step undo semantics can only be self-managed | +| The native textarea undo stack | Unreliable under controlled + programmatic writes; the paste two-step undo semantics can only be self-managed — both sides retired with the textarea itself; Lexical's history owns undo now | | The InputBar receiving a 16-member wiring-callback bundle | The consumption matrix proved 11 members InputBar-exclusive and 1 a dead member; the standard-kit channel lets components fetch their own, with the keyboard surface passed privately in-package | | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only | | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning | @@ -129,7 +114,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ ## Consequences -- One resident conversation shell carries no-session/blank/active: no session → blank preserves ConversationRoot, Hero, the root-scoped Workspace picker, scrollport, composer seat, InputBar, and textarea; only the strict header and body outlets gain content. The same blank session → engaging/active also keeps the InputBar and textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. +- One resident conversation shell carries no-session/blank/active: no session → blank preserves ConversationRoot, Hero, the root-scoped Workspace picker, scrollport, composer seat, InputBar, and the composer surface; only the strict header and body outlets gain content. The same blank session → engaging/active also keeps the InputBar and the composer surface. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. - The input surface's zero knowledge of commands plus optional dependencies: pure input works without the command packages; `@` references and skill references get free reuse of the same menu/pick pipeline. The cost is that space/enter adjudication is a per-source polling protocol whose answer semantics (sync/async, the meaning of undefined) are a frozen contract. - Transactionalized submission (attempt seq + the drift guard) makes the three defect classes — stale-result backwash, session switching, concurrent replay — structurally impossible, pinned by the matrix tests. -- Known gaps: chip fidelity across refresh (paste matching is reusable for it) has no workstream yet; the subagent reference's model representation awaits its business workstream. +- Known gaps: chip fidelity across refresh has no workstream yet; the subagent reference's model representation awaits its business workstream. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index dda9a43c41..eebfdae780 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-25-web-input-machine-and-slash-pipeline.md) | 中文 -> 范围:输入状态机(occurrence 表 + claim 看护 + 提交事务)、hub/facade 与发送编排、跨插件输入改写的三个 scoped bail 事件、`/` 与 `@` 触发检测与菜单流水线(ui-input-trigger)、composer 周边 slot 体系。依赖[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)的 sctx / provide / session-maybe 与 blank 实体模型;命令知识(三型、目录、popup)零涉——那是[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)的领地。 +> 范围:输入状态机(claim 看护 + 提交事务)、hub/facade 与发送编排、跨插件输入改写的三个 scoped bail 事件、`/` 与 `@` 触发检测与菜单流水线(ui-input-trigger)、composer 周边 slot 体系。依赖[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)的 sctx / provide / session-maybe 与 blank 实体模型;命令知识(三型、目录、popup)零涉——那是[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)的领地。原决策的编辑面一半——textarea + occurrence 表、自管 undo log、粘贴匹配流水线与 backdrop 装饰——已由 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md) 取代;本 note 仍拥有状态机的提交平面、bail 事件约定、触发流水线与 composer slot。 ## 问题 @@ -21,32 +21,17 @@ Status: implemented ## 决策 -### 输入状态机(`InputMachine`) +### 输入状态机(现为 `SubmitMachine`) 纯状态机,事件进/效果出,注入时钟。四相 phase(plain / adjudicating / claimed / submitting)。命令态**永不从 draft 推导**,由 pick 路径在离散时刻显式建立;claim 由 `draft.startsWith(token)` 看护、退格破坏自动 release;claim 形状 `{token, hint?}`(hint 供 ghost text)。 -事件面(`dispatch(ev)` 单写入口,每个事件一个 transaction): +机器的编辑面一半——occurrence 表、自管 undo log、粘贴匹配 attempt 流水线与 `set-invalid`——已并入 Lexical 文档树及其 history(文档身份、位置映射与三投影归 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md) 所有)。留在这里的是语义不变的提交平面: -- `draft-changed {draft, editRange?}`——textarea 全量草稿;editRange 缩小 occurrence 平移计算,缺省前后缀共扫。 -- `newline {selection}`——Ctrl+Enter 换行(不经浏览器 execCommand:自管 undo 下浏览器写入会分叉双历史)。 -- `begin-command {claim, span}` / `insert-ref {reference, span}` / `consume-token {guard}`——三个 bail 事件的机器侧;span CAS = draftRev 相等。 -- `set-invalid {invalidIds}`——owner resolution 结果的样式位(非 transaction)。 -- `undo` / `redo`——自管 transaction log(容量为 100 的环形缓冲区;单字符打字按注入时钟窗合并;提交成功清 log)。 -- `paste-begin {text, selection, components?, generation?}`——粘贴 + 热快照同步匹配组件同 transaction(Undo 一次回粘贴前);打开 PasteMatchAttempt。 -- `paste-upgrade {attemptId, span, reference}`——异步匹配升级为独立 transaction(Undo 两段);attempt 保持 current,insertedRange 随升级收缩。 -- `invalidate-paste`——DOM 层观察到的 attempt 终结手势(caret/selection 操作等)。 -- `enter {mode}` / `adjudicated` / `adjudication-failed` / `submit-settled` / `release`——提交事务平面:SubmitAttempt(seq + AbortSignal)防回灌,成功 commit 清稿,失败带漂移守卫 rollback(回车时快照仅当 live draft 仍等于它才回填;用户已再输入则只发 notice)。 +- `draft-changed {draft}`——编辑器文档的剪贴板投影,供 claim 看护。 +- `claim {claim}` / `release` 与在编辑器层应用的 span-CAS 改写事件——bail 事件的机器侧;span CAS = draftRev 相等。 +- `enter {draft}` / `adjudicated` / `adjudication-failed` / `submit-settled {draft}` / `send-committed`——提交事务平面:SubmitAttempt(seq + AbortSignal)防回灌,成功 commit 清除草稿前缀,失败带漂移守卫 rollback(回车时快照仅当 live draft 仍等于它才回填;用户已再输入则只发 notice)。 -效果面(shell 执行):`adjudicate`(调 InputTriggerController.adjudicate)、`begin-submit`(claim.submit 事务)、`default-sink`(普通消息,hub 编排)、`notice`。 - -occurrence 表与 chip 三投影: - -- 每颗引用在 draft 中占一个 `U+FFFC`;表项 `{occurrenceId, source, ref, offset, label, clipboardText, invalid?}`;同名 chip 因 occurrenceId 独立。 -- 一切编辑同 transaction 更新 draft 与表:区间平移;与占位符相交的删除/替换作用于整颗。 -- 单字符占位使键盘原子性大半原生成立(caret 无内部位;Backspace/方向键/Shift 扩选原生即整颗);鼠标点 chip 由 backdrop 命中 → 整颗 setSelectionRange。 -- 视觉投影 = label:backdrop 在占位符 offset 渲染 chip(textarea 字形不可见),invalid 走失效样式。 -- 剪贴板/持久化投影 = clipboardText:copy/cut 把选区内占位符展开;draft 持久化 mirror 写同一投影(chat store 里永远是普通文本,刷新 seed 语义 = 全选复制→重开→粘贴,chip 跨刷新降级为文本)。 -- 模型投影 = submit 时经 source `codec.serialize` 逐颗生成(归 submit attempt 的 signal 与陈旧守卫;owner 缺失/失败/取消则不发送,不降级为 `/name`)。 +效果面(shell 执行):`adjudicate`(调 InputTriggerController.adjudicate)、`begin-submit`(claim.submit 事务)、`default-sink`(普通消息,hub 编排)、`commit-draft`(前缀删除并保留后缀)、`notice`。 ### 跨插件输入改写:三个 scoped bail 事件 @@ -56,21 +41,21 @@ occurrence 表与 chip 三投影: - `slash/input-insert-reference` `{reference, span}`——引用 chip 插入(InputTriggerController 派发)。 - `slash/input-consume-token` `{guard: span | bare-token}`——业务成功后消费命令 token(下游命令面派发)。 -不事件化的调用(注册表登记 → 显式调用 → await):Input 自身的 draft/submit、Enter 异步裁决、reference serializer、异步 paste matcher。`@mode bail` 已入 JSDoc parser 与 cordis catalog 门禁(scripts/jsdoc.ts)。 +不事件化的调用(注册表登记 → 显式调用 → await):Input 自身的 draft/submit、Enter 异步裁决、reference serializer。`@mode bail` 已入 JSDoc parser 与 cordis catalog 门禁(scripts/jsdoc.ts)。 ### slash 流水线(ui-input-trigger:root `InputTriggerService` + 每会话 `InputTriggerController`) 对「命令」零知识的触发/菜单/pick 流水线: - 服务只有 source 注册表(`InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;流水线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。 -- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的 textarea selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 +- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在编辑器表面,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的编辑器 selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 - 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。 ### hub / facade:常驻外壳与严格会话输入体 - hub(trigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-input-trigger/命令面时输入正常收发,优雅降级。 - 每个实体会话只有一个 `SessionInputShell`(facade),随会话作用域创建和拆除;无会话时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat;会话出现后,彼此独立的严格会话 header 和 body outlet 只填入这些固定区域。 -- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank 会话后同一实例转为 live——textarea DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。 +- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank 会话后同一实例转为 live——编辑器表面 DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。 - ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`:summary 已证实为空的会话在任何 open state 下都保持 Hero,未经证实的会话则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在提示词成功受理后翻 false。 - 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标会话不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank 会话留存但不再 current。 @@ -82,14 +67,14 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。 -- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即 `.textRef` mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失。 +- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 - 发送即原文(不再 `` 序列化);气泡侧 MessageItem 双形状装饰(legacy `` 标签 + 纯文本 token)。 -- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。装饰响应性:InputBar 以 uSES 订阅 shell 的 lexicon source,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 +- 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 ### 每会话供数贡献与键盘私面 - ui-conversation(hub 兼贡献者)经 `sessions.provide` 供 `'input'` hook(机器状态 + queue overlay)+ `inputActions` prop(`setDraft`/`submit`,稳定 void 回调)。 -- 公私分界:公共 provide 只放 React 语汇成员;键盘/DOM 命令面(track/arbitrate/space/undo/redo/paste/dismissPopup/bindMirror——同步返回值、disposer 语义)是 InputBar 独占,走 InputBar entry 自己的 inject 包内私递,不出插件边界。 +- 公私分界:公共 provide 只放 React 语汇成员;键盘/DOM 命令面(`ComposerKeyboard`:shell 所有的 editor,arbitrate/space/paste/dismissPopup/caretSpan——同步返回值、disposer 语义)是 InputBar 独占,走 InputBar entry 自己的 inject 包内私递,不出插件边界。 ### slot 体系 @@ -97,7 +82,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 - `conversation.session.header`(single)——常驻 scrollport 上方严格会话的 breadcrumb、view tab 与 header action。 - `conversation.session`(single)——常驻 scrollport 内严格会话的 view ring 与 draft mirror。header 和 body 共享同一个会话作用域 chat store;会话 id 切换时各自重建。 -- `conversation.composer.bar`(single)——InputBar 本体的 slot:InputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。 +- `conversation.composer.bar`(single)——InputBar 本体的 slot:InputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏编辑器表面的 DOM 存活。 - `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自每会话 controller。 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 - `conversation.composer.dock`——composer 上沿统计带。 @@ -116,9 +101,9 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 | ActiveCommand 中间态 / registerMode 模式注册表 / 从 draft 推导命令态 | claim 由 pick 路径显式建立——无表、无推导 | | bindTarget/bindDraft 对象直连 | 反向耦合 + root 单例跨会话误配;scoped bail 事件保依赖倒置且路由结构性正确 | | 统一 slash/input-apply 或全事件化 | 三个独立 payload 覆盖跨插件改写;异步链路保持基于注册表的显式调用 | -| contenteditable / 富文本树 | 兼容性差;textarea + U+FFFC + occurrence 表覆盖全部交互约定 | +| contenteditable / 富文本树 | 兼容性差;textarea + U+FFFC + occurrence 表覆盖全部交互约定——后被 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md) 反转:textarea 栈的样式与身份成本被实测后翻案 | | draft 双持久化 {text, occurrences} | mirror 写剪贴板投影零新概念;chip 跨刷新降级可接受 | -| 原生 textarea undo 栈 | 受控 + 程序化写入下不可靠;粘贴两段 undo 语义只能自管 | +| 原生 textarea undo 栈 | 受控 + 程序化写入下不可靠;粘贴两段 undo 语义只能自管——两侧都随 textarea 一并退役;undo 现归 Lexical history | | InputBar 收 16 员 wiring 回调包 | 消费矩阵实证 11 员 InputBar 独占、1 员死成员;标准件通道让组件自取,键盘面包内私递 | | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通提示词;不可逆副作用只留显式入口 | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 | @@ -129,7 +114,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 ## 后果 -- 一个常驻 conversation 外壳承接无会话/blank/active:无会话 → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与 textarea;只有严格会话 header 和 body outlet 开始承载内容。同一 blank 会话 → engaging/active 也保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费方一并删除。 +- 一个常驻 conversation 外壳承接无会话/blank/active:无会话 → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与编辑器表面;只有严格会话 header 和 body outlet 开始承载内容。同一 blank 会话 → engaging/active 也保持 InputBar 与编辑器表面。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费方一并删除。 - 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;`@` 引用与 skill 引用免费复用同一菜单/pick 流水线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结约定。 - 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。 -- 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。 +- 已知欠账:chip 跨刷新保真未立项;subagent 引用的模型表示待业务立项。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml index d5fdbcc63f..3318134b3b 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.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-08-18-experimental-agent-teams-packages.md -2026-08-18-experimental-agent-teams-packages.md: 495922d57bd88a78f0ca6b61367ef49c820db632 -2026-08-18-experimental-agent-teams-packages.zh.md: 65aed57e223d8fa9b8ff97e2fcc109057b51474d +2026-08-18-experimental-agent-teams-packages.md: b769791d52472290f76eec7e518f24001db919ad +2026-08-18-experimental-agent-teams-packages.zh.md: 25227770b5029b1baedef6bd9b7ad78b79586365 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md index 495922d57b..b769791d52 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md @@ -12,13 +12,13 @@ An experimental directory without a current package previously imposed placement ## Decision -`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, and `packages/experimental/agent-team-profile` are private workspace packages. The [experimental package naming decision](2026-08-19-experimental-package-name-prefix.md) owns their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation. +`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are private workspace packages. The [experimental package naming decision](2026-08-19-experimental-package-name-prefix.md) owns their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation. The dsh pack and publish set and the local baseline publisher exclude every manifest below `packages/experimental/`. `release:dsh` still advances their manifest versions with the shared dsh version without creating release tags. Workspace constraints require each experimental package to set `private: true` and omit `publishConfig`. The same top-level check rejects `dependencies`, `optionalDependencies`, and `peerDependencies` from release packages, release apps, or the Python runtime to an experimental package. Experimental packages may depend on release packages and each other; tests may use them through `devDependencies`, and examples may load them explicitly. The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction. -The private Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows, disables the global continuable-child controls whose model-visible names overlap the Team tools, and leaves the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. +The private Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate private Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. Profile startup resolves selected bundles before healing module fallbacks. The shared fallback retains the dsh installation's carrier-specific entries: symlinks under plain Node and ESM proxies in a packaged executable. Missing packages from selected bundle closures are linked under the current profile's own `node_modules`, while pnpm-managed profile entries remain authoritative. Closure discovery starts from each explicit external bundle's real package directory and traverses every listed root even when an earlier dependency has the same package name. It excludes dsh-owned profile projections from later discovery, so a projected dependency cannot feed back into its own closure. Link ownership compares canonical parent paths so junction-normalized targets remain removable. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution. diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md index 65aed57e22..25227770b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md @@ -12,13 +12,13 @@ Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Sess ## 决策 -`packages/experimental/agent-team`、`packages/experimental/tool-agent-team` 与 `packages/experimental/agent-team-profile` 是私有 workspace 包。[实验性包命名决策](2026-08-19-experimental-package-name-prefix.zh.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。 +`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是私有 workspace 包。[实验性包命名决策](2026-08-19-experimental-package-name-prefix.zh.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。 dsh pack 与 publish 集合以及本地 baseline 发布器均排除 `packages/experimental/` 下的所有 manifest。`release:dsh` 仍会让这些 manifest 跟随 dsh 共享版本递增,但不会创建发布 tag。workspace 约束要求每个实验性包设置 `private: true` 并省略 `publishConfig`。同一个顶层检查会拒绝发布包、发布 app 或 Python runtime 通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 依赖实验性包。实验性包可以依赖发布包和其他实验性包;测试可以通过 `devDependencies` 使用它们,示例可以显式加载它们。 通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。 -私有 Agent Teams profile bundle 依赖 Team 包,并应用在 `dsh-base` 之后。它插入 Team 配置行,禁用模型可见名称与 Team 工具重叠的全局 continuable-child control,并保持已发布 base、CLI、Web 与 Python runtime 的依赖图不变。 +私有 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立的私有 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持已发布 base、CLI、Web 与 Python runtime 的依赖图不变。 profile 启动会先解析所选 bundle,再修复模块 fallback。共享 fallback 保留 dsh 安装的载体专用条目:普通 Node 下使用 symlink,打包 executable 中使用 ESM proxy。仅由所选 bundle 闭包携带的缺失包会链接到当前 profile 自己的 `node_modules` 下,而 pnpm 管理的 profile 条目仍具有优先权。闭包发现从每个显式外部 bundle 的真实包目录开始;即使前一个依赖具有相同包名,也会遍历所有列出的根。后续发现会排除 dsh 所有的 profile projection,避免投影后的依赖重新进入自己的闭包。link ownership 通过规范化父路径比较,使 junction 规范化后的 target 仍可删除。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。 diff --git a/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.i18n.yaml new file mode 100644 index 0000000000..3c47eef5c6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.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/architecture/2026-08-20-web-composer-lexical-editor.md +2026-08-20-web-composer-lexical-editor.md: bc1f2f55c894ef547454d59977c70d696c071529 +2026-08-20-web-composer-lexical-editor.zh.md: 2ba7e495a7eb80877a096251f0982686016470d6 diff --git a/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md b/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md new file mode 100644 index 0000000000..bc1f2f55c8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md @@ -0,0 +1,61 @@ +# Agent Note: Web composer as a Lexical editor (chips as atomic nodes) + +Status: implemented + +English | [中文](2026-08-20-web-composer-lexical-editor.zh.md) + +> Scope: the composer's text surface (ui-conversation input/editor), the SubmitMachine that remains of InputMachine, and the projection contract feeding the untouched ui-input-trigger pipeline. Supersedes the draft/occurrence half of the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md); its submit-plane, slot, and trigger-pipeline halves stay current. + +## Problem + +The textarea composer painted text in three coupled layers (hidden auto-grow mirror, decoration backdrop, transparent-text textarea) and held the draft twice (the textarea string and the machine's occurrence table). Both couplings produced structural bugs: reconciling occurrences against a string diff guessed the edit position, and a greedy scan sliding into a reference silently degraded it before the serialization guard could run (#2813); scan-derived decorations carried no identity, so typing ahead of one rebuilt its DOM every keystroke (#2793). The layer trick also taxed every chip style — nothing could change glyph advance, so no background, padding, radius, or label truncation. + +## Decision + +One Lexical editor per session shell replaces the three layers and the draft half of the machine. + +- **Ownership**: `SessionInputShell` creates the editor outside React (`createEditor` + `registerPlainText` + `registerHistory`) and keeps it for the session's lifetime; React binds a resident contenteditable to it (`ComposerContentEditable`, ~40 lines) and portals decorators (`DecoratorPortals`). `@lexical/react` is deliberately not used: its composer owns editor creation inside React, which conflicts with per-session shell ownership, and it drags an unused dependency tree. +- **Chips are atomic `DecoratorNode`s** (`ReferenceChipNode`) carrying the owner's insert-time projections. NodeKey is the occurrence identity; `getTextContent()` answers the clipboard projection, so native copy/cut and the draft mirror need no expansion code. +- **One tree, three projections**: the detect projection (chip = one U+FFFC) feeds `detectTrigger` and TokenSpan coordinates, restoring the opaque-reference invariant #2769 broke; the clipboard projection (chip = clipboardText) feeds `InputState.draft`, persistence, and submit-plane decisions; the model form is produced per chip at submit through the owner codec. `span-map.ts` is the single place numeric spans map back to Lexical points. +- **The machine slims to the submit plane** (phase/claim/attempt); it never holds the draft — events carry the clipboard projection (`enter`, `submit-settled`), and the claimed integrity watch runs on `draft-changed`. Draft clearing became the `commit-draft` effect the shell executes in the editor (suffix retention included), followed by `CLEAR_HISTORY_COMMAND`. +- **Contract stability**: `TokenSpan {start, end, draftRev}`, `ReferenceInsert`, `CommandClaim`, the four `slash/input-*` bail events, every trigger source, the controller, and MenuView are unchanged. `draftRev` is now the editor update counter. +- **Claim tokens stay literal text** with a transform-styled leading leaf (backspacing the token remains the exit gesture); plain-text references ride `registerLexicalTextEntity` (`TextRefNode`); the ghost hint is a CSS `--dsh-composer-hint` variable rendered as generated content. + +## Retired with the rewrite + +The mirror/backdrop layers and their CSS coupling rules; the Safari soft-wrap repair (2026-08-13 note's workaround — the surface has no mirror to disagree with); mirror-Range caret measurement; the machine's undo ring and typing-merge clock (Lexical history, 1000ms merge delay preserved); manual boundary Backspace/Delete occurrence deletion (atomic nodes); manual copy/cut expansion; `EditRange`/`diffEdit`/`reconcile`. The paste-attempt plane (`paste-begin` components, `paste-upgrade`, `invalidate-paste`) and the `set-invalid` event had **no producers anywhere in the tree** and were deleted rather than ported; `Occurrence.invalid` stays on the node and the projection for the day a producer exists. + +## Deliberate behavior changes + +- Claimed command args now reach the source in clipboard form (references as canonical text, not display labels) — the parseable form. +- `InputState.draft` is the clipboard projection (was display text). Cross-package readers consume phase/queue-level fields; the occurrence table had zero external readers. +- Chip deletion follows the engine's native decorator gesture; jsdom lacks `Selection.modify`, so the keyboard path is asserted in the browser lane only. +- Folder text-refs render the folder glyph as an icon prefix before the intact literal token (a currentcolor mask of the bubble's asset); the old backdrop overpainted the trigger character instead, which a Lexical text node cannot express. +- The composer's accessible name is an explicit `aria-label` mirroring the placeholder (a div's `data-placeholder` does not name it the way a textarea's placeholder did) — caught by the reference-composer aria golden. +- Caret-only commits publish nothing: the shell advances `draftRev` and re-publishes `InputState` only when the projection's content changes. Caret motion still feeds menu tracking, but it neither invalidates snapshot-built CAS spans (apply.ts builds spans from the published `draftRev`) nor re-renders subscribers. The first cut re-published on every commit; review caught the drift from the old machine's text-only revision. +- A paste is its own undo boundary: the custom PASTE_COMMAND handler consumes the event before `@lexical/plain-text` could tag the update, so the shell attaches `PASTE_TAG` itself (via `$addUpdateTag` — the dispatch path always runs nested inside the command update). Without it, history merged a paste with typing inside the 1s window and one undo removed both. +- The claim decoration outranks text-ref entities on the leading-token seat: a claimed command name that is also on the trigger lexicon stays a plain warn-styled TextNode, because Lexical transforms register per concrete node class and an entity capture would silently drop the claim color (probe-confirmed before the guard: the entity node won and the style was lost). + +## Alternatives considered + +- **Patch the textarea** (record beforeinput selections to narrow the diff, #2813's proposed fix): shrinks the guessing window but keeps two truths and the style tax; every future decoration pays it again. +- **Hand-rolled contenteditable layer**: rejected by the dependencies-over-hand-rolling policy — IME, selection, and engine quirks are exactly what Lexical already owns. +- **Deleting the machine entirely** (editor state as the only machine): the submit plane (attempt CAS, anti-backwash, abort) is text-independent and battle-tested; rewriting it buys risk, not simplicity. +- **`@lexical/react`**: its composer creates the editor inside React, conflicting with per-session shell ownership, and pulls an unused dependency tree; the two bindings it would replace total ~80 lines. + +## Consequences + +- Bugs #2813 and #2793 are structurally unexpressible: no edit-position inference exists, and chip DOM identity rides NodeKey. +- Chips are real DOM (icon, capsule, `max-width` truncation, invalid strike-through) and enter the accessibility tree; the old backdrop was `aria-hidden`. +- The editor and its history survive session switches on the shell; unit tests drive the document headlessly, while true keyboard gestures (chip deletion, IME) belong to the browser lane. +- The ui-conversation client bundle carries lexical (+~70KB gzip); no other package imports a Lexical value, so no module-table row exists. +- The submit plane, trigger pipeline, and slash/input-* contracts are byte-compatible for every source plugin. + +## Traps + +- `editor.update` **defers** its fn when called from inside the same editor's update (command handlers land there synchronously); a nested discrete throws. `applyEdit` runs the `$`-body directly when `editor._updating` (legal in command handlers — the pattern Lexical itself uses for setEditable) and discretely at top level. A bail answer computed through a wrapped nested update reads stale state. +- Lexical's chord/space detection reads `event.keyCode` (undo `z`=90, space=32); synthetic tests must set it. +- A history restore (`UNDO_COMMAND`) commits on the next flush, not synchronously inside the dispatch. +- The client bundle needs the `production`/`development` exports condition pinned (tsdown preset `inputOptions.resolve.conditionNames`): lexical's `node` condition file selects its flavor with a top-level await a CJS bundle cannot carry. +- `registerHistory`'s merge delay reads `Date.now` at call time; fake-timer tests must install the mock before shell construction or advance past the window. +- `isKeyboardSelectable()` must be **false** on the chip. With the default `true`, an arrow at the chip edge creates a NodeSelection whose DOM projection collapses to an element point, and the plain-text binding's arrow/delete/insert handlers all bail on non-Range selections — arrows, typing, and Backspace deadlock at the chip until a pointer click. False restores the placeholder semantics: arrows step across in one move, Backspace/Delete remove the chip whole (browser-lane e2e pins the gesture; only a real key event reproduces it — CDP raw keydowns carry no engine default). diff --git a/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.zh.md b/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.zh.md new file mode 100644 index 0000000000..2ba7e495a7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.zh.md @@ -0,0 +1,61 @@ +# Agent Note:Web 输入框改为 Lexical 编辑器(chip 为原子节点) + +Status: implemented + +[English](2026-08-20-web-composer-lexical-editor.md) | 中文 + +> 范围:输入框文本表面(ui-conversation input/editor)、InputMachine 瘦身后余下的 SubmitMachine,以及喂给原封不动的 ui-input-trigger 管线的投影契约。取代[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.zh.md) 中 draft/occurrence 的那一半;其提交面、slot 与 trigger 管线部分仍然有效。 + +## 问题 + +textarea 输入框用三个耦合层绘制文本(隐藏自增高 mirror、装饰 backdrop、文字透明的 textarea),且草稿存在两份(textarea 字符串与状态机的 occurrence 表)。两处耦合各自产生结构性 bug:occurrence 对齐依赖字符串 diff 猜测编辑位置,贪心扫描滑进引用内部会在序列化守卫运行前把它静默降级(#2813);扫描推导的装饰没有身份,在其前方打字每一击都重建它的 DOM(#2793)。层叠戏法还对每种 chip 样式征税——任何改变字形 advance 的样式都不可用,于是没有背景、内边距、圆角,也无法截断标签。 + +## 决策 + +每个会话壳持有一个 Lexical 编辑器,取代三层结构与状态机的草稿半边。 + +- **所有权**:`SessionInputShell` 在 React 之外创建编辑器(`createEditor` + `registerPlainText` + `registerHistory`)并持有它到会话结束;React 侧把常驻 contenteditable 绑上去(`ComposerContentEditable`,约 40 行)并 portal 渲染 decorator(`DecoratorPortals`)。刻意不用 `@lexical/react`:其 composer 在 React 内部创建编辑器,与 per-session 壳所有权冲突,还会拖入用不到的依赖树。 +- **chip 是原子 `DecoratorNode`**(`ReferenceChipNode`),携带所有者插入时的投影。NodeKey 即 occurrence 身份;`getTextContent()` 回答剪贴板投影,因此原生复制/剪切与草稿镜像不再需要展开代码。 +- **一棵树,三个投影**:检测投影(chip = 1 个 U+FFFC)供 `detectTrigger` 与 TokenSpan 坐标使用,恢复了 #2769 打破的不透明引用不变量;剪贴板投影(chip = clipboardText)供 `InputState.draft`、持久化与提交面决策使用;模型形式在提交时逐 chip 经所有者 codec 产出。`span-map.ts` 是数字 span 映射回 Lexical point 的唯一场所。 +- **状态机瘦身为提交面**(phase/claim/attempt);它不再持有草稿——事件携带剪贴板投影(`enter`、`submit-settled`),claimed 完整性监视跑在 `draft-changed` 上。清空草稿变成 shell 在编辑器里执行的 `commit-draft` 效果(含后缀保留),随后 `CLEAR_HISTORY_COMMAND`。 +- **契约稳定**:`TokenSpan {start, end, draftRev}`、`ReferenceInsert`、`CommandClaim`、四个 `slash/input-*` bail 事件、所有 trigger source、controller 与 MenuView 一律未改。`draftRev` 现在是编辑器 update 计数。 +- **claim token 保持字面文本**,前缀叶子由 transform 上色(退格删 token 仍是退出手势);纯文本引用走 `registerLexicalTextEntity`(`TextRefNode`);ghost hint 是 CSS 变量 `--dsh-composer-hint` 生成内容。 + +## 随重构退役 + +mirror/backdrop 层及其 CSS 耦合规则;Safari 软换行修复(2026-08-13 note 的 workaround——表面已无可与之分歧的 mirror);mirror-Range 光标测量;状态机的 undo 环与打字合并时钟(Lexical history,保留 1000ms 合并窗口);手写的边界 Backspace/Delete 整段删除(原子节点原生);手写复制/剪切展开;`EditRange`/`diffEdit`/`reconcile`。粘贴尝试面(`paste-begin` components、`paste-upgrade`、`invalidate-paste`)与 `set-invalid` 事件**全仓没有任何生产者**,直接删除而非移植;`Occurrence.invalid` 保留在节点与投影上,待未来出现生产者。 + +## 刻意的行为变化 + +- 已认领命令的 args 现以剪贴板形式到达 source(引用为规范文本而非展示标签)——可解析的那种形式。 +- `InputState.draft` 是剪贴板投影(原为展示文本)。跨包读方只消费 phase/queue 级字段;occurrence 表的外部读方为零。 +- chip 删除遵循引擎的原生 decorator 手势;jsdom 缺 `Selection.modify`,键盘路径只在浏览器 lane 断言。 +- 文件夹纯文本引用在完整字面 token 前渲染文件夹图标前缀(气泡同款资产的 currentcolor mask);旧 backdrop 是覆盖绘制 trigger 字符,而 Lexical 文本节点无法表达这种覆盖。 +- 输入框的可访问名称改为显式 `aria-label` 镜像 placeholder(div 的 `data-placeholder` 不像 textarea 的 placeholder 那样参与命名)——由 reference-composer 的 aria golden 逮出。 +- 纯光标 commit 不发布任何东西:shell 只在投影内容变化时推进 `draftRev` 并重发布 `InputState`。光标移动仍然喂给菜单 tracking,但既不会使快照构造的 CAS span 失效(apply.ts 用已发布的 `draftRev` 构造 span),也不会触发订阅者重渲染。第一版每次 commit 都重发布;review 逮出了与旧机器「仅文本推进版本号」语义的漂移。 +- 粘贴是独立的 undo 边界:自定义 PASTE_COMMAND handler 在 `@lexical/plain-text` 有机会打 tag 之前就消费了事件,因此 shell 自己补上 `PASTE_TAG`(经 `$addUpdateTag`——dispatch 路径必然嵌套在命令 update 内部执行)。没有它,history 会把粘贴与 1 秒窗内的输入合并,一次 undo 同时撤销两者。 +- claim 装饰对行首 token 席位的优先级高于 text-ref 实体:被 claim 的命令名即使同时在触发 lexicon 上,也保持为普通的警告色 TextNode——因为 Lexical transform 按具体节点类注册,实体捕获会无声吃掉 claim 颜色(加守卫前经探针证实:实体节点胜出、样式丢失)。 + +## 曾考虑的替代方案 + +- **给 textarea 打补丁**(记录 beforeinput 时的 selection 收窄 diff,即 #2813 提议的修法):缩小猜测窗口但保留双事实源与样式税;未来每个装饰都要再交一次。 +- **自研 contenteditable 薄层**:被「依赖优先于手搓」政策否决——IME、selection 与引擎怪癖正是 Lexical 已经解决的本职。 +- **彻底删除状态机**(编辑器状态为唯一状态机):提交面(attempt CAS、防倒灌、abort)与文本表示无关且久经考验;重写只买来风险。 +- **`@lexical/react`**:其 composer 在 React 内创建编辑器,与 per-session 壳所有权冲突,还拉入用不到的依赖树;它能替代的两个绑定总共约 80 行。 + +## 后果 + +- #2813 与 #2793 在结构上不可表达:不存在编辑位置推断,chip DOM 身份随 NodeKey。 +- chip 是真实 DOM(图标、胶囊、`max-width` 截断、失效删除线)并进入可访问性树;旧 backdrop 是 `aria-hidden` 的。 +- 编辑器及其历史随壳跨会话切换存活;单元测试无头驱动文档,真实键盘手势(删 chip、IME)归浏览器 lane。 +- ui-conversation 的 client bundle 携带 lexical(gzip 约 +70KB);无其他包 import Lexical 值,故无模块表行。 +- 提交面、trigger 管线与 slash/input-* 契约对每个 source 插件字节兼容。 + +## 坑 + +- 在同一编辑器的 update 内再调 `editor.update` 会**推迟**其 fn(command handler 同步落到这里);嵌套 discrete 直接抛错。`applyEdit` 在 `editor._updating` 时直接执行 `$` 函数体(command handler 内合法——Lexical 自己对 setEditable 用同款分叉),顶层则 discrete。经包裹嵌套 update 计算的 bail 答案读到的是旧状态。 +- Lexical 的组合键/空格检测读 `event.keyCode`(undo `z`=90、空格=32);合成事件测试必须设置它。 +- 历史恢复(`UNDO_COMMAND`)在下一次 flush 才提交,不在 dispatch 内同步生效。 +- client bundle 需钉住 `production`/`development` exports 条件(tsdown preset 的 `inputOptions.resolve.conditionNames`):lexical 的 `node` 条件文件用顶层 await 选择口味,CJS bundle 载不动。 +- `registerHistory` 的合并延时在调用时捕获 `Date.now`;fake-timer 测试要么在 shell 构造前装好 mock,要么推进越过窗口。 +- chip 的 `isKeyboardSelectable()` 必须为 **false**。取默认值 `true` 时,方向键落在 chip 边缘会创建 NodeSelection,其 DOM 投影坍塌为 element point,而 plain-text binding 的方向键/删除/插入 handler 全都对非 Range selection 直接放弃——方向键、打字与退格在 chip 边死锁,直到鼠标点击才能解除。false 恢复占位符语义:方向键一步跨过,Backspace/Delete 整颗删除(浏览器 lane e2e 钉住该手势;只有真实按键事件能复现——CDP 裸 keydown 不携带引擎默认行为)。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml index 5555d98fec..4e693b9b80 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.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/bug-fix/2026-07-30-approval-panel-command-cap.md -2026-07-30-approval-panel-command-cap.md: 1d74d790299522a0f1e6b23f342cd10b35192d27 -2026-07-30-approval-panel-command-cap.zh.md: 35263df4df4460ccfad4a0c11704997f4b2e27f2 +2026-07-30-approval-panel-command-cap.md: 0bfdc2d00fa4a0f9fa9ee71400d6f3cc2350e617 +2026-07-30-approval-panel-command-cap.zh.md: a086888b12c0164b931019f4d73102bd11bccf4b diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md index 1d74d79029..0bfdc2d00f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md @@ -34,7 +34,7 @@ The panel's card rebinds `--dsh-scrollbar-thumb{,-hover}` to the l2 pair, as eve - A long command scrolls inside the card and the refuse/allow buttons stay on screen. Measured on the built client at 900x1000 and 900x700: the region reports `scrollHeight` past `clientHeight`, and both buttons stay inside the card and inside the viewport. - Electing the takeover no longer changes how tall the composer seat can get, so the transcript above it does not reflow by hundreds of pixels when an approval arrives or resolves. -- The InputBar's 14-line cap now resolves through a custom property inherited from `.composerSeat`, on the box that scrolls its draft ([one scrollport for both text layers](2026-07-31-composer-text-layers-share-one-scrollport.md) moved the declaration off the auto-grow mirror). Rendering the bar outside that seat would drop the declaration (an unresolved `var()` with no fallback), so a future composer host has to carry the property — which is why it is declared on the shared seat rather than the app root. +- The InputBar's 14-line cap now resolves through a custom property inherited from `.composerSeat`, on the box that scrolls its draft ([one scrollport for both text layers](../../archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md) moved the declaration off the auto-grow mirror). Rendering the bar outside that seat would drop the declaration (an unresolved `var()` with no fallback), so a future composer host has to carry the property — which is why it is declared on the shared seat rather than the app root. - The scenario's recorded command is a 200-token blob, far longer than a round trip needs. That cost is deliberate: the cap is unfalsifiable without content that passes it, and the model compresses any regular payload (the first recording turned "alpha 400 times" into `printf 'alpha %.0s' {1..400}`, a one-line command that proves nothing). ## Verification diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md index 35263df4df..a086888b12 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md @@ -34,7 +34,7 @@ Status: implemented - 长命令在卡片内滚动,拒绝/允许按钮留在屏幕内。在构建产物客户端上于 900x1000 与 900x700 实测:该区域报告的 `scrollHeight` 超过 `clientHeight`,两个按钮都留在卡片内、也都留在视口内。 - 选中接管面板不再改变 composer 容器能达到的高度,因此审批到来或解决时,上方的 transcript(文本记录)不会有数百像素的重排。 -- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析,且落在真正滚动草稿的那个盒子上([两层文本共用同一个滚动容器](2026-07-31-composer-text-layers-share-one-scrollport.zh.md)把该声明从自增高镜像层移了出去)。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 `var()`),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。 +- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析,且落在真正滚动草稿的那个盒子上([两层文本共用同一个滚动容器](../../archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md)把该声明从自增高镜像层移了出去)。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 `var()`),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。 - 该场景录制的命令是一段 200 个 token 的字符块,远超一次往返所需。这个代价是有意付出的:没有能越过上限的内容,这个上限无法被证伪,而模型会把任何规整的载荷压缩掉(第一次录制时,模型把「alpha 重复 400 次」写成了 `printf 'alpha %.0s' {1..400}`,一条什么也证明不了的单行命令)。 ## 验证 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml index b4da830df6..b0a9caa27f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.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/bug-fix/2026-08-04-composer-tab-gutter-reservation.md -2026-08-04-composer-tab-gutter-reservation.md: 37a07b2b98bc676ff9c11fe90bc8e346f6189690 -2026-08-04-composer-tab-gutter-reservation.zh.md: bd6a7899515505017549960c2a5769e9ab076ca5 +2026-08-04-composer-tab-gutter-reservation.md: ed8d973c289fcb92b33cc468551aabe5ebb9eee7 +2026-08-04-composer-tab-gutter-reservation.zh.md: 282151ecbfe6ab046b785c8a8bfcd88781607449 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md index 37a07b2b98..ed8d973c28 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md @@ -18,7 +18,7 @@ So for as long as the transcript overflowed — the ordinary state of any sessio `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between Chat's two phases — an `auto` gutter would state the bug rather than fix it. -The reservation lives on an `overflow-y: auto` box, and that form is load-bearing: WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation on a hidden box would hold in Chromium and silently not in Safari. The overlay branch keeps its `overflow-y: auto` form too, as a clipping box nothing scrolls out of: a single-axis scroller computes the other axis to `auto`, so the horizontal axis is declared `hidden` rather than left to compute, and would otherwise grow a horizontal scrollbar of its own the first time a view's content reached past the column. +The reservation lives on an `overflow-y: auto` box, and that form is load-bearing: WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](../../archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation on a hidden box would hold in Chromium and silently not in Safari. The overlay branch keeps its `overflow-y: auto` form too, as a clipping box nothing scrolls out of: a single-axis scroller computes the other axis to `auto`, so the horizontal axis is declared `hidden` rather than left to compute, and would otherwise grow a horizontal scrollbar of its own the first time a view's content reached past the column. The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md index bd6a789951..282151ecbf 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md @@ -18,7 +18,7 @@ composer 座位在组件树中只有一个节点、一个位置,但它究竟 选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而「溢出与否」恰恰就是 Chat 两种相位之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。 -这条预留位于 `overflow-y: auto` 的盒子上,而这个形式是承重的:WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](2026-07-31-composer-text-layers-share-one-scrollport.zh.md)——所以把预留放在 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。覆盖分支同样保留 `overflow-y: auto` 的形式,作为没有任何内容会滚出去的裁剪盒:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,因此横向轴显式声明为 `hidden` 而不是交给推导,否则某个视图的内容第一次伸出列外时,它就会长出自己的横向滚动条。 +这条预留位于 `overflow-y: auto` 的盒子上,而这个形式是承重的:WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](../../archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留放在 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。覆盖分支同样保留 `overflow-y: auto` 的形式,作为没有任何内容会滚出去的裁剪盒:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,因此横向轴显式声明为 `hidden` 而不是交给推导,否则某个视图的内容第一次伸出列外时,它就会长出自己的横向滚动条。 这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.i18n.yaml new file mode 100644 index 0000000000..10d7ec3662 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.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-21-inline-user-text-projection.md +2026-08-21-inline-user-text-projection.md: deb0c54f41f950313246f3700d13b2a2c9f1b7ef +2026-08-21-inline-user-text-projection.zh.md: f7b8dd1d825aae05440256df267ae5bf7404ec29 diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.md b/.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.md new file mode 100644 index 0000000000..deb0c54f41 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.md @@ -0,0 +1,32 @@ +# Agent Note: Sent user text projects inline, and queue rows fold wire references + +Status: implemented + +English | [中文](2026-08-21-inline-user-text-projection.zh.md) + +## Problem + +Two display gaps in sent user text, both older than the Lexical composer. The user-bubble decorator (`projectUserText`, then private to `MessageItem`) split one message into plain runs and reference chips, but rendered every plain run through the block-level `MessageText` div — so a decorated single-line message broke into one line per run, and the single space between two adjacent tokens rendered as a whole blank line. Separately, the queue dock's read-only row printed `row.preview` verbatim, so a queued message carrying a chip showed the wire session form `@[查看并分析图片](dsh-session:InNlc3Npb24t…)` — the model-facing text, unreadable as a preview. The logged model text was correct in both cases (verified against the session log bytes); both defects were presentation only. + +## Decision + +One shared inline projection, `reference/user-text.tsx`, owns the display of sent user text and is consumed by the bubble and the queue row: + +- **Everything inline.** Plain runs render as `span`s; the block-level `MessageText` leaves the path entirely. White-space policy stays with the consumer: the bubble declares `pre-wrap` (real newlines survive), the queue preview keeps its `nowrap`/ellipsis single line — the shared spans pin neither. +- **Wire session forms fold.** A new highest-precedence rule folds `@[label](dsh-session:…)` to a session chip showing the label (the source text stays on `title`). The existing rules — recall-associated exact labels, then bare `/name` / `@name` tokens by shape — follow at their old precedence, so the fold also shields the URI from the bare-token scan that would otherwise misread it as a file path. +- **Queue edit stays literal.** The row's edit field exposes `row.text` unchanged: the user edits exactly what will be sent, and folding an editable surface would detach the visible text from the durable one. + +`queue-actions.e2e` locators moved from `getByText(…).locator('..')` to row-container matching (`li` with `hasText`): the projection adds one span layer, so a parent hop from the matched text no longer lands on the row. + +## Alternatives considered + +- **Carry a display text beside the queued model text**: rejected — it adds a wire/session field for a presentation concern and violates the single-truth rule; folding at render needs no new state. +- **Fold inside the editor field too**: rejected — the edit target is the literal sent text; a folded editable view would let the user "edit" text that is not what gets sent. +- **Fix only the bubble's blank line with CSS** (collapse empty runs): rejected — the runs were block-level by construction, and the queue gap needed the shared projection anyway. + +## Consequences + +- A decorated single-line message renders on one line; the bubble in the field report dropped from four visual lines (one blank) to its natural wrapped height. +- Queue previews read as the composer showed them: label chips instead of `dsh-session:` URIs; the wire form also folds in the bubble if it ever reaches durable text. +- `MessageItem` and `QueueDock` share one decoration vocabulary and stylesheet (`user-text.module.css`); the chip styles left `MessageItem.module.css`. +- Tests: `user-text.client.spec` pins the inline guarantee (zero `div`s, whitespace-preserving runs) and every fold rule; the chat-view literal-text matcher followed the element change. diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.zh.md new file mode 100644 index 0000000000..f7b8dd1d82 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-21-inline-user-text-projection.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 已发送用户文本行内投影,Queue 行折叠 wire 引用 + +Status: implemented + +[English](2026-08-21-inline-user-text-projection.md) | 中文 + +## 问题 + +已发送用户文本存在两个显示缺口,都早于 Lexical composer。用户气泡装饰器(`projectUserText`,当时是 `MessageItem` 的私有函数)把一条消息切成普通片段与引用 chip,但每个普通片段都经块级的 `MessageText` div 渲染——被装饰的单行消息因此被拆成每段一行,两个相邻 token 之间的单个空格更是渲染成一整行空白。另一处,queue dock 的只读行原样打印 `row.preview`,携带 chip 的排队消息因此显示 wire 会话形式 `@[查看并分析图片](dsh-session:InNlc3Npb24t…)`——面向模型的文本,作为预览不可读。两处的持久模型文本都是正确的(已对照会话日志字节核实);两个缺陷都纯属呈现层。 + +## 决策 + +由一个共享的行内投影 `reference/user-text.tsx` 负责已发送用户文本的显示,气泡与 queue 行共同消费: + +- **一切行内。** 普通片段渲染为 `span`;块级 `MessageText` 彻底退出该路径。换行策略归消费方:气泡声明 `pre-wrap`(真实换行保留),queue 预览保持 `nowrap`/省略号单行——共享 span 两者都不钉死。 +- **wire 会话形式折叠。** 新增最高优先级规则把 `@[label](dsh-session:…)` 折叠为显示标签的会话 chip(原文保留在 `title`)。既有规则——recall 关联的精确标签、按形状识别的裸 `/name` / `@name` token——按原优先级跟随,因此折叠同时挡住了裸 token 扫描(否则它会把 URI 误读成文件路径)。 +- **Queue 编辑态保持原文。** 行的编辑框展示未经处理的 `row.text`:用户编辑的正是将要发送的内容,折叠一个可编辑表面会让可见文本与持久文本脱钩。 + +`queue-actions.e2e` 的定位器从 `getByText(…).locator('..')` 改为行容器匹配(带 `hasText` 的 `li`):投影多了一层 span,从命中文本向上跳一级不再落在行元素上。 + +## 曾考虑的替代方案 + +- **在排队模型文本旁另存一份显示文本**:否决——为呈现关切增加 wire/会话字段,违背单一真源;渲染期折叠不需要新状态。 +- **编辑框内也折叠**:否决——编辑对象就是字面发送文本,可编辑表面折叠会让用户"编辑"一段并非实际发送的文本。 +- **仅用 CSS 修气泡空行**(折叠空片段):否决——片段的块级性是结构性的,且 queue 缺口本来就需要共享投影。 + +## 后果 + +- 被装饰的单行消息渲染为一行;现场报告中的气泡从四视觉行(含一空行)降到自然换行高度。 +- Queue 预览按 composer 中的样子呈现:标签 chip 取代 `dsh-session:` URI;wire 形式若进入持久文本,气泡同样折叠。 +- `MessageItem` 与 `QueueDock` 共享一套装饰词汇与样式表(`user-text.module.css`);chip 样式移出 `MessageItem.module.css`。 +- 测试:`user-text.client.spec` 钉住行内保证(零 `div`、片段保留空白)与每条折叠规则;chat-view 的字面文本匹配器随元素变化更新。 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index b49045dd58..5075fdd7a2 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-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/feature/2026-07-06-approval-seam.md -2026-07-06-approval-seam.md: cbb1b1cb1fef0f6ed1db8e3e0eb1ea01a5834f53 -2026-07-06-approval-seam.zh.md: 29e23f81827200cbbbe0d6a78216ff1f1d4b52ae +2026-07-06-approval-seam.md: 6e94ed945b4c568bd3c82c8e590736d8d1c27606 +2026-07-06-approval-seam.zh.md: 26447522ecf1d93a09944c0d2eb9574f1e0f9cb2 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index cbb1b1cb1f..6e94ed945b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -55,7 +55,7 @@ After validation and a successful `approval/asked` append, the service resolves Answerers are `approval/request` waterfall listeners. Zero listeners fall through to `unavailable`; a recognizing listener occupies the first-wins decision slot, while an unrecognized agent must delegate with `next()`. Listeners dispose with their fibers, so an unloaded channel fails closed. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and reserves `prepend` for decide-or-delegate gates. -`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `CallId` brand without importing `dsh-tools`, which depends on this seam. Channel adapters correlate any richer call state by `callId`; the approval request does not duplicate tool arguments. +`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `ToolCallId` brand without importing `dsh-tools`, which depends on this seam. Channel adapters correlate any richer call state by `callId`; the approval request does not duplicate tool arguments. #### Ask routing in dsh-tools diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index 29e23f8182..26447522ec 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -55,7 +55,7 @@ tool/result "escalated" — this one call ran under the wider mode; the gra 应答者是 `approval/request` waterfall 监听器。零监听器会直接落到 `unavailable`;识别该 agent 的监听器占用先到先得的决策槽,而不识别的监听器必须调用 `next()` 委派。监听器会随其 fiber 一同 dispose(资源释放),因此卸载通道后,请求会在故障时默认被拒绝。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,并保留 `prepend` 给「决策或委派」门禁。 -`ApprovalRequest` 携带发起请求的 `agent`、`toolName`、可选的精确 `callId`、人类可读的 `reason` 和可选的 `signal`。它使用 `CallId` brand 而不导入依赖本 seam 的 `dsh-tools`。通道适配器可按 `callId` 关联任何更丰富的调用状态;审批请求本身不重复携带工具参数。 +`ApprovalRequest` 携带发起请求的 `agent`、`toolName`、可选的精确 `callId`、人类可读的 `reason` 和可选的 `signal`。它使用 `ToolCallId` brand 而不导入依赖本 seam 的 `dsh-tools`。通道适配器可按 `callId` 关联任何更丰富的调用状态;审批请求本身不重复携带工具参数。 #### dsh-tools 中的 Ask 路由 diff --git a/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.i18n.yaml new file mode 100644 index 0000000000..9e42275b6f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.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-08-06-agent-teams-web.md +2026-08-06-agent-teams-web.md: 5002146f7db67f7199102b286a85da0148864fa9 +2026-08-06-agent-teams-web.zh.md: d05f22141811f35ba52a92a02c8153b024ef8d8d diff --git a/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.md b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.md new file mode 100644 index 0000000000..5002146f7d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.md @@ -0,0 +1,43 @@ +# Agent Note: Experimental Agent Teams Web controls + +Status: implemented + +English | [中文](2026-08-06-agent-teams-web.zh.md) + +## Problem + +The durable Agent Teams runtime owns roster, mailbox, and task state but exposes only model tools and Host service methods. Web users need to inspect teammate activity, manage shared tasks with the same compare-and-set rules, and open a teammate conversation. Agent Teams is still experimental, so these capabilities must not add Team-specific contracts or dependencies to the stable API Proxy, Session Controller, Client UI packages, or Web bundle. + +## Decision + +The private `ctx.agentTeams` service owns generated `agentTeams/view`, `agentTeams/createTask`, and `agentTeams/updateTask` Remote methods beside its domain operations. The Team package owns the browser-safe view and mutation-result types. Views contain roster and current task state but omit pending mailbox content and deleted task tombstones. Create and update rejections cross Remote as closed business results; stale update revisions preserve `team-task-conflict`, while other Team rejections preserve `team-rejected`. Unexpected failures remain ordinary `RemoteResult` failures. + +`@deepseek-ai/dsh-experimental-client-ui-agent-team` mounts the `@deepseek-ai/dsh-experimental-agent-team/remote` contribution through the stable `ctx.remote` service, then consumes the generated `ctx.remote.agentTeams` methods without an additional Client result wrapper. It displays roster status, model and diagnostics and supports task create, edit, dependency update, assignment, completion, reopen, and deletion. Every update sends the displayed revision. Each create or update owns an independent pending token, invalidates older refreshes before starting, and reloads the complete Team view after success. A conflict asks the user to review only after its reload succeeds; a reload failure remains visible. Overlapping refreshes publish only the latest request for the selected Session. + +Teammate navigation uses the existing `{ parentSessionId, childSessionId, mode: 'continuable' }` Subagent address without a Team tag. The UI refreshes the direct-child catalog, rechecks the selected Session, and opens the addressed conversation. History and later human prompts follow the stable Subagent path; the Team mailbox remains reserved for Team peer delivery from Team tools. + +`@deepseek-ai/dsh-experimental-agent-team-web-profile` inserts only the UI after the stable Web bundle. It is applied alongside the Host-side `@deepseek-ai/dsh-experimental-agent-team-profile`, which already inserts `ctx.agentTeams` and the model tools. Neither stable bundle contains disabled Team rows or dependencies. + +Stable Web presets still register continuable Subagent controls inside their preset scope. Top-level Agent Teams profile overrides cannot replace those registrations, so this experimental composition may expose both the Team roster and legacy child controls. A Team-aware Web preset is deferred; the [Web profile README](../../../../packages/experimental/agent-team-web-profile/README.md#known-limitations-and-deferred-work) owns the current limitation. + +## Boundaries + +The Web UI has no mailbox timeline, worktree or Git controls, teammate creation, rename, deletion, interruption, or automatic merge behavior. It does not infer filesystem authority from task ownership or write scopes. A human continuation after teammate navigation is an ordinary addressed-child prompt, not a Team mailbox message. + +## Alternatives considered + +**Extend the legacy API Proxy Team RPC map.** Rejected because it would put an experimental domain in a stable wire package and duplicate the generated Remote vocabulary and validation. + +**Introduce a separate browser Remote service.** Rejected because the methods have no state, lifecycle, or policy owner distinct from `ctx.agentTeams`; a second Cordis service would duplicate Team injection and require another package for the same Typert namespace. + +**Add Team metadata to the stable Subagent address and prompt routing.** Rejected because ordinary child navigation already identifies the conversation. A Team tag would couple stable Client and Subagent contracts to experimental mailbox policy. + +**Put disabled Team rows in the stable Web bundle.** Rejected because a disabled row still creates release dependencies and makes the experimental package part of shipped composition. + +## Testing + +Team-service unit tests, generation, and a plain-Node built-artifact smoke verify the direct Remote methods, error mapping, and exported descriptors. Client typechecking and browser component tests cover the mounted namespace, Lead routing, raw generated results, every task action, independent pending operations, complete-board reloads, successful and failed conflict reloads, stale async results, navigation, disposal, and status or error presentation. A Web end-to-end test asserts that its overlay equals both shipped experimental profile layers, then exercises the real Host Remote flow. + +## Consequences + +The Team service is the single Cordis owner for both domain state and the Remote operations that expose selected Team values. The stable API Proxy, Session Controller, Client UI packages, and Web bundle remain Team-agnostic. Source-checkout users must add two ordered experimental profile layers to a Web profile. Promotion renames the experimental npm packages but does not require a new generated namespace. diff --git a/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.zh.md b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.zh.md new file mode 100644 index 0000000000..d05f221418 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.zh.md @@ -0,0 +1,43 @@ +# Agent Note:实验性 Agent Teams Web 控件 + +状态:已实现 + +[English](2026-08-06-agent-teams-web.md) | 中文 + +## 问题 + +持久 Agent Teams runtime 负责 roster、mailbox 与 task 状态,但只提供模型工具和 Host service method。Web 用户需要查看 teammate 活动、按同样的 compare-and-set 规则管理共享任务,并打开 teammate 会话。Agent Teams 仍处于实验阶段,因此这些能力不能向稳定 API Proxy、Session Controller、Client UI package 或 Web bundle 增加 Team 专用 contract 或依赖。 + +## 决策 + +私有 `ctx.agentTeams` service 除 domain operation 外,还直接负责生成式 `agentTeams/view`、`agentTeams/createTask` 与 `agentTeams/updateTask` Remote method。Team package 负责浏览器安全的 view 与 mutation-result type。View 包含 roster 与当前 task 状态,但不包含 pending mailbox 内容或已删除 task tombstone。Create 与 update rejection 通过封闭 business result 跨越 Remote;过期的 update revision 保留为 `team-task-conflict`,其他 Team rejection 保留为 `team-rejected`。意外 failure 仍是普通 `RemoteResult` failure。 + +`@deepseek-ai/dsh-experimental-client-ui-agent-team` 通过稳定 `ctx.remote` service 挂载 `@deepseek-ai/dsh-experimental-agent-team/remote` contribution,随后直接消费生成式 `ctx.remote.agentTeams` method,不增加 Client result 包装层。它展示 roster status、model 与 diagnostics,并支持 task create、edit、dependency update、assignment、completion、reopen 与 deletion。每次 update 都发送当前显示的 revision。每个 create 或 update 都独立持有 pending token,在开始前使更早的 refresh 失效,并在成功后重新读取完整 Team view。Conflict 仅在其 reload 成功后要求用户检查;如果重新读取失败,则保留该错误。重叠 refresh 只发布所选 Session 的最新请求。 + +Teammate navigation 使用既有 `{ parentSessionId, childSessionId, mode: 'continuable' }` Subagent address,不带 Team tag。UI 刷新直接 child catalog、再次检查所选 Session,然后打开 addressed conversation。History 与后续人类 prompt 使用稳定 Subagent 路径;Team mailbox 只用于 Team 工具发起的 Team peer delivery。 + +`@deepseek-ai/dsh-experimental-agent-team-web-profile` 在稳定 Web bundle 之后只插入 UI。它与 Host 侧 `@deepseek-ai/dsh-experimental-agent-team-profile` 一起应用,后者已经插入 `ctx.agentTeams` 与模型工具。两个稳定 bundle 都不包含禁用的 Team row 或依赖。 + +稳定 Web preset 仍会在自身 preset scope 内注册 continuable Subagent control。顶层 Agent Teams profile override 无法替换这些 registration,因此该实验性 composition 可能同时暴露 Team roster 与 legacy child control。Team-aware Web preset 暂缓实现;[Web profile README](../../../../packages/experimental/agent-team-web-profile/README.zh.md#known-limitations-and-deferred-work)负责记录当前限制。 + +## 边界 + +Web UI 不提供 mailbox timeline、worktree 或 Git control、teammate creation、rename、deletion、interrupt 或自动 merge。它不会从 task ownership 或 write scope 推断文件系统权限。导航到 teammate 后的人类 continuation 是普通 addressed-child prompt,不是 Team mailbox message。 + +## 考虑过的替代方案 + +**扩展 legacy API Proxy Team RPC map。** 拒绝,因为这会把实验性 domain 放入稳定 wire package,并重复生成式 Remote vocabulary 与 validation。 + +**引入独立的浏览器 Remote service。** 拒绝,因为这些 method 没有区别于 `ctx.agentTeams` 的状态、lifecycle 或 policy owner;第二个 Cordis service 会重复 Team injection,并要求另一个 package 提供同一个 Typert namespace。 + +**向稳定 Subagent address 与 prompt routing 添加 Team metadata。** 拒绝,因为普通 child navigation 已经标识会话;Team tag 会让稳定 Client 与 Subagent contract 耦合实验性 mailbox policy。 + +**在稳定 Web bundle 中加入禁用 Team row。** 拒绝,因为禁用 row 仍会产生 release 依赖,并让实验性 package 成为随附 composition 的一部分。 + +## 测试 + +Team service 单元测试、生成流程与 plain-Node built-artifact smoke 校验直接 Remote method、error mapping 与导出 descriptor。Client typecheck 与浏览器 component test 覆盖挂载 namespace、Lead routing、原始生成式 result、所有 task action、独立 pending operation、完整 task board reload、成功及失败的 conflict reload、陈旧 async result、navigation、dispose 与状态或错误呈现。Web 端到端测试先断言 overlay 等于两个随附实验性 profile 层,再运行真实 Host Remote flow。 + +## 后果 + +Team service 是 domain state 与公开选定 Team value 的 Remote operation 的唯一 Cordis owner。稳定 API Proxy、Session Controller、Client UI package 和 Web bundle 保持 Team 无关。源码 checkout 用户必须向 Web profile 添加两个有序 experimental profile 层。Promotion 会重命名实验性 npm package,但不要求新的生成式 namespace。 diff --git a/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.i18n.yaml index 61de3cb841..b547b3a0e9 100644 --- a/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md -2026-08-20-multiline-question-answer-field.md: 5e411d6728205ce1b72ef8355bbf2493f28873eb -2026-08-20-multiline-question-answer-field.zh.md: a19178227e813c78b99529fc3e3186e5cfb98e2a +2026-08-20-multiline-question-answer-field.md: 207a7e5303b6558d7f09fc2684940db4e81ff452 +2026-08-20-multiline-question-answer-field.zh.md: 3c7644675a85e740ad0cb5c299e1f591adbfbac6 diff --git a/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md b/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md index 5e411d6728..207a7e5303 100644 --- a/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md +++ b/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md @@ -22,7 +22,7 @@ Enter continues the flow and submits the batch on the last question, Shift+Enter ## Alternatives considered -**`field-sizing: content`.** Rejected for the same reason [the composer's Safari recovery](../bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md) rejected it: Safari reproduces a stale intrinsic height after a deletion crosses a wrap threshold. The mirror is a plain block whose height Safari computes correctly, and it is already the technique this repository runs in the chat composer. +**`field-sizing: content`.** Rejected for the same reason [the composer's Safari recovery](../../archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md) rejected it: Safari reproduces a stale intrinsic height after a deletion crosses a wrap threshold. The mirror is a plain block whose height Safari computes correctly, and it is the technique the chat composer ran before its Lexical editor. **Resize in JS on every keystroke** — set `height: auto`, read `scrollHeight`, write it back. Rejected: it pays two forced layouts per keystroke and reintroduces the stale-geometry class of defect the mirror avoids, in exchange for no capability the mirror lacks. diff --git a/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.zh.md b/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.zh.md index a19178227e..3c7644675a 100644 --- a/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.zh.md @@ -22,7 +22,7 @@ Enter 继续流程并在最后一题提交整批,Shift+Enter 换行,IME 保 ## Alternatives considered -**`field-sizing: content`。** 与[聊天输入框的 Safari 恢复方案](../bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md)出于同一理由否决:当删除跨过换行阈值后,Safari 会复现陈旧的固有高度。镜像是一个 Safari 能正确计算高度的普通块,而且它已经是本仓库在聊天输入框中运行的技术。 +**`field-sizing: content`。** 与[聊天输入框的 Safari 恢复方案](../../archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)出于同一理由否决:当删除跨过换行阈值后,Safari 会复现陈旧的固有高度。镜像是一个 Safari 能正确计算高度的普通块,而且它是聊天输入框在改用 Lexical 编辑器之前运行的技术。 **在 JS 中逐次按键重算高度**——置 `height: auto`、读 `scrollHeight`、再写回。否决:每次按键要付两次强制布局,并重新引入镜像所避开的那类陈旧几何缺陷,换来的却是镜像本就具备的能力。 diff --git a/.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.i18n.yaml new file mode 100644 index 0000000000..7c0adc21ee --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.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-08-24-folder-reference-pick-vs-drill.md +2026-08-24-folder-reference-pick-vs-drill.md: de5d4930eb86a2fc961044cab8aecb7bcaf87a9c +2026-08-24-folder-reference-pick-vs-drill.zh.md: 773e5b2b7cf54b02bc93638ad20bbdc08f34c3d1 diff --git a/.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.md b/.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.md new file mode 100644 index 0000000000..de5d4930eb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.md @@ -0,0 +1,31 @@ +# Agent Note: Folder references settle on pick; descent moves to an explicit drill verb + +Status: implemented + +English | [中文](2026-08-24-folder-reference-pick-vs-drill.zh.md) + +## Problem + +A directory row in the `@` menu had one verb doing two jobs. Picking it inserted literal `@dir/` text and kept the menu open — the descent path for reaching a file — so a user who wanted the folder *itself* as context never got a settled entity: the token kept its trigger character, stayed editable (typing `123` kept filtering children), and looked nothing like the atomic chip a file pick produces. Field feedback with a competitor screenshot made the expectation concrete: a chosen folder should be as settled as a chosen file. + +## Decision + +Split the two intents into two verbs on the same row, keyboard-mapped to shell-completion instincts: + +- **Settle** (row click / Enter): the directory resolves as an atomic folder chip — the file chip's exact language: folder glyph, `dir/` label, no trigger character, one deletable unit — whose serialized and clipboard form is the canonical `@dir/` mention. Implementation is the `{ insert }` arm the folder path had simply never taken; `appearance: 'folder'` was already supported end to end. +- **Drill** (Tab / the row's trailing chevron): the previous behavior verbatim — literal editable `@dir/` text, menu open on the children. + +The plumbing is one new dimension, not a parallel path: `InputTriggerCandidate.drill?: boolean` advertises the second verb (only `ui-reference` directories set it), `InputTriggerPick.action: 'pick' | 'drill'` reports which one ran, `ArbitrateKey` gains `'tab'`, and the composer keymap registers `KEY_TAB_COMMAND` through the same arbitration helper as the arrows — `'consumed'` prevents default, anything else leaves native focus traversal alone. MenuView renders the chevron only on drill rows (`role="button"` span inside the option, mousedown like the row so composer focus survives, `stopPropagation` so the row's settling pick stays out). + +## Alternatives considered + +- **Settle on menu close** (auto-fold a literal `@dir/` into a chip when the menu dismisses): rejected — the moment an editable token becomes an entity would be invisible and surprising; hand-typed mentions stay honest text. +- **Drill on click, settle via a dedicated row button** (the inverse mapping): rejected — settling is the common intent and deserves the primary gesture; descent is the power-user refinement, which matches Tab. +- **CSS-overpainting the trigger character** on the literal text instead of introducing an entity: rejected earlier for the same reason it failed the folder-glyph fix — a Lexical text node cannot split its trigger character out, and the literal text is not a settled entity anyway. + +## Consequences + +- A picked folder and a picked file are the same species: atomic, glyph-labeled, no `@`, whole-unit deletion; hand-typed `@dir/` remains a plain-text reference with the glyph prefix. +- `onPick` implementations that ignore `action` behave exactly as before (`'pick'` is what every pre-existing path reports); the only behavioral change sits in `ui-reference`'s directory arm. +- Tab is intercepted only while the menu highlights a drill row; everywhere else the browser keeps it, pinned by the keymap-routing spec. +- Coverage: controller arbitration (drill / plain / pick-action), MenuView chevron routing, `ui-reference` verb split, and a real-browser e2e driving all three gestures (Enter settle, Tab drill, chevron drill) against a real workspace directory. diff --git a/.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.zh.md b/.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.zh.md new file mode 100644 index 0000000000..773e5b2b7c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-folder-reference-pick-vs-drill.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 文件夹引用 pick 即选定;下钻移交给显式 drill 动词 + +Status: implemented + +[English](2026-08-24-folder-reference-pick-vs-drill.md) | 中文 + +## 问题 + +`@` 菜单里的目录行用一个动词干两件事。pick 它会插入字面 `@dir/` 文本并保持菜单打开——那是抵达文件的下钻路径——于是想要文件夹*本身*作为上下文的用户永远得不到一个已选定的实体:token 保留触发字符、保持可编辑(继续输入 `123` 会继续筛选子项),与文件 pick 产出的原子 chip 完全不是一个物种。现场反馈附上竞品截图把期望说得很具体:选中的文件夹应当与选中的文件一样"定下来"。 + +## 决策 + +把两个意图拆成同一行上的两个动词,键位对齐 shell 补全直觉: + +- **选定**(点击行主体 / Enter):目录解析为原子 folder chip——与文件 chip 完全同语言:文件夹图标、`dir/` 标签、无触发字符、整体删除一个单位——序列化与剪贴板形式为规范 `@dir/` mention。实现上就是文件夹路径从未走过的 `{ insert }` 分支;`appearance: 'folder'` 端到端早已支持。 +- **钻取**(Tab / 行尾 chevron):原行为原样保留——字面可编辑的 `@dir/` 文本,菜单对子项保持打开。 + +管线上是一个新维度而非平行通路:`InputTriggerCandidate.drill?: boolean` 声明第二动词(只有 `ui-reference` 的目录行设置),`InputTriggerPick.action: 'pick' | 'drill'` 报告实际执行的是哪一个,`ArbitrateKey` 增加 `'tab'`,composer keymap 经与方向键相同的仲裁 helper 注册 `KEY_TAB_COMMAND`——`'consumed'` 才 preventDefault,其余情况原生焦点遍历不受影响。MenuView 只在 drill 行渲染 chevron(option 内的 `role="button"` span,与行同用 mousedown 保住 composer 焦点,`stopPropagation` 把行主体的选定 pick 挡在外面)。 + +## 曾考虑的替代方案 + +- **菜单关闭时选定**(菜单消失时把字面 `@dir/` 自动固化为 chip):否决——可编辑 token 变实体的时机不可见且令人意外;手敲的 mention 保持诚实文本。 +- **点击钻取、行内专用按钮选定**(反向映射):否决——选定是常见意图,应占据主手势;下钻是进阶细化,与 Tab 匹配。 +- **对字面文本 CSS 覆盖触发字符**而不引入实体:早先在文件夹图标修复中已因同一原因否决——Lexical 文本节点无法拆出触发字符,而且字面文本本来就不是已选定的实体。 + +## 后果 + +- pick 出的文件夹与文件是同一物种:原子、带图标标签、无 `@`、整体删除;手敲的 `@dir/` 仍是带图标前缀的纯文本引用。 +- 忽略 `action` 的 `onPick` 实现行为与从前完全一致(既有路径全部报告 `'pick'`);唯一的行为变化在 `ui-reference` 的目录分支。 +- 只有菜单高亮在 drill 行时才拦截 Tab;其余场合浏览器保有该键,由 keymap-routing spec 钉住。 +- 覆盖:controller 仲裁(drill / 普通行 / pick action)、MenuView chevron 路由、`ui-reference` 动词分流,以及在真实工作区目录上驱动全部三个手势(Enter 选定、Tab 钻取、chevron 钻取)的真浏览器 e2e。 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml index 4affd8145c..5c561d9188 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.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/proposed/feature/2026-08-04-task-surface.md -2026-08-04-task-surface.md: 03f79dbc7d40957603885c97883a2de1bb5405d7 -2026-08-04-task-surface.zh.md: 63bb286bd8095f6eb0d3b0d366e62a838ae8bdc9 +2026-08-04-task-surface.md: dbf73976b606a4a45202c1b29b79f5cfbd77ac1c +2026-08-04-task-surface.zh.md: ecf8764b7b1da7a4a874dd78c6f69df80fb36f36 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md index 03f79dbc7d..dbf73976b6 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -142,7 +142,7 @@ type SubmitTaskSurfaceResult = type GetActiveTaskSurfaceResult = | { active: true - callId: CallId + callId: ToolCallId surfaceId: TaskSurfaceId model: TaskSurfaceModelV1 pending: TaskSurfacePendingSubmission | null @@ -166,7 +166,7 @@ The Host resolves the exact successful `show_task_surface` occurrence, revalidat interface TaskSurfaceCorrelation { version: 1 submissionId: TaskSurfaceSubmissionId - callId: CallId + callId: ToolCallId surfaceId: TaskSurfaceId values: Record } @@ -210,7 +210,7 @@ The Session log is the authority. A small `taskSurface` unit in the existing [Se ```ts ignore-check interface TaskSurfaceProjection { - active: { callId: CallId; surfaceId: TaskSurfaceId } | null + active: { callId: ToolCallId; surfaceId: TaskSurfaceId } | null } ``` diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md index 63bb286bd8..ecf8764b7b 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -142,7 +142,7 @@ type SubmitTaskSurfaceResult = type GetActiveTaskSurfaceResult = | { active: true - callId: CallId + callId: ToolCallId surfaceId: TaskSurfaceId model: TaskSurfaceModelV1 pending: TaskSurfacePendingSubmission | null @@ -166,7 +166,7 @@ Host 解析出 `show_task_surface` 的确切成功调用实例,依据其已持 interface TaskSurfaceCorrelation { version: 1 submissionId: TaskSurfaceSubmissionId - callId: CallId + callId: ToolCallId surfaceId: TaskSurfaceId values: Record } @@ -210,7 +210,7 @@ Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'q ```ts ignore-check interface TaskSurfaceProjection { - active: { callId: CallId; surfaceId: TaskSurfaceId } | null + active: { callId: ToolCallId; surfaceId: TaskSurfaceId } | null } ``` diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 03c01e88af..08c7f13f0f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -38,6 +38,10 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | +| [`@lexical/history`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/plain-text`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/text`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/utils`](https://github.com/facebook/lexical) | MIT | | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | MIT | | [`@octokit/webhooks`](https://github.com/octokit/webhooks.js) | MIT | @@ -71,6 +75,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | | [`katex`](https://github.com/KaTeX/KaTeX) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT | +| [`lexical`](https://github.com/facebook/lexical) | MIT | | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | | [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT | @@ -134,6 +139,7 @@ External packages **directly declared** only by repository tooling, test infrast | Package | License | | --- | --- | | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | +| [`@lexical/headless`](https://github.com/facebook/lexical) | MIT | | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index cdc4dbb6d3..d579e8fe89 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-skill' @@ -45,7 +45,7 @@ try { : undefined const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge') const result = await ctx.tools.execute({ - callId: CallId('dsh-badge-snapshot'), + callId: ToolCallId('dsh-badge-snapshot'), name: 'skill', arguments: { name: 'dsh-badge' }, signal: new AbortController().signal, diff --git a/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/control-surface-llm.ts b/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/control-surface-llm.ts index b0858998ee..b438799e38 100644 --- a/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/control-surface-llm.ts +++ b/apps/cli/tests/profiles/acp/tests/fixtures/control-surface/control-surface-llm.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import { - CallId, + ToolCallId, LlmAdapter, ReasoningEffortId, type GenerateOptions, @@ -50,7 +50,7 @@ class ControlSurfaceAdapter extends LlmAdapter { .join('') const hasToolResult = current.some(message => message.content.some(block => block.type === 'tool-result')) if (!hasToolResult) { - const callId = CallId(userText.includes('cancel') ? 'control-cancel-add' : 'control-add') + const callId = ToolCallId(userText.includes('cancel') ? 'control-cancel-add' : 'control-add') yield { type: 'block-start', index: 0, blockType: 'reasoning' } yield { type: 'reasoning-delta', index: 0, text: 'checking the attached tool' } yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'checking the attached tool' } } diff --git a/apps/cli/tests/profiles/headless/tests/code-mode.e2e.ts b/apps/cli/tests/profiles/headless/tests/code-mode.e2e.ts index cca7641575..ca34a38628 100644 --- a/apps/cli/tests/profiles/headless/tests/code-mode.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/code-mode.e2e.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime, { createUserMessage, CallId, HarnessError } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, ToolCallId, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -93,7 +93,7 @@ function runCode( agent?: Agent, ): Promise { return harness.tools.execute({ - callId: CallId(`keyless-code-${++keylessCall}`), + callId: ToolCallId(`keyless-code-${++keylessCall}`), name: RUN_CODE_NAME, arguments: { code, description: 'Run the e2e program' }, signal, diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index 0ec1b9703a..57f0fe3dd2 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -1,6 +1,6 @@ /** Deterministic keyless Agent Teams adapter shared by profile snapshot and CLI e2e. */ -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm' let nextCall = 0 @@ -38,7 +38,7 @@ function latestToolText(messages) { function toolChunks(specs) { const chunks = [] for (const [index, spec] of specs.entries()) { - const id = CallId(`team-fixture-${++nextCall}`) + const id = ToolCallId(`team-fixture-${++nextCall}`) const args = JSON.stringify(spec.args) chunks.push( { type: 'block-start', index, blockType: 'tool-call' }, diff --git a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts index 4e7118b4ec..031ec836ee 100644 --- a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, ToolCallId , createMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' @@ -46,7 +46,7 @@ async function seedInterruptedSession(root: string, cwd: string): Promise { // The preset's own loader tool resolves the global-layer skill. const loaded = await ctx.tools.execute({ - callId: CallId('preset-skills-load'), + callId: ToolCallId('preset-skills-load'), name: 'skill', arguments: { name: 'dsh-badge' }, signal: new AbortController().signal, diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 44232de563..4c0cd69705 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -241,7 +241,7 @@ describe('web e2e: agent-preset selection', () => { // Continues the previous case: the chip has already applied `minimal` to // the blank session, and this one reads the menu that switch left behind. onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog')) - const composer = page.locator('textarea:enabled').last() + const composer = page.locator('[data-composer-input][contenteditable="true"]').last() // `minimal` mounts neither the compaction group nor plan mode nor local // skill discovery, so the catalog the composer warmed under the diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts new file mode 100644 index 0000000000..2601dc20f9 --- /dev/null +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -0,0 +1,109 @@ +// Keyless assembled-browser coverage for the private Agent Teams Web profiles +// over the real Host Typert Remote flow. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { readFileSync } from 'node:fs' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import * as yaml from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url)) +const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md') +const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url)) +const HOST_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url)) +const WEB_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-web-profile/cordis.patch.yml', import.meta.url)) +const INSTALL_ANCHORS = [ + fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/package.json', import.meta.url)), + fileURLToPath(new URL('../../../packages/experimental/agent-team-web-profile/package.json', import.meta.url)), +] +const MODE = webSnapshotMode() + +function profileEntries(path: string): unknown[] { + const parsed = yaml.load(readFileSync(path, 'utf8'), { schema: entryListSchema }) + if (!Array.isArray(parsed)) throw new Error(`profile layer at ${path} must be a list`) + return parsed +} + +describe('Agent Teams panel overlay', () => { + it('matches the shipped Host and Web profile layers', () => { + expect(profileEntries(OVERLAY)).toEqual([ + ...profileEntries(HOST_PATCH), + ...profileEntries(WEB_PATCH), + ]) + }) +}) + +describe('web e2e: Agent Teams panel', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, extraInstallAnchors: INSTALL_ANCHORS }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('connected Team workspace did not create an Agent') + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Open the Agent Team controls.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'Ready.' }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn: 1, step: 1 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByText('Ready.').waitFor({ timeout: 10_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('loads the roster and creates one shared task through generated Remote', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) + const action = page.locator('[data-team-action]') + await action.getByRole('button', { name: /Agent Team/iu }).click() + await action.getByText('No shared tasks yet').waitFor() + await action.getByText('lead').waitFor() + + await action.getByRole('button', { name: 'New task' }).click() + await action.getByPlaceholder('Task subject').fill('Browser task') + await action.getByPlaceholder('Task description').fill('Created through the assembled browser') + await action.getByPlaceholder(/Write scopes/iu).fill('src/web') + await action.getByRole('button', { name: 'Save' }).click() + await action.getByText('Browser task').waitFor() + + const snapshot = await captureStableAria(page, '[data-team-action]', scaffold.workspaceCwd) + await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md']) + }) +}) diff --git a/apps/web/tests/agent-team-panel.overlay.yml b/apps/web/tests/agent-team-panel.overlay.yml new file mode 100644 index 0000000000..ddc86b0947 --- /dev/null +++ b/apps/web/tests/agent-team-panel.overlay.yml @@ -0,0 +1,42 @@ +# Equivalent to the two explicit Agent Teams profile layers over the scaffold's +# stable base and Web bundles. +- id: tool-subagent-control + disabled: true + +- id: tool-subagent-list-agents + disabled: true + +- id: tool-subagent-report + disabled: true + +- id: tool-subagent + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot + +- id: tool-subagent-fork + config: + provider: fork + toolName: subagent_fork + backgroundMode: one-shot + +- insert: + - id: agent-team + name: '@deepseek-ai/dsh-experimental-agent-team' + config: + maxMembers: 8 + maxTasks: 256 + maxPendingMessagesPerMember: 64 + maxMessageBytes: 65536 + disposalTimeoutMs: 5000 + + - id: tool-agent-team + name: '@deepseek-ai/dsh-experimental-tool-agent-team' + config: + freshProvider: spawn + forkProvider: fork + +- insert: + - id: ui-agent-team + name: '@deepseek-ai/dsh-experimental-client-ui-agent-team' diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index d8fe3935c8..235e4cc8c3 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -58,7 +58,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { if (MODE !== 'record') { expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) // Derive the expected cap from the live composer instead of duplicating its pixel value. @@ -136,7 +136,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { await assertFinalWorkspaceSnapshot(SNAPSHOT_DIR, join(scaffold.workspaceCwd, 'workspace')) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(1) expect(await page.locator('[data-approval-key]').count()).toBe(0) - await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 300_000) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index f002ceb9c0..05bfb45a4b 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -198,6 +198,19 @@ let unmount: (() => Promise) | undefined * the boot globals, and the injected plugin styles afterwards. */ export function installAssembledBootEnv(): void { + // jsdom implements no scroll geometry: the trigger menu reveals its + // highlight with scrollIntoView on open, which a pasted leading token now + // reaches in this lane (the editor re-tracks at the settled caret). + if (typeof Element.prototype.scrollIntoView !== 'function') { + Element.prototype.scrollIntoView = () => {} + } + // jsdom implements no Range geometry either: Lexical's selection reveal + // measures the caret with one after a programmatic edit settles focus. + if (typeof Range.prototype.getBoundingClientRect !== 'function') { + Range.prototype.getBoundingClientRect = () => ({ + top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}), + }) + } beforeEach(() => { localStorage.clear() // The locale service derives its provisional locale from the browser and diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index cad3d05aa0..1030369886 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -7,7 +7,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { JobId } from '@deepseek-ai/dsh-jobs' import { @@ -85,7 +85,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { const started = await scaffold.ctx.tools.execute({ signal: new AbortController().signal, - callId: CallId('background-job-list-e2e'), + callId: ToolCallId('background-job-list-e2e'), name: 'bash', arguments: { command: COMMAND, description: 'Hold a background slot open', run_in_background: true }, agent, diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index e18af7c791..fc01177397 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -9,7 +9,7 @@ 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 { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { @@ -32,7 +32,7 @@ interface TurnSpec { readonly firstMarker: string readonly doneMarker: string readonly deltas: readonly string[] - readonly callId?: ReturnType + readonly callId?: ReturnType readonly toolResultMarker?: string } @@ -81,7 +81,7 @@ function turnSpec(index: number): TurnSpec { firstMarker, doneMarker, deltas, - callId: CallId(`continuous-chat-tool-${id}`), + callId: ToolCallId(`continuous-chat-tool-${id}`), toolResultMarker: `CONTINUOUS_CHAT_TOOL_RESULT_${id}`, } } @@ -216,16 +216,16 @@ describe('web e2e: continuous conversation grown through the composer', () => { it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation')) - const composer = page.locator('textarea:enabled').last() + const composer = page.locator('[data-composer-input][contenteditable="true"]').last() await composer.waitFor({ timeout: 15_000 }) let sessionId: SessionId | undefined for (const spec of specs) { const eventStart = sessionEvents.length - expect(await composer.inputValue()).toBe('') + expect(await composer.textContent()).toBe('') expect(await composer.isEnabled()).toBe(true) await composer.fill(spec.prompt) - expect(await composer.inputValue()).toBe(spec.prompt) + expect(await composer.textContent()).toBe(spec.prompt) const settled = scaffold.whenTurnSettled(60_000) await page.getByRole('button', { name: 'Send message', exact: true }).click() @@ -257,7 +257,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) await page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) - await expect.poll(() => composer.inputValue(), { timeout: 10_000 }).toBe('') + await expect.poll(() => composer.textContent(), { timeout: 10_000 }).toBe('') await expect.poll(() => composer.isEnabled(), { timeout: 10_000 }).toBe(true) const turnEvents = sessionEvents.slice(eventStart) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 8ae41f4b2f..9a04a5c3d3 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -320,14 +320,14 @@ describe('web e2e: long Chat interaction contract', () => { .toBe(`${FIXTURE.title} (1)`) await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) const settled = scaffold.whenTurnSettled(60_000) - const composer = page.locator('textarea:enabled').last() + const composer = page.locator('[data-composer-input][contenteditable="true"]').last() await composer.fill(CONTINUE_PROMPT) await page.getByRole('button', { name: 'Send message', exact: true }).click() await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1) expect(await settled).toBe(child.session.id) await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) - expect(await composer.inputValue()).toBe('') + expect(await composer.textContent()).toBe('') expect(await composer.isEnabled()).toBe(true) expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false) expect(child.session.events.filter(event => ( diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 287e3a61f8..e7b1fedfb9 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -9,7 +9,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' @@ -35,7 +35,7 @@ const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST' const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE' const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.' -const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call') +const LIVE_TOOL_CALL_ID = ToolCallId('chat-scroll-live-tool-call') const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT' const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST' const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE' @@ -502,7 +502,7 @@ describe('web e2e: long Chat scroll contract', () => { const settled = world.scaffold.whenTurnSettled(60_000) try { - const composer = world.page.locator('textarea:enabled').last() + const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() await composer.fill(LIVE_TEXT_PROMPT) await world.page.getByRole('button', { name: 'Send message', exact: true }).click() await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 }) @@ -565,7 +565,7 @@ describe('web e2e: long Chat scroll contract', () => { const settled = world.scaffold.whenTurnSettled(60_000) let released = false try { - const composer = world.page.locator('textarea:enabled').last() + const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() await composer.fill(LIVE_TOOL_PROMPT) await world.page.getByRole('button', { name: 'Send message', exact: true }).click() await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true) @@ -707,7 +707,7 @@ describe('web e2e: long Chat scroll contract', () => { RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns), ) await expectBottom(world.page) - const composer = world.page.locator('textarea:enabled').last() + const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() const longDraft = Array.from( { length: 18 }, (_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`, @@ -796,7 +796,7 @@ describe('web e2e: long Chat scroll contract', () => { const settled = world.scaffold.whenTurnSettled(60_000) let released = false try { - const composer = world.page.locator('textarea:enabled').last() + const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() await composer.fill(LIVE_FLING_PROMPT) await world.page.getByRole('button', { name: 'Send message', exact: true }).click() await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true) diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts index caf9cf13d9..ed6c80da68 100644 --- a/apps/web/tests/chat-scroll-fixture.ts +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -3,7 +3,7 @@ // persisted conversations, while unique markers identify semantic rows // without depending on CSS-module names or virtualizer DOM positions. import { - CallId, + ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage, @@ -105,7 +105,7 @@ function appendToolStep( ): void { const calls = [1, 2].map((index) => { const marker = markers.tool(turn, index) - const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`) + const callId = ToolCallId(`chat-scroll-${suffix(turn)}-${String(index)}`) const args = JSON.stringify({ command: `printf '${marker}\\n'`, description: marker, diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 58210a6437..5f83566065 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -53,7 +53,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { if (MODE !== 'record') { expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index 20aeb3a040..cc0b7a01a2 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -13,17 +13,32 @@ import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' installAssembledBootEnv() -/** Open a fresh fixture session and return its composer textarea. */ -async function freshComposer(): Promise { +/** Open a fresh fixture session and return its composer surface. */ +async function freshComposer(): Promise { const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) const start = tree.querySelector('button[aria-label="New session in fixture"]') if (start === null) throw new Error('fixture Workspace new-session action missing') fireEvent.click(start) - return await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) as HTMLTextAreaElement + return await waitFor(() => { + const surface = document.querySelector( + '[data-composer-input][data-placeholder="Describe what you want to build"]', + ) + if (surface === null) throw new Error('composer surface missing') + return surface + }, { timeout: 10_000 }) +} + +/** Type through the clipboard: jsdom carries no editable beforeinput; the + * paste command inserts at the caret, committing a microtask later. */ +async function pasteText(surface: HTMLElement, text: string): Promise { + fireEvent.paste(surface, { + clipboardData: { items: [], getData: () => text }, + }) + await waitFor(() => { expect(surface.textContent).toContain(text) }) } /** Paste one tiny PNG into the composer and wait for its rail thumbnail. */ -async function pasteImage(textarea: HTMLTextAreaElement, name: string): Promise { +async function pasteImage(textarea: HTMLElement, name: string): Promise { const image = new File([new Uint8Array([137, 80, 78, 71])], name, { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { @@ -44,7 +59,7 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft await pasteImage(textarea, 'ref.png') // /echo is a leadingInput fixture command without `input.images`. - fireEvent.change(textarea, { target: { value: '/echo hello' } }) + await pasteText(textarea, '/echo hello') fireEvent.keyDown(textarea, { key: 'Enter' }) // The refusal rides the same transient error banner as other composer @@ -59,7 +74,7 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft expect([...document.querySelectorAll('[role="status"]')] .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false) // The whole envelope is retained: draft text and the rail thumbnail. - expect(textarea.value).toBe('/echo hello') + await waitFor(() => { expect(textarea.textContent).toBe('/echo hello') }) const rail = document.querySelector('[role="group"][aria-label="Pending images"]') expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png']) }) @@ -71,11 +86,11 @@ it('consumes images through a declaring command and clears the composer on succe // /goal declares `input.images` in the fixture catalog; the claim submit // serializes the pasted bytes and the fixture executor admits them. - fireEvent.change(textarea, { target: { value: '/goal rebuild the cathedral' } }) + await pasteText(textarea, '/goal rebuild the cathedral') fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => { - expect(textarea.value).toBe('') + expect(textarea.textContent).toBe('') expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() }, { timeout: 5_000 }) }) @@ -85,11 +100,13 @@ it('submits a bare /plan with an image as an image-only plan request', async () const textarea = await freshComposer() await pasteImage(textarea, 'plan-task.png') - fireEvent.change(textarea, { target: { value: '/plan' } }) + // Trailing separator: a bare '/plan' leaves the caret on the token, where + // the re-track opens the menu and Enter would pick instead of submit. + await pasteText(textarea, '/plan ') fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => { - expect(textarea.value).toBe('') + expect(textarea.textContent).toBe('') expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() }, { timeout: 5_000 }) expect([...document.querySelectorAll('[role="alert"]')] diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index 0913d82e90..c43b919e5d 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -11,7 +11,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { - CallId, + ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage, @@ -233,7 +233,7 @@ function appendToolStep( toolCount: number, ): void { const calls = Array.from({ length: toolCount }, (_, index) => { - const callId = CallId(`perf-call-${String(turn)}-${String(index)}`) + const callId = ToolCallId(`perf-call-${String(turn)}-${String(index)}`) const args = JSON.stringify({ turn, index, @@ -467,7 +467,7 @@ function soakTurn(index: number): ConversationTurnSpec { } function toolStream(index: number, marker: string): StreamChunk[] { - const callId = CallId(`performance-tool-${marker.toLowerCase()}-${String(index)}`) + const callId = ToolCallId(`performance-tool-${marker.toLowerCase()}-${String(index)}`) const args = JSON.stringify({ command: `printf '${marker}\\n'`, description: `Emit performance marker ${String(index)}`, @@ -936,7 +936,7 @@ async function continueConversation( readonly checkpointInterval?: number }, ): Promise { - const composer = world.page.locator('textarea:enabled').last() + const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() await composer.waitFor({ timeout: 15_000 }) const retainedBefore = await retainedBrowserState(cdp, world.page) const checkpoints: RetainedCheckpoint[] = [{ turns: options.startingTurns, state: retainedBefore }] @@ -947,8 +947,8 @@ async function continueConversation( const spec = options.turnSpec(index) const composerFill = await measure(cdp, async () => { await composer.fill(spec.prompt) - await expect.poll(() => composer.inputValue()).toBe(spec.prompt) - return (await composer.inputValue()).length + await expect.poll(() => composer.textContent()).toBe(spec.prompt) + return ((await composer.textContent()) ?? '').length }) expect(composerFill.value).toBe(spec.prompt.length) @@ -1064,11 +1064,11 @@ async function measurePostSoakUserRender( if (spec.toolResultMarker !== undefined) { throw new Error('post-soak render probe must remain a text-only turn') } - const composer = world.page.locator('textarea:enabled').last() + const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() const composerFill = await measure(cdp, async () => { await composer.fill(spec.prompt) - await expect.poll(() => composer.inputValue()).toBe(spec.prompt) - return (await composer.inputValue()).length + await expect.poll(() => composer.textContent()).toBe(spec.prompt) + return ((await composer.textContent()) ?? '').length }) expect(composerFill.value).toBe(spec.prompt.length) diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index 04b13e206e..37a49dc0c0 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -1,6 +1,23 @@ -// Browser geometry for the composer's caret and visible text layers. A -// same-task gap probe detects deferred scroll synchronization that DOM-only -// tests cannot observe. +// Web e2e scenario: a composer draft longer than the 14-line cap scrolls, +// reveals the caret, and holds no second scroll offset. +// +// The composer is ONE contenteditable surface (see +// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): +// the Lexical editor's root carries the glyphs, the selection and the caret +// together, grows with its content, and `[data-input-scroll]` — the +// composer's single scrolling box — caps it at 14 lines. With one surface +// there is no second text layer whose offset could drift from the caret's; +// what remains to pin is the cap, the wheel gesture, and the caret reveals +// (typing at a scrolled end, pasting a long block, a trailing-newline end). +// +// Only a real engine can show any of this. Scrolling is layout: jsdom reports +// `scrollHeight === clientHeight` for every element and never scrolls one, so +// the unit spec in packages/client/ui-conversation/tests/input-bar.client.spec.tsx can +// only assert that the scrollport holds the surface. +// +// Zero model calls: a fresh workspace's blank session already carries a live +// composer, and the scenario only types into it. A stray stream would fail loud +// with NO_ADAPTER. import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -13,118 +30,106 @@ import { import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/composer-draft-scroll', import.meta.url)) -/** Scroll geometry is absent from ARIA snapshots, so this scenario records it directly. */ +/** + * Committed golden of the composer's scroll geometry. The change alters no + * accessible name, so the aria goldens the other scenarios commit are + * byte-identical with and without it; this records the relations instead, + * which makes a shift in the cap or in the reveal behavior a reviewable diff + * rather than an assertion someone has to reconstruct. + */ const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') const MODE = webSnapshotMode() -/** Marks the first and last line so a Range can find them in the backdrop's text. */ +/** Marks the first and last line so the measurement can find their line boxes. */ const FIRST_MARKER = 'FIRST-LINE-MARKER' const LAST_MARKER = 'LAST-LINE-MARKER' /** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */ const DRAFT_LINES = 40 -const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => { +const DRAFT_ROWS = Array.from({ length: DRAFT_LINES }, (_unused, index) => { if (index === 0) return FIRST_MARKER if (index === DRAFT_LINES - 1) return LAST_MARKER return `draft line ${String(index + 1).padStart(2, '0')}` -}).join('\n') +}) -/** - * A draft ending in a newline, where the two layers reserve their - * final line box on different terms. A textarea keeps one for the caret after a - * final newline; `white-space: pre-wrap` collapses a text node's trailing - * newline and generates none. The hidden auto-grow mirror carries the newline - * and so decides the height for both, which is why the backdrop needs no - * padding of its own — but only a draft with a trailing newline can show it. - */ -const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n` - -/** The composer's text layers as the browser lays them out. */ -interface ComposerMetrics { - overflows: boolean - clientHeight: number - visibleLines: number - scrollTop: number - scrollMax: number - inputScrollable: number - /** - * Distance between where the caret sits for a draft line and where the - * backdrop paints that line, in pixels. A fixed value (the difference between - * a line box's top and its glyph box's) is alignment; a value that CHANGES - * with the scroll offset is the defect — the words trailing the caret. - */ - caretGlyphGap: number - /** - * How much the caret-to-glyph gap moves when the offset changes before a - * scroll listener can run. - */ - gapShiftOnScroll: number - lastLineOffset: number - firstLineOffset: number - inputWrapWidth: number - backdropWrapWidth: number - mirrorWrapWidth: number +/** The live composer surface. */ +function surface(page: Page): ReturnType { + return page.locator('[data-composer-input][contenteditable="true"]').first() } /** - * Measure the composer's layers in the page, in the caret's coordinate frame. + * Replace the draft through real gestures: select-all, delete, then insert + * the rows with soft line breaks (the composer's Enter submits). * @param page - the page under test. - * @returns the offset, the caret-to-glyph gap, and where the draft's first and last lines sit. + * @param rows - draft lines; a trailing empty row leaves a trailing newline. + */ +async function typeDraft(page: Page, rows: readonly string[]): Promise { + const input = surface(page) + await input.click() + await page.keyboard.press('ControlOrMeta+KeyA') + await page.keyboard.press('Delete') + for (const [index, row] of rows.entries()) { + if (index > 0) await page.keyboard.press('Shift+Enter') + if (row !== '') await page.keyboard.insertText(row) + } +} + +/** The composer's scroll surface as the browser lays it out. */ +interface ComposerMetrics { + /** True when the draft is taller than the capped box — the situation under test. */ + overflows: boolean + /** Visible height of the scrollport's content box: the cap in pixels. */ + clientHeight: number + /** Whole lines that fit in the visible box, at the composer's own line-height. */ + visibleLines: number + /** The composer's one scroll offset. */ + scrollTop: number + /** Furthest that offset can go. */ + scrollMax: number + /** + * Scrollable overflow the editable surface holds on its own — 0, or a + * second offset exists beside the scrollport's. + */ + surfaceScrollable: number + /** Top of the LAST draft line relative to the visible box's top: at most `clientHeight` when on screen. */ + lastLineOffset: number + /** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */ + firstLineOffset: number +} + +/** + * Measure the composer surface in the page. + * @param page - the page under test. + * @returns the offset, the cap, and where the draft's first and last lines sit. */ function measureComposer(page: Page): Promise { return page.evaluate(({ first, last }) => { - const input = document.querySelector('textarea:enabled') - if (input === null) throw new Error('no live composer textarea in the DOM') + const input = document.querySelector('[data-composer-input][contenteditable="true"]') + if (input === null) throw new Error('no live composer surface in the DOM') const scroll = input.closest('[data-input-scroll]') - if (scroll === null) throw new Error('the composer textarea is not inside a draft scrollport') - const backdrop = input.parentElement?.querySelector('[data-input-backdrop]') - if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea') - // The hidden auto-grow mirror: the textarea's next sibling, and the layer - // that decides the box's height, so its wrap width matters as much as the - // two that carry glyphs. - const mirror = input.nextElementSibling - if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea') - // The draft carries no chips or claim token, so the decoration walk emits it - // as a single text node, which is what the Range below needs. - const text = backdrop.firstChild - if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node') + if (scroll === null) throw new Error('the composer surface is not inside a draft scrollport') const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight) - /** Where the backdrop paints the line holding `marker`, in viewport coordinates. */ + /** Where the surface paints the line holding `marker`, in viewport coordinates. */ const glyphTop = (marker: string): number => { - const at = text.data.indexOf(marker) - if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`) - const range = document.createRange() - range.setStart(text, at) - range.setEnd(text, at + marker.length) - return range.getBoundingClientRect().top + const walker = document.createTreeWalker(input, NodeFilter.SHOW_TEXT) + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + const text = node as Text + const at = text.data.indexOf(marker) + if (at < 0) continue + const range = document.createRange() + range.setStart(text, at) + range.setEnd(text, at + marker.length) + return range.getBoundingClientRect().top + } + throw new Error(`marker ${marker} missing from the composer text`) } - const paddingTop = Number.parseFloat(getComputedStyle(input).paddingTop) - // Where the CARET sits on the draft's first line: the textarea lays its own - // (transparent) glyphs out from its border box, shifted by any offset it - // holds itself. Reading the caret's frame this way rather than the - // scrollport's is what makes the gap the user-visible quantity — it stays - // honest if the textarea ever starts scrolling on its own again. - const gap = (): number => - Math.round(input.getBoundingClientRect().top + paddingTop - input.scrollTop - glyphTop(first)) - // The same-task probe: move the offset and re-read the gap before the task - // ends, which is before any scroll event could have run a listener. - const before = gap() - const restore = scroll.scrollTop - scroll.scrollTop = restore === 0 ? 120 : 0 - const gapShiftOnScroll = Math.abs(gap() - before) - scroll.scrollTop = restore const box = scroll.getBoundingClientRect() return { - inputWrapWidth: input.clientWidth, - backdropWrapWidth: backdrop.clientWidth, - mirrorWrapWidth: mirror.clientWidth, overflows: scroll.scrollHeight > scroll.clientHeight, clientHeight: scroll.clientHeight, visibleLines: Math.floor(scroll.clientHeight / lineHeight), scrollTop: scroll.scrollTop, scrollMax: scroll.scrollHeight - scroll.clientHeight, - inputScrollable: input.scrollHeight - input.clientHeight, - caretGlyphGap: before, - gapShiftOnScroll, + surfaceScrollable: input.scrollHeight - input.clientHeight, lastLineOffset: glyphTop(last) - box.top, firstLineOffset: glyphTop(first) - box.top, } @@ -132,8 +137,14 @@ function measureComposer(page: Page): Promise { } /** - * Render platform-neutral comparisons instead of font-dependent glyph - * coordinates. + * Render the golden body. + * + * Absolute glyph coordinates are deliberately absent: they depend on font + * metrics and would make the fixture fail on a machine that measures text + * differently — a golden that needs re-recording per platform documents the + * platform, not the behavior. What is recorded is the cap, the single-offset + * invariant, and which lines are on screen, each a comparison that survives + * any layout keeping the behavior. * @param top - metrics with the draft scrolled to its start. * @param bottom - metrics with the draft scrolled to its end. * @param trailingNewline - metrics with the trailing-newline draft scrolled to its end. @@ -144,32 +155,26 @@ function renderGeometry( top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics, ): string { return [ - '# Composer draft scrolling (14-line cap, two text layers, one scrollport)', + '# Composer draft scrolling (14-line cap, one editable surface, one scrollport)', '', '## At the start of the draft', '', `- draft overflows the capped box: ${String(top.overflows)}`, `- visible lines: ${String(top.visibleLines)}`, - `- the textarea holds no scroll offset of its own: ${String(top.inputScrollable === 0)}`, - `- all three layers wrap at one width: ${String( - top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth, - )}`, + `- the surface holds no scroll offset of its own: ${String(top.surfaceScrollable === 0)}`, `- scroll offset: ${String(top.scrollTop)}px`, - `- caret and glyphs stay level when the offset changes: ${String(top.gapShiftOnScroll === 0)}`, `- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`, `- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`, '', '## Scrolled to the end of the draft', '', `- offset moved: ${String(bottom.scrollTop > 0)}`, - `- caret sits on its own glyphs: ${String(bottom.caretGlyphGap === top.caretGlyphGap)}`, - `- caret and glyphs stay level when the offset changes: ${String(bottom.gapShiftOnScroll === 0)}`, + `- the surface holds no scroll offset of its own: ${String(bottom.surfaceScrollable === 0)}`, `- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`, `- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`, '', '## Draft ending in a newline, scrolled to the end', '', - `- caret sits on its own glyphs: ${String(trailingNewline.caretGlyphGap === top.caretGlyphGap)}`, `- the draft's own last line is on screen: ${String( trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight, )}`, @@ -177,7 +182,6 @@ function renderGeometry( '## Right after pasting a long block at the end', '', `- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`, - `- caret and glyphs stay level when the offset changes: ${String(pasted.gapShiftOnScroll === 0)}`, `- the pasted block's last line is on screen: ${String( pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight, )}`, @@ -198,7 +202,7 @@ describe('web e2e: composer draft scrolling', () => { await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll') - await page.locator('textarea:enabled').first().fill(DRAFT) + await typeDraft(page, DRAFT_ROWS) }, 180_000) afterAll(async () => { @@ -206,7 +210,7 @@ describe('web e2e: composer draft scrolling', () => { await scaffold?.close() }) - it('caps the draft box and keeps both text layers at the start', async () => { + it('caps the draft box at 14 lines with a single scroll offset', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top')) // Vacuity guard: without an overflowing draft there is nothing to scroll and // every assertion below holds trivially. @@ -214,7 +218,7 @@ describe('web e2e: composer draft scrolling', () => { // Typing the draft left the caret — and the box — at its end, so reach the // start by the same gesture a user would, and leave it there for the wheel // case below. - await page.locator('textarea:enabled').first().hover() + await surface(page).hover() await page.mouse.wheel(0, -2000) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0) const metrics = await measureComposer(page) @@ -222,9 +226,9 @@ describe('web e2e: composer draft scrolling', () => { // 14 x 24px lines). The count, not the pixels: it is the figma constant and // survives a device-pixel-ratio change. expect(metrics.visibleLines).toBe(14) - // One scrolling box: the textarea is as tall as the draft, so there is no - // second offset for the caret to hold while the glyphs hold another. - expect(metrics.inputScrollable).toBe(0) + // One scrolling box: the surface grows with the draft, so it holds no + // second offset beside the scrollport's. + expect(metrics.surfaceScrollable).toBe(0) expect(metrics.scrollTop).toBe(0) expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0) expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight) @@ -232,128 +236,53 @@ describe('web e2e: composer draft scrolling', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('lays out all three text layers at one wrap width', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width')) - // A layer that breaks lines somewhere else puts the words under the wrong - // caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive - // draft. All three share a containing block — the scrollport — so a - // scrollbar that consumes layout space costs them the same width; with - // only the textarea scrolling, WebKit reserves gutter space for it alone - // (768 against 776) while chromium and firefox do not. - const metrics = await measureComposer(page) - expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth) - // The mirror decides the box height, so it belongs in the same equality — - // were it alone to wrap wider, the box would be measured too short and - // clip content before the 14-line cap, with every other assertion green. - expect(metrics.mirrorWrapWidth).toBe(metrics.inputWrapWidth) - expect(tripwire.pageErrors).toEqual([]) - }, 60_000) - - it('the glyphs cannot lag the caret: one task moves both', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-lag')) - // The reported symptom, isolated. A scroll offset changes and the caret's - // distance to its own glyphs is re-read before the task ends — before any - // `scroll` listener could have run. With the layers on one scrollport the - // browser moves both, so the distance is unchanged; with the glyph layer - // catching up in a listener it is off by the whole delta until a later - // frame, which is a caret flying away from its text mid-gesture. - const metrics = await measureComposer(page) - expect(metrics.gapShiftOnScroll).toBe(0) - expect(tripwire.pageErrors).toEqual([]) - }, 60_000) - - it('a wheel gesture over a long draft moves the words, not only the caret', async () => { + it('a wheel gesture over a long draft moves the draft', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel')) - const input = page.locator('textarea:enabled').first() - await input.hover() - const resting = (await measureComposer(page)).caretGlyphGap - // One delta past the whole draft: the box clamps at its own end. - await page.mouse.wheel(0, 2000) + await surface(page).hover() + await page.mouse.wheel(0, 240) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }) .toBeGreaterThan(0) - const metrics = await measureComposer(page) - // The caret is still on its own glyphs after the gesture. - expect(metrics.caretGlyphGap).toBe(resting) - // The reported symptom, stated as what the user sees: the end of the draft - // is on screen and its beginning is not. - expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0) - expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight) - expect(metrics.firstLineOffset).toBeLessThan(0) + const scrolled = await measureComposer(page) + expect(scrolled.firstLineOffset).toBeLessThan(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('typing at the end of a scrolled draft brings the caret back into view', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit')) - // The other way the box moves, and the one that depends on the browser: the - // textarea holds no scroll offset of its own, so revealing the caret after - // an edit is a scroll-into-view that has to walk up to the scrollport. - // Scroll away from the caret first, so the edit has somewhere to bring it - // back from. - const input = page.locator('textarea:enabled').first() - await input.press('End') + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-typing')) + const input = surface(page) + // Put the caret at the very end, scroll the view away from it, then type: + // the editor's own caret reveal must bring the end back on screen. + // Select-all + ArrowRight lands the caret at the document end on every + // platform (Cmd/Ctrl+End is not a caret move in mac contenteditable). + await input.click() + await page.keyboard.press('ControlOrMeta+KeyA') + await page.keyboard.press('ArrowRight') await input.hover() await page.mouse.wheel(0, -2000) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0) - await input.pressSequentially(' tail') - const metrics = await measureComposer(page) - expect(metrics.scrollTop).toBeGreaterThan(0) - expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0) - expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight) - expect(tripwire.pageErrors).toEqual([]) - }, 60_000) - - it('pasting a long block scrolls to the caret it leaves at the end', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste')) - // The composer suppresses the native paste — the machine owns the draft and - // the undo log — and restores the caret programmatically, which reveals - // nothing on its own: in chromium and WebKit the view stays put while the - // caret sits at the end of the pasted block, so the restore scrolls it - // into view; this case pins it. - const input = page.locator('textarea:enabled').first() - await input.fill('one short line') - await input.press('End') - // A real `paste` event carrying real clipboard data, dispatched at the - // textarea: the same event a Cmd-V delivers, and it runs the same handler. - await input.evaluate((el, text) => { - const data = new DataTransfer() - data.setData('text/plain', text) - el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) - // The engines disagree when the draft ends in a newline: the caret - // lands on a line with nothing on it, where chromium reports no client - // rects at all for the collapsed position. - }, `\n${DRAFT}\n`) - await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) - // The restore lands one frame after the machine commits the draft, so the - // box overflows before it moves; waiting on the offset is waiting for the - // behavior itself, and its absence fails this poll. - await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0) - const metrics = await measureComposer(page) - // The caret is at the end of what was pasted, so the draft's last line is - // what has to be on screen. - expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0) - expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight) - expect(metrics.gapShiftOnScroll).toBe(0) + await page.keyboard.insertText(' typed-at-end') + await expect.poll(async () => { + const m = await measureComposer(page) + return m.lastLineOffset >= 0 && m.lastLineOffset < m.clientHeight + }, { timeout: 10_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('a draft ending in a newline scrolls to its true end, not a line above it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline')) - // The layers reserve a final line box on different terms, so the trailing-newline case is - // the one that separates a height every layer agrees on from a box measured - // one line short of the caret's own last position. - const input = page.locator('textarea:enabled').first() - await input.fill(DRAFT_TRAILING_NEWLINE) + // A trailing soft break reserves a final empty line box; the end of the + // draft is below the last glyph line, and scrolling to the end must show it. + await typeDraft(page, [...DRAFT_ROWS, '']) await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) - await input.hover() + await surface(page).hover() await page.mouse.wheel(0, 4000) await expect.poll(async () => { const m = await measureComposer(page) return m.scrollTop === m.scrollMax }, { timeout: 10_000 }).toBe(true) const bottom = await measureComposer(page) - // At the very bottom the glyphs are level with the caret, and the draft's - // own last line — the one before the empty final line — is on screen. - expect(bottom.gapShiftOnScroll).toBe(0) + // At the very bottom the draft's own last line — the one before the empty + // final line — is on screen. expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0) expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight) expect(tripwire.pageErrors).toEqual([]) @@ -361,21 +290,20 @@ describe('web e2e: composer draft scrolling', () => { it('matches the committed composer scroll geometry golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden')) - const input = page.locator('textarea:enabled').first() - // Restore the pristine draft (the edit case appended to it) and return to + // Restore the pristine draft (the edit cases appended to it) and return to // its start, both through ordinary gestures. - await input.fill(DRAFT) - await input.hover() + await typeDraft(page, DRAFT_ROWS) + await surface(page).hover() await page.mouse.wheel(0, -2000) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0) const top = await measureComposer(page) - await input.hover() + await surface(page).hover() await page.mouse.wheel(0, 2000) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }) .toBeGreaterThan(0) const bottom = await measureComposer(page) - await input.fill(DRAFT_TRAILING_NEWLINE) - await input.hover() + await typeDraft(page, [...DRAFT_ROWS, '']) + await surface(page).hover() await page.mouse.wheel(0, 4000) await expect.poll(async () => { const m = await measureComposer(page) @@ -384,14 +312,12 @@ describe('web e2e: composer draft scrolling', () => { const trailingNewline = await measureComposer(page) // The paste path, measured the way a user meets it: a short draft, the // caret at its end, one long block pasted in. - await input.fill('one short line') - await input.press('End') - await input.evaluate((el, text) => { + await typeDraft(page, ['one short line']) + await surface(page).evaluate((el, text) => { const data = new DataTransfer() data.setData('text/plain', text) el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) - // Keep a final glyph so the collapsed caret position has a client rect. - }, `\n${DRAFT}`) + }, `\n${DRAFT_ROWS.join('\n')}`) await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0) const pasted = await measureComposer(page) @@ -400,6 +326,8 @@ describe('web e2e: composer draft scrolling', () => { }, 60_000) it('commits exactly the fixtures it reads', async () => { + // Zero model calls, so the scenario records no session fixture: the geometry + // golden is the whole inventory. await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) }) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index c258520c45..9e378902fc 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -106,7 +106,7 @@ describe('web e2e: Cordis tools use their owned cards', () => { if (MODE !== 'record') { expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STOP_PROMPT]) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const runTurnSettled = scaffold.whenTurnSettled() await input.fill(PROMPT) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index d095448a79..a041f85d14 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -129,7 +129,7 @@ describe('web e2e: the composer model switch is the default for later sessions', it('goes inert when the route the default names stops being served', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked')) - const box = page.locator('textarea[data-input-phase], textarea').first() + const box = page.locator('[data-composer-input]').first() await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true) // What removing the provider on the Models page leaves behind: the saved @@ -139,7 +139,7 @@ describe('web e2e: the composer model switch is the default for later sessions', await scaffold.ctx.settings.replace(settingsNamespace('llm-pi-ai'), { providers: {} }) await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false) - expect(await box.getAttribute('placeholder')).toBe('当前模型不可用,请先选择模型') + expect(await box.getAttribute('data-placeholder')).toBe('当前模型不可用,请先选择模型') // The block is an affordance; the refusal is the Host's. A client that // never disabled anything still cannot start a turn on a dead route. diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index d53338e1a1..4e2e1a6efa 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -91,7 +91,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S it('starts and reloads closed, then stays closed across Session ownership changes', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle')) const settled = scaffold.whenTurnSettled() - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill(PROMPT) await input.press('Enter') await settled diff --git a/apps/web/tests/expected/composer-draft-scroll/geometry.expected.md b/apps/web/tests/expected/composer-draft-scroll/geometry.expected.md index ef08cdfd94..58644e22a8 100644 --- a/apps/web/tests/expected/composer-draft-scroll/geometry.expected.md +++ b/apps/web/tests/expected/composer-draft-scroll/geometry.expected.md @@ -1,31 +1,26 @@ -# Composer draft scrolling (14-line cap, two text layers, one scrollport) +# Composer draft scrolling (14-line cap, one editable surface, one scrollport) ## At the start of the draft - draft overflows the capped box: true - visible lines: 14 -- the textarea holds no scroll offset of its own: true -- all three layers wrap at one width: true +- the surface holds no scroll offset of its own: true - scroll offset: 0px -- caret and glyphs stay level when the offset changes: true - first draft line is on screen: true - last draft line is on screen: false ## Scrolled to the end of the draft - offset moved: true -- caret sits on its own glyphs: true -- caret and glyphs stay level when the offset changes: true +- the surface holds no scroll offset of its own: true - first draft line has scrolled out above: true - last draft line is on screen: true ## Draft ending in a newline, scrolled to the end -- caret sits on its own glyphs: true - the draft's own last line is on screen: true ## Right after pasting a long block at the end - the composer scrolled to the caret it left: true -- caret and glyphs stay level when the offset changes: true - the pasted block's last line is on screen: true diff --git a/apps/web/tests/expected/reference-composer/caret-edits.expected.md b/apps/web/tests/expected/reference-composer/caret-edits.expected.md deleted file mode 100644 index f1f756c367..0000000000 --- a/apps/web/tests/expected/reference-composer/caret-edits.expected.md +++ /dev/null @@ -1,4 +0,0 @@ -draft "@@Research notes " -plain "@" -chip "@Research notes" appearance=session icons=1 -plain " " diff --git a/apps/web/tests/expected/reference-composer/menu.expected.md b/apps/web/tests/expected/reference-composer/menu.expected.md index b5fccf69f5..6668011066 100644 --- a/apps/web/tests/expected/reference-composer/menu.expected.md +++ b/apps/web/tests/expected/reference-composer/menu.expected.md @@ -1,6 +1,9 @@ - listbox "Trigger suggestions": - text: Files & folders - - option "File · reference.txt reference.txt" [selected] + - option "Folder · folderx/ folderx Browse folder" [selected]: + - text: Folder · folderx/ folderx + - button "Browse folder": › + - option "File · reference.txt reference.txt" - text: Session conversations - option "Session · Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}" - option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}" diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index 242b28188c..ac92f5da0c 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -63,7 +63,7 @@ describe('web e2e: /feedback command acknowledgement', () => { // Drift guard: the committed fixture must carry exactly the drive prompt. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) // Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot // miss the turn/end that settles the recorded turn. @@ -82,7 +82,7 @@ describe('web e2e: /feedback command acknowledgement', () => { // command row does not render while a fresh session is still blank) and // the replayed reply is on screen. await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill('/feedback the diff view is unreadable') await input.press('Enter') // The command plane settles without a model turn: the ack row names the diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index 256555944f..2d81b67653 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -84,7 +84,7 @@ describe('web e2e: feedback-gated release under the shipped default mode', () => // Drift guard: the shared fixture must carry exactly the drive prompt. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) @@ -100,7 +100,7 @@ describe('web e2e: feedback-gated release under the shipped default mode', () => onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release')) await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) expect(uploads).toEqual([]) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill('/feedback the diff view is unreadable') await input.press('Enter') @@ -121,7 +121,7 @@ describe('web e2e: feedback-gated release under the shipped default mode', () => it.skipIf(MODE === 'record')('releases only the records since the last handoff on a second feedback', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-suffix')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill('/feedback the second remark') await input.press('Enter') await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(2) diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index 631367886b..06bc22a74b 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -45,7 +45,7 @@ describe('web e2e: goal bar clear convergence', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear')) // Startup reuses the fixture workspace's blank session, keeping this // command independent of alpha's running replay and pending question. - const input = page.getByPlaceholder('Describe what you want to build') + const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build"]') await input.waitFor({ timeout: 10_000 }) await input.fill('/goal guard rapid clear clicks') await input.press('Enter') diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 605c45e1bb..9f4ad2fc53 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -48,10 +48,10 @@ describe('web e2e: /goal human transcript presentation', () => { await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000, }).toBe(1) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill('/goal') await input.press('Enter') - await expect.poll(() => input.inputValue()).toBe('/goal ') + await expect.poll(() => input.textContent()).toBe('/goal ') await input.press('Enter') const commandInput = page.locator('[data-command-input]') diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index 54af9f2da9..18596f923c 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -124,7 +124,7 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = /** Submit the Goal command after arming the two-turn barrier. */ async function runGoal(timeoutMs: number): Promise { - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = whenTurnsSettled(scaffold!, 2, timeoutMs) await input.fill(COMMAND) diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index fcdcc19f0f..6c8107d1bd 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -88,7 +88,13 @@ it('accepts pasted images into the composer rail in order and removes them', asy // Image-only send arming is pinned at package level (input-bar.spec.tsx); // this assembled lane pins the intake chain over the built graph. - const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const textarea = await waitFor(() => { + const surface = document.querySelector( + '[data-composer-input][data-placeholder="Describe what you want to build"]', + ) + if (surface === null) throw new Error('composer surface missing') + return surface + }, { timeout: 10_000 }) const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { @@ -157,7 +163,13 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov const start = tree.querySelector('button[aria-label="New session in fixture"]') if (start === null) throw new Error('fixture Workspace new-session action missing') fireEvent.click(start) - const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const textarea = await waitFor(() => { + const surface = document.querySelector( + '[data-composer-input][data-placeholder="Describe what you want to build"]', + ) + if (surface === null) throw new Error('composer surface missing') + return surface + }, { timeout: 10_000 }) // A file drag anywhere over the page raises the full-viewport overlay whose // desc line carries the projected limits — copy that can only render after @@ -206,7 +218,13 @@ it('renders a host dimension rejection with the projected 2000px limit', async ( if (start === null) throw new Error('fixture Workspace new-session action missing') fireEvent.click(start) - const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const textarea = await waitFor(() => { + const surface = document.querySelector( + '[data-composer-input][data-placeholder="Describe what you want to build"]', + ) + if (surface === null) throw new Error('composer surface missing') + return surface + }, { timeout: 10_000 }) const image = new File([new Uint8Array([137, 80, 78, 71])], 'too-wide.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index e1b62632aa..d8714e9a91 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -21,7 +21,7 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -76,10 +76,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(snapshot).not.toContain('text: Skills') expect(snapshot).not.toContain('text: Subagents') const launchedBox = await menu.boundingBox() - await page.locator('textarea').first().press('Escape') + await page.locator('[data-composer-input]').first().press('Escape') await expect.poll(() => menu.count()).toBe(0) - const input = page.locator('textarea').first() - await input.fill('/') + const input = page.locator('[data-composer-input]').first() + await writeComposerDraft(page, input, '/') await menu.waitFor({ timeout: 10_000 }) const typedBox = await menu.boundingBox() expect(launchedBox).not.toBeNull() @@ -88,13 +88,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(Math.abs( launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height, )).toBeLessThan(1) - await input.fill('/cpt') + await writeComposerDraft(page, input, '/cpt') await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([ 'compactCompact older conversation history', ]) const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE) - await input.fill('') + await writeComposerDraft(page, input, '') await expect.poll(() => menu.count()).toBe(0) }) @@ -106,19 +106,19 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await activePage.goto(activeScaffold.authenticatedUrl, { waitUntil: 'load' }) await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd) - const input = activePage.locator('textarea').first() + const input = activePage.locator('[data-composer-input]').first() await activePage.getByRole('button', { name: 'Commands' }).click() const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' }) await menu.waitFor({ timeout: 10_000 }) await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click() - await expect.poll(() => input.inputValue()).toBe('/plan ') + await expect.poll(() => input.textContent()).toBe('/plan ') await input.press('Enter') const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' }) await planButton.waitFor({ timeout: 10_000 }) // The golden encodes an empty composer, and the button arriving does not // mean the submitted text is gone yet: under load the capture can catch // a textbox still holding `/plan`. - await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('') + await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd) await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE) const planStyle = await planButton.evaluate((element) => { @@ -164,7 +164,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () // The blank frame renders the hero, not the resident composer: the // headline plus the guidance placeholder are the empty state's anchors. await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) if (MODE !== 'record') { await page.getByText('Into the Unknown', { exact: false }).hover() @@ -175,7 +175,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE) } const settled = scaffold.whenTurnSettled() - await input.fill(PROMPT) + await writeComposerDraft(page, input, PROMPT) const observeTurn = async () => { const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 } if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 }) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 553b1565cf..5fd28cd394 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -117,7 +117,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { * can act mid-turn (the cancel scenario's whole point). */ async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType }> { - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold!.whenTurnSettled(timeoutMs) await input.fill(PROMPT) @@ -158,7 +158,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill(RUNNING_DRAFT) const send = page.getByRole('button', { name: 'Send message', exact: true }) await send.waitFor({ timeout: 10_000 }) @@ -166,7 +166,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { const runningDraftSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(RUNNING_DRAFT_EXPECTED, runningDraftSnapshot, MODE) await send.click() - await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('') + await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: RUNNING_DRAFT }) await queuedRow.waitFor({ timeout: 10_000 }) await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor({ timeout: 10_000 }) @@ -179,7 +179,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // Composer recovered; no streaming node lingers. The host settled first // (awaited above), but the abort frame reaches the browser over SSE — the // frozen-partial swap is eventually consistent, so poll rather than count. - await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0) // Golden of the aborted end-state: the prompt bubble plus the frozen // partial ('partial' is the hang entry's replayed prefix) and no more. @@ -199,7 +199,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { expect(turnEndReasons(sessionEvents).at(-1)).toBe('error') // AUTH is outside llm-retry's retryable set: no retry record. expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0) - await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) expect(await page.locator('[data-streaming="true"]').count()).toBe(0) const errorStatus = page.getByRole('status').filter({ hasText: 'This turn failed' }) await errorStatus.waitFor({ timeout: 10_000 }) @@ -288,7 +288,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('error') expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(2) - await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) expect(await page.locator('[data-streaming="true"]').count()).toBe(0) // The terminal error row must render even though the turn owns a retry // chain: exhausted recovery shares the failing turn, so suppressing the diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index 75aec1c7e3..ececcbd5bf 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -5,7 +5,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -85,14 +85,14 @@ describe('minimal agent preset', () => { const signal = new AbortController().signal await scaffold.ctx.tools.execute({ signal, - callId: CallId('minimal-bash-state-setup'), + callId: ToolCallId('minimal-bash-state-setup'), name: 'bash', arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` }, agent: agentHandle.agent, }) const bash = await scaffold.ctx.tools.execute({ signal, - callId: CallId('minimal-bash-state-read'), + callId: ToolCallId('minimal-bash-state-read'), name: 'bash', arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' }, agent: agentHandle.agent, @@ -101,7 +101,7 @@ describe('minimal agent preset', () => { await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n') const editor = await scaffold.ctx.tools.execute({ signal, - callId: CallId('minimal-editor-smoke'), + callId: ToolCallId('minimal-editor-smoke'), name: 'str_replace_editor', arguments: { command: 'view', path: seedPath }, agent: agentHandle.agent, diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 6ba5df86c9..ffc1084d9e 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -156,7 +156,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-record')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) let sessionId: Awaited> | undefined for (const prompt of [PROMPT_TURN1, PROMPT_TURN2]) { @@ -335,7 +335,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await ensureSeedOpen(observer) try { - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 }) await input.fill('/export') await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/permission-policy-context.e2e.ts b/apps/web/tests/permission-policy-context.e2e.ts index 5ff6141af8..3ea7507550 100644 --- a/apps/web/tests/permission-policy-context.e2e.ts +++ b/apps/web/tests/permission-policy-context.e2e.ts @@ -94,7 +94,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () => expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() let sessionId: Awaited> | undefined for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) { await input.fill(`/permission ${preset}`) diff --git a/apps/web/tests/plan-control-row.e2e.ts b/apps/web/tests/plan-control-row.e2e.ts index dd55656d61..381f079725 100644 --- a/apps/web/tests/plan-control-row.e2e.ts +++ b/apps/web/tests/plan-control-row.e2e.ts @@ -77,7 +77,7 @@ describe('web e2e: plan chip click area at the narrow viewport', () => { it('keeps the plan chip and model trigger disjoint and exits plan mode by click', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-narrow-viewport')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) await input.fill('/plan ') await input.press('Enter') diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 8b4dc3afd4..6e1f410fba 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -68,7 +68,7 @@ describe('web e2e: plan review takeover round trip', () => { if (MODE !== 'record') { expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([TASK]) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000) await input.fill(LINE) @@ -108,7 +108,7 @@ describe('web e2e: plan review takeover round trip', () => { // Card gone; regular input restored. expect(await page.locator('[data-plan-review-key]').count()).toBe(0) expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) - await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index dd8daa9dad..0d054a293d 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -310,7 +310,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { const configureLater = page.getByRole('button', { name: 'Configure later' }) await configureLater.waitFor({ timeout: 30_000 }) await configureLater.click() - await page.locator('textarea:enabled[placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][data-placeholder="Describe what you want to build"]') .waitFor({ timeout: 30_000 }) const exercised = await page.evaluate(async () => { diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts index f89cfe53b3..9e1772cdce 100644 --- a/apps/web/tests/produced-file-mentions.e2e.ts +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -9,7 +9,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { ToolCallId, createAssistantMessage, createToolResultMessage, 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 { @@ -46,7 +46,7 @@ function mentionFixture(): string { session.append('step/start', { turn: 1, step: 1 }) const calls = WRITES.map((path, index) => ({ path, - callId: CallId(`file-mention-${String(index)}`), + callId: ToolCallId(`file-mention-${String(index)}`), args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }), })) session.append('assistant/message', { diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 3c6ebbf526..81259cf365 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' -import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { ToolCallId, createAssistantMessage, createToolResultMessage, 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 { @@ -49,7 +49,7 @@ function producedFixture(): string { session.append('step/start', { turn: 1, step: 1 }) const calls = PRODUCED.map((path, index) => ({ path, - callId: CallId(`produced-files-${String(index)}`), + callId: ToolCallId(`produced-files-${String(index)}`), args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }), })) session.append('assistant/message', { diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index b63815975a..8bd3ddb92b 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -91,7 +91,7 @@ describe('web e2e: resident question composer round trip', () => { if (MODE !== 'record') { expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000) await input.fill(PROMPT) @@ -220,7 +220,7 @@ describe('web e2e: resident question composer round trip', () => { // Composer gone; regular input restored. expect(await page.locator('[data-question-key]').count()).toBe(0) expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) - await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) // Golden of the answered transcript: the ask_user_question round trip // rendered as history (question tool row + DONE), composer takeover gone. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index d026aa1110..637b542084 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -86,13 +86,15 @@ describe('web e2e: queue row actions', () => { await connectFreshWorkspace(page, scaffold.workspaceCwd) onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() const firstSettled = scaffold.whenTurnSettled() await input.fill(ACTIVE_PROMPT) await input.press('Enter') await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true) for (const text of [REMOVE, EDIT]) { + // A just-submitted composer is read-only for the prompt round-trip. + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(text) await input.press('Enter') } @@ -131,7 +133,7 @@ describe('web e2e: queue row actions', () => { expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1) await page.setViewportSize({ width: 1680, height: 1000 }) - const editRow = page.getByText(EDIT, { exact: true }).locator('..') + const editRow = page.locator('[data-queue-dock] li', { hasText: EDIT }) await editRow.getByRole('button', { name: 'Edit queued message' }).click() const editor = page.getByRole('textbox', { name: 'Edit queued message' }) await editor.fill(EDITED) @@ -140,7 +142,7 @@ describe('web e2e: queue row actions', () => { await page.getByRole('button', { name: 'Save queued message' }).click() await page.getByText(EDITED, { exact: true }).waitFor() - const removeRow = page.getByText(REMOVE, { exact: true }).locator('..') + const removeRow = page.locator('[data-queue-dock] li', { hasText: REMOVE }) await removeRow.getByRole('button', { name: 'Remove queued message' }).click() await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0) @@ -196,8 +198,9 @@ describe('web e2e: queue row actions', () => { await connectFreshWorkspace(page, scaffold.workspaceCwd) onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() const settled = scaffold.whenTurnSettled() + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill('/goal Keep the composer context panels aligned') await input.press('Enter') await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true) @@ -214,6 +217,8 @@ describe('web e2e: queue row actions', () => { await page.locator('[data-testid="todo-panel"]').waitFor({ timeout: 10_000 }) for (const text of ['Layout queue first', 'Layout queue second']) { + // A just-submitted composer is read-only for the prompt round-trip. + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(text) await input.press('Enter') } diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 1e052462f5..3f170b6558 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -1,7 +1,7 @@ // Web e2e scenario: the shipped composition discovers local files and cold // sessions through the real Host, groups both domains in the shared @ menu, // and projects each pick as a complete inline range without issuing a model call. -import { writeFile } from 'node:fs/promises' +import { mkdir, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -25,12 +25,11 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/reference-composer', import.meta.url)) const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') const ORDER_EXPECTED = join(SNAPSHOT_DIR, 'order.expected.md') -const CARET_EXPECTED = join(SNAPSHOT_DIR, 'caret-edits.expected.md') const MODE = webSnapshotMode() const SOURCE_SESSION_ID = 'reference-source-session' const TARGET_SESSION_ID = 'reference-order-target-session' @@ -111,29 +110,6 @@ function targetSessionFixture(): string { ].join('\n') } -/** - * Project the composer backdrop into one stable block: the draft it paints and - * each segment in draft order, with the decoration a segment carries. - * @param page - the assembled app page. - * @returns the golden text for the composer's decoration layer. - */ -async function composerSegments(page: Page): Promise { - return page.evaluate(() => { - const backdrop = document.querySelector('[data-input-backdrop]') - const textarea = document.querySelector('textarea') - if (backdrop === null || textarea === null) return 'composer absent' - const rows = [...backdrop.childNodes].map((node) => { - if (!(node instanceof HTMLElement)) return `plain ${JSON.stringify(node.textContent ?? '')}` - const decoration = node.dataset['decoration'] ?? 'unknown' - const appearance = node.dataset['referenceAppearance'] - const icons = node.querySelectorAll('svg').length - return `${decoration.padEnd(8)} ${JSON.stringify(node.textContent ?? '')}` - + `${appearance === undefined ? '' : ` appearance=${appearance}`} icons=${icons}` - }) - return [`draft ${JSON.stringify(textarea.value)}`, ...rows].join('\n') - }) -} - describe.skipIf(MODE === 'record')('web e2e: file and session references through the real host', () => { let scaffold: WebScaffold let browser: Browser @@ -147,10 +123,16 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) + // Fixture files land before the workspace connects so the Host's file + // index never races their creation (the connect helper mkdirs the same + // directory and tolerates it existing). + await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) + await writeFile(join(scaffold.workspaceCwd, 'workspace', 'reference.txt'), 'reference fixture\n') + await mkdir(join(scaffold.workspaceCwd, 'workspace', 'folderx'), { recursive: true }) + await writeFile(join(scaffold.workspaceCwd, 'workspace', 'folderx', 'child.txt'), 'child fixture\n') await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) - await writeFile(join(scaffold.workspaceCwd, 'workspace', 'reference.txt'), 'reference fixture\n') }, 120_000) afterAll(async () => { @@ -160,7 +142,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through it('groups both sources and projects files and sessions as structured inline icon labels', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-composer')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) await input.fill('@') @@ -176,53 +158,121 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await input.fill('@reference') await menu.getByRole('option', { name: /File \u00b7 reference\.txt/ }).click() - const fileReference = page.locator('[data-reference-appearance="file"]') - await expect.poll(() => fileReference.textContent()).toBe('@reference.txt') + // The pick lands an atomic chip: a real DOM capsule carrying the domain + // icon and the label (the canonical reference text lives on the node and + // expands on submit; the surface text is the label plus the separator). + const fileReference = page.locator('[data-composer-chip]').last() + await expect.poll(() => fileReference.textContent()).toBe('reference.txt') await expect.poll(() => fileReference.locator('svg').count()).toBe(1) - await expect.poll(() => input.inputValue()).toBe('@reference.txt ') + await expect.poll(() => input.textContent()).toBe('reference.txt ') await input.fill('@Research') await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click() - const sessionReference = page.locator('[data-reference-appearance="session"]') - await expect.poll(() => sessionReference.textContent()).toBe('@Research notes') + const sessionReference = page.locator('[data-composer-chip]').last() + await expect.poll(() => sessionReference.textContent()).toBe('Research notes') await expect.poll(() => sessionReference.locator('svg').count()).toBe(1) - await expect.poll(() => input.inputValue()).toBe('@Research notes ') + await expect.poll(() => input.textContent()).toBe('Research notes ') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) - it('keeps a structured reference across caret edits in front of it', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-caret-edits')) - const input = page.locator('textarea').first() + it('typing a trigger directly ahead of a chip inserts without disturbing it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-type-ahead')) + const input = page.locator('[data-composer-input]').first() const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) - const sessionReference = page.locator('[data-reference-appearance="session"]') - await input.fill('@Research') - await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click() - await expect.poll(() => input.inputValue()).toBe('@Research notes ') + await input.fill('@reference') + await menu.getByRole('option', { name: /File · reference\.txt/ }).click() + await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1) - // Only the caret is placed programmatically; both edits below are real key - // presses, which is the whole point — the range a textarea reports for them - // is what the composer has to read, and no synthetic event can stand in. - await input.evaluate((el: HTMLTextAreaElement) => { el.focus(); el.setSelectionRange(0, 0) }) - await input.press('@') - await expect.poll(() => input.inputValue()).toBe('@@Research notes ') - await expect.poll(() => sessionReference.count()).toBe(1) - await expect.poll(() => sessionReference.textContent()).toBe('@Research notes') - await expect.poll(() => sessionReference.locator('svg').count()).toBe(1) + // The #2813 gesture: collapse the caret to the document start, directly + // ahead of the chip, and open the trigger menu there. + await input.click() + await page.keyboard.press('ControlOrMeta+A') + await page.keyboard.press('ArrowLeft') + await page.keyboard.type('@Research') + await menu.getByRole('option', { name: /Session · Research notes/ }).click() - // The decoration layer is aria-hidden, so the accessibility tree cannot see - // the chip; the golden projects the backdrop's own segments instead, which - // is where the surviving reference is observable at all. - await compareOrRefreshGolden(CARET_EXPECTED, await composerSegments(page), MODE) + // Both chips survive the boundary insert: the session chip lands ahead of + // the intact file chip. + const chips = input.locator('[data-composer-chip]') + await expect.poll(() => chips.count()).toBe(2) + await expect.poll(() => chips.first().textContent()).toBe('Research notes') + await expect.poll(() => chips.last().textContent()).toBe('reference.txt') + await expect.poll(() => input.textContent()).toBe('Research notes reference.txt ') - // The caret sits after the typed trigger; this Backspace removes it with a - // collapsed selection, the gesture the reported range cannot describe alone. - await input.press('Backspace') - await expect.poll(() => input.inputValue()).toBe('@Research notes ') - await expect.poll(() => sessionReference.count()).toBe(1) - await expect.poll(() => sessionReference.textContent()).toBe('@Research notes') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('arrows step across a chip in one move and Backspace removes it whole', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-keyboard')) + const input = page.locator('[data-composer-input]').first() + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + + await input.fill('@reference') + await menu.getByRole('option', { name: /File · reference\.txt/ }).click() + await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1) + + // First ArrowLeft crosses the trailing space; the second steps across the + // chip in one move — no keyboard-selected intermediate state — and typing + // continues normally on the far side. + await page.keyboard.press('ArrowLeft') + await page.keyboard.press('ArrowLeft') + await page.keyboard.type('pre') + await expect.poll(() => input.textContent()).toBe('prereference.txt ') + await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1) + + // A collapsed Backspace directly ahead of the chip removes the typed + // character only; the chip's identity is untouched (#2814's gesture). + await page.keyboard.press('Backspace') + await expect.poll(() => input.textContent()).toBe('prreference.txt ') + await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1) + + // ArrowRight steps back across the chip; Backspace directly behind it + // removes the whole chip in one keystroke. + await page.keyboard.press('ArrowRight') + await page.keyboard.press('Backspace') + await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(0) + await expect.poll(() => input.textContent()).toBe('pr ') + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('settles a folder as an atomic chip; Tab and the chevron drill instead', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-folder')) + const input = page.locator('[data-composer-input]').first() + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + + // Settle: Enter on the highlighted folder row resolves the folder itself + // as an atomic chip — folder glyph, no trigger character, one unit. + await writeComposerDraft(page, input, '@folderx') + // First folder query on this page: allow the Host index a cold start. + await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor({ timeout: 60_000 }) + await page.keyboard.press('Enter') + const chip = input.locator('[data-composer-chip]').last() + await expect.poll(() => chip.textContent()).toBe('folderx/') + await expect.poll(() => chip.locator('svg').count()).toBe(1) + await expect.poll(() => input.textContent()).toBe('folderx/ ') + + // Tab drills: the literal descent text stays editable and the open menu + // lists the folder's children. + await writeComposerDraft(page, input, '@folderx') + await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor() + await page.keyboard.press('Tab') + await expect.poll(() => input.textContent()).toBe('@folderx/') + await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor() + + // The row chevron drills the same way by pointer. + await writeComposerDraft(page, input, '@folderx') + const row = menu.getByRole('option', { name: /Folder · folderx\// }) + await row.waitFor() + await row.getByRole('button', { name: 'Browse folder' }).click() + await expect.poll(() => input.textContent()).toBe('@folderx/') + await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor() + await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -244,6 +294,6 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes')) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['caret-edits.expected.md', 'menu.expected.md', 'order.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md']) }) }) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 6325e202b2..7413576885 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -14,7 +14,7 @@ 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 { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, @@ -67,7 +67,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { // Drift guard: the committed fixture must carry exactly the drive prompt. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) // Arm the host-side settled barrier BEFORE the send click. const settled = scaffold.whenTurnSettled() @@ -99,7 +99,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) const result = await scaffold.ctx.tools.execute({ signal: AbortSignal.timeout(5_000), - callId: CallId('web-url-probe'), + callId: ToolCallId('web-url-probe'), name: 'bash', arguments: { command: 'printf \'%s\\n\' "$DSH_WEB_URL"', diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 068afbec66..c818d10b6c 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -54,6 +54,7 @@ import { composeEntries, healProfilesModuleFallback, loadOverlayPatches, + type Profile, } from '@deepseek-ai/dsh-app-boot' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -237,6 +238,11 @@ export interface LaunchOptions { * ordering. */ extraOverlayPath?: string + /** + * Additional source-checkout package manifests whose dependency closures + * supply private profile layers named by {@link extraOverlayPath}. + */ + extraInstallAnchors?: string[] /** * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row * in replay/refresh modes; ignored in record mode (the real adapter @@ -571,11 +577,35 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise/profiles. - await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, home: harnessHome }) const profileDir = join(harnessHome, 'profiles', 'scaffold') + const extraLayers: Profile['layers'] = await Promise.all((options.extraInstallAnchors ?? []).map(async (anchor) => { + const manifest = JSON.parse(await readFile(anchor, 'utf8')) as { name?: unknown } + if (typeof manifest.name !== 'string' || manifest.name === '') { + throw new Error(`web scaffold extra install anchor has no package name: ${anchor}`) + } + const packageDir = dirname(anchor) + return { + packageName: manifest.name, + packageDir, + patchPath: join(packageDir, 'cordis.patch.yml'), + patches: [], + } + })) + // Mirror the production launcher: the shared installation closure keeps + // its carrier-specific fallback, while private bundle dependencies stay + // isolated to this synthetic scaffold profile. + await healProfilesModuleFallback({ + installAnchor: INSTALL_ANCHOR, + home: harnessHome, + profile: { + name: 'scaffold', + dir: profileDir, + layers: extraLayers, + patchPath: join(profileDir, 'cordis.patch.yml'), + patches: [], + patchReload: 'startup', + }, + }) await mkdir(profileDir, { recursive: true }) const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 9b0b407464..5a15831128 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -6,7 +6,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { ToolCallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -121,7 +121,7 @@ class BrowserZoneAtAdapter extends LlmAdapter { this.selectedAt = localAt(target, AT_BROWSER_ZONE) this.scheduledAt = new Date(target).toISOString() const argumentsJson = JSON.stringify({ prompt: AT_PROMPT, at: this.selectedAt }) - const callId = CallId('schedule-at-browser-zone') + const callId = ToolCallId('schedule-at-browser-zone') yield { type: 'block-start', index: 0, blockType: 'tool-call' } yield { type: 'tool-call-delta', @@ -258,7 +258,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { await workspace.attachSession(afterHandle.agent.id) const afterCreated = await scaffold.ctx.tools.execute({ signal: AbortSignal.timeout(10_000), - callId: CallId('schedule-after-create'), + callId: ToolCallId('schedule-after-create'), name: 'schedule_create', arguments: { prompt: AFTER_PROMPT, after_seconds: 1 }, agent: afterHandle.agent, @@ -314,7 +314,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { await workspace.attachSession(everyHandle.agent.id) const everyListed = await scaffold.ctx.tools.execute({ signal: AbortSignal.timeout(10_000), - callId: CallId('schedule-every-list'), + callId: ToolCallId('schedule-every-list'), name: 'schedule_list', arguments: {}, agent: everyHandle.agent, @@ -357,7 +357,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { const atSession = page.getByRole('treeitem', { name: /Explicit local-time reminder/ }) await atSession.waitFor({ timeout: 15_000 }) await atSession.click() - const composer = page.locator('textarea:enabled').last() + const composer = page.locator('[data-composer-input][contenteditable="true"]').last() await composer.fill(AT_USER_PROMPT) const settled = scaffold.whenTurnSettled(60_000) await page.getByRole('button', { name: 'Send message', exact: true }).click() diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 07d5810185..3349eb17b5 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -219,7 +219,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE !== 'record')('records the seed turn live through the composer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-record')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) @@ -492,7 +492,7 @@ describe('web e2e: seeded history renders through cold resume', () => { const previousDshHome = process.env.DSH_HOME process.env.DSH_HOME = scaffold.harnessHome try { - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill('/feedback the diff view is unreadable') await input.press('Enter') const row = page.locator('[data-variant="others"]').filter({ diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 41ebdb823b..f100e045c3 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -7,7 +7,7 @@ import { readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { afterEach, expect, it } from 'vitest' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -207,7 +207,7 @@ it('lets a preset producer reach the background-job registry', async () => { // fails here — with every task control still listed in the catalog above. const started = await ctx.tools.execute({ signal, - callId: CallId('shipped-bash-background'), + callId: ToolCallId('shipped-bash-background'), name: 'bash', arguments: { command: 'printf SHIPPED_BACKGROUND_OK', @@ -225,7 +225,7 @@ it('lets a preset producer reach the background-job registry', async () => { // owner. A per-preset registry would list nothing here even on success. const listed = await ctx.tools.execute({ signal, - callId: CallId('shipped-task-list'), + callId: ToolCallId('shipped-task-list'), name: 'job_list', arguments: {}, agent: handle.agent, @@ -239,7 +239,7 @@ it('lets a preset producer reach the background-job registry', async () => { // through a preset-plane control, which is the linkage the realm severed. const collected = await ctx.tools.execute({ signal, - callId: CallId('shipped-task-output'), + callId: ToolCallId('shipped-task-output'), name: 'job_output', arguments: { job_id: 'bash-1', wait: true }, agent: handle.agent, diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index de0a838417..2de45d3f9b 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -95,7 +95,7 @@ describe('web e2e: skill invocation policy through the real host', () => { it('renders every user-invocable skill and marks the user-only entry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill('/policy') const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) await expect.poll( diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index 04c7d6880c..1248d12df9 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -98,7 +98,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke')) - const composer = page.locator('textarea:enabled').last() + const composer = page.locator('[data-composer-input][contenteditable="true"]').last() await composer.waitFor({ timeout: 15_000 }) // The menu lists the user-only skill (its only entry point) before enter. diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a5c5de075f..ba28fd09ad 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -743,7 +743,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke // step mounts and the page is immediately interactive. // Fresh world: connect a Workspace so the composer starts live. await connectFreshWorkspace(page, sessionsDir) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const productTitle = await page.title() await screen(page, '02-empty-state') @@ -803,7 +803,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke it('bash differential rendering: tool row click leaves the default details column closed', async () => { onTestFailed(() => saveFailureShot(page, 'w5-tool-details')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果') await input.press('Enter') // Wait for the tool ROW, not response text (the reply echoes any marker). diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md new file mode 100644 index 0000000000..7b7eecd5d0 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -0,0 +1,29 @@ +- button "Agent Team" [expanded]: + - img + - text: Agent Team +- dialog "Agent Team": + - strong: Agent Team + - button "Refresh Team": + - img + - button "Close": + - img + - heading "Members" [level=3] + - 'button "lead Idle · Model: deepseek-v4-flash" [disabled]' + - heading "Shared tasks" [level=3] + - button "New task": + - img + - text: New task + - article: + - strong: Browser task + - text: Pending + - paragraph: Created through the assembled browser + - text: "task-1 Ready Write scopes: src/web Owner" + - combobox "Owner": + - option "Unowned" [selected] + - option "lead" + - button "Edit": + - img + - text: Edit + - button "Delete": + - img + - text: Delete diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index 7cfc3e68b2..e257aa031d 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -54,7 +54,7 @@ describe('web e2e: startup auto-selection', () => { workspaceChip: document.querySelector('[aria-label="Choose workspace"]'), scrollBody: document.querySelector('[data-conversation-scroll]'), composerSeat: document.querySelector('[data-composer-seat]'), - textarea: document.querySelector('textarea'), + composer: document.querySelector('[data-composer-input]'), } if (Object.values(refs).some(node => node === null)) throw new Error('incomplete initial Hero tree') ;(window as unknown as { __heroTree: typeof refs }).__heroTree = refs @@ -72,8 +72,8 @@ describe('web e2e: startup auto-selection', () => { workspaceChip: document.querySelector('[aria-label="Choose workspace"]') === before.workspaceChip, scrollBody: document.querySelector('[data-conversation-scroll]') === before.scrollBody, composerSeat: document.querySelector('[data-composer-seat]') === before.composerSeat, - textarea: document.querySelector('textarea') === before.textarea, - textareaEnabled: !(document.querySelector('textarea') as HTMLTextAreaElement).disabled, + composer: document.querySelector('[data-composer-input]') === before.composer, + composerEnabled: document.querySelector('[data-composer-input]')?.getAttribute('aria-disabled') !== 'true', } })).toEqual({ phase: 'hero', @@ -81,8 +81,8 @@ describe('web e2e: startup auto-selection', () => { workspaceChip: true, scrollBody: true, composerSeat: true, - textarea: true, - textareaEnabled: true, + composer: true, + composerEnabled: true, }) expect(tripwire.pageErrors).toEqual([]) }, 120_000) @@ -121,15 +121,21 @@ describe('web e2e: startup auto-selection', () => { await page.reload({ waitUntil: 'commit' }) await openingInFlight + // The frame a user sees while the session is still opening: hero phase, the + // hero title, and a composer that is actually painted (`settling` hides the + // seat with `visibility:hidden`, which Playwright reports as not visible). await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') expect(await page.getByText('Into the Unknown').isVisible()).toBe(true) - expect(await page.locator('textarea').first().isVisible()).toBe(true) + expect(await page.locator('[data-composer-input]').first().isVisible()).toBe(true) releaseOpening() - await page.locator('textarea:enabled[placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]') .waitFor({ timeout: 15_000 }) acknowledgeReloadConnectionLoss(tripwire, warningsBefore) + + // Settling is not merely absent from the frame sampled above: the root + // never entered it at any point of the load. expect(await recordedPhases(page)).toEqual(['hero']) expect(tripwire.pageErrors).toEqual([]) } finally { diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 30a4afb832..41f42aaf31 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -96,9 +96,10 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // both the opening prompt and the later same-turn steer. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STEER]) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000) + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(PROMPT) await input.press('Enter') await expect.poll( @@ -108,6 +109,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // Enter remains the Queue gesture. The row action then atomically moves // this exact occurrence into the current turn's steering outbox. + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(STEER) await input.press('Enter') const queuedRow = page.getByRole('listitem').filter({ hasText: STEER }) @@ -204,16 +206,18 @@ describe('web e2e: composer shortcut steers directly', () => { it.skipIf(MODE === 'record')('uses Cmd+Enter without creating a Queue row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-steering')) expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STEER]) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled(30_000) + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(PROMPT) await input.press('Enter') await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 }) + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(STEER) await input.press('Meta+Enter') - await expect.poll(() => input.inputValue(), { timeout: 5_000 }).toBe('') + await expect.poll(() => input.textContent(), { timeout: 5_000 }).toBe('') expect(await page.locator('[data-queue-dock]').count()).toBe(0) const composer = page.locator('[data-question-key]') @@ -267,13 +271,15 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => { await dialog.getByRole('button', { name: 'Steer' }).waitFor({ timeout: 10_000 }) await page.keyboard.press('Escape') - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() const settled = scaffold.whenTurnSettled(30_000) + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(PROMPT) await input.press('Enter') await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 }) const queuedText = 'Queued by the complementary Cmd+Enter shortcut.' + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(queuedText) await input.press('Meta+Enter') const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: queuedText }) @@ -327,16 +333,19 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-steer-all')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled(30_000) // Call 0 streams a question-tool call; the fills must land inside the // first replay window, before the question composer replaces the textarea. + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(PROMPT) await input.press('Enter') + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(STEER_ONE) await input.press('Enter') + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await input.fill(STEER_TWO) await input.press('Enter') const dock = page.locator('[data-queue-dock]') diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts index 411b37075c..4e74eb8764 100644 --- a/apps/web/tests/streaming-fence-highlight.e2e.ts +++ b/apps/web/tests/streaming-fence-highlight.e2e.ts @@ -16,7 +16,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/streaming-fence-highlight', import.meta.url)) const MID_EXPECTED = fileURLToPath(new URL('./snapshots/streaming-fence-highlight/mid-stream.expected.md', import.meta.url)) @@ -111,9 +111,9 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting', it('renders the growing fence through shiki and preserves its token tree when the turn settles', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-streaming-fence-highlight')) - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() const settled = scaffold.whenTurnSettled(30_000) - await input.fill(PROMPT) + await writeComposerDraft(page, input, PROMPT) await input.press('Enter') await adapter.paused diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index fe40b00361..147bf7ea50 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -107,7 +107,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const parent = scaffold.ctx.agents.roots()[0] if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent') const parentSettled = scaffold.whenTurnSettled() - const parentInput = page.locator('textarea:enabled').first() + const parentInput = page.locator('[data-composer-input][contenteditable="true"]').first() await parentInput.fill(PARENT_PROMPT) await parentInput.press('Enter') expect(await parentSettled).toBe(parent.id) @@ -526,7 +526,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() await page.getByRole('button', { name: '3 subagents' }).hover() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - await page.locator('textarea:enabled').first().waitFor() + await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor() expect(scaffold.ctx.agents.get(childId)).toBeUndefined() const forkResponse = page.waitForResponse(response => @@ -543,7 +543,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() await page.getByRole('button', { name: '3 subagents' }).press('ArrowDown') await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - const input = page.locator('textarea:enabled').first() + const input = page.locator('[data-composer-input][contenteditable="true"]').first() await input.waitFor() const promptResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/subagent.prompt') diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index e9ef24ca4f..1975e0b614 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -154,7 +154,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co // One prompted parent turn makes the parent non-blank so the session // header (and its subagent catalog action) renders. const parentSettled = scaffold.whenTurnSettled() - const parentInput = page.locator('textarea:enabled').first() + const parentInput = page.locator('[data-composer-input][contenteditable="true"]').first() await parentInput.fill('Ask a research subagent to explain event sourcing.') await parentInput.press('Enter') expect(await parentSettled).toBe(parent.id) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index ec3d0eea77..b43fcbaa76 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -59,7 +59,7 @@ export function probeFreePort(): Promise { * until the live composer unlocks. A fresh world has no Workspace, so the boot * lands in the Workspace-trigger view state (startup auto-selection has nothing to * select); every scenario that types into the composer must connect one - * first. With nothing to list, activating the textarea raises the dialog directly — + * first. With nothing to list, activating the composer surface raises the dialog directly — * adding a workspace is the picker's only entry. The directory is staged here * and adopted through the path editor, which is idempotent across the repeated * connects a scenario may make; creating a folder from inside the dialog (the @@ -83,7 +83,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo await dialog.getByRole('button', { name: 'Open', exact: true }).click() // The pick connected the workspace: the blank session's live composer // replaces the locked placeholder and enables. - await page.locator('textarea:enabled[placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]') .waitFor({ timeout: 15_000 }) } @@ -106,10 +106,33 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容"]') .waitFor({ timeout: 15_000 }) } +/** + * Replace the composer draft through per-key gestures. `fill()` issues + * select-all and insertText inside one task; directly after a trigger-menu or + * chip interaction Lexical's internal selection has not yet absorbed the DOM + * selection, and the batched edit lands on a null selection and is silently + * dropped, leaving the previous draft in place. Real keystrokes leave room for + * `selectionchange` between keys, which is also what a user's typing does. + * @param page - the page under test. + * @param input - the `[data-composer-input]` surface locator. + * @param text - the replacement draft; `''` clears the draft. Must not + * contain a newline: typed Enter submits the composer. + */ +export async function writeComposerDraft( + page: Page, + input: ReturnType, + text: string, +): Promise { + await input.click() + await page.keyboard.press('ControlOrMeta+A') + if (text === '') await page.keyboard.press('Backspace') + else await page.keyboard.type(text) +} + /** Failure evidence goes to the gitignored .artifacts/ (repo convention). */ export async function saveFailureShot(page: Page, name: string): Promise { const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index e0f2b5bb66..062fcc19c8 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -322,7 +322,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => { host.scrollTo = trackedScrollTo as typeof host.scrollTo }) const settled = scaffold.whenTurnSettled() - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill('Stream one deterministic response while Trajectory remains visible.') await input.press('Enter') await settled diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index aa44fcaa16..9a78fb9f9c 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -88,7 +88,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { /** Send the recorded prompt with the settled barrier pre-armed (returned wrapped so the caller can act mid-turn). */ async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType }> { - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold!.whenTurnSettled(timeoutMs) await input.fill(PROMPT) diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index 7237670f0b..bba79e3ad4 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -185,7 +185,7 @@ describe('web e2e: shipped default web search', () => { if (MODE !== 'record') { expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index f3d9e2c8b3..ede39f8e0f 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -72,7 +72,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live')) const settled = waitForParentSettlement() - const input = page.locator('textarea').first() + const input = page.locator('[data-composer-input]').first() await input.fill(prompt) await input.press('Enter') diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f0851966cb..f261db5614 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -83,6 +83,7 @@ "tests/schedule-after.e2e.ts", "tests/feedback-command.e2e.ts", "tests/feedback-release.e2e.ts", + "tests/agent-team-panel.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", "tests/produced-file-mentions.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 3a8354d7f4..683f690e37 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: d86a05e240cd64a0c8a172b00fe9e1df809fe408 -capability-seams.zh.md: 1050191d696fd5a46aa28101beef118b07c29ed2 +capability-seams.md: 2ee2d1b989c4eb25b245cfd528b2fa1ba3bb0272 +capability-seams.zh.md: 7ecda568ef4df4d360414ee872d2b0241ebe1dce diff --git a/docs/capability-seams.md b/docs/capability-seams.md index d86a05e240..2ee2d1b989 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -172,6 +172,7 @@ flowchart LR pkg_experimental_agent_team["experimental-agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] + pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_jobs["jobs"] svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] @@ -324,6 +325,7 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo + svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -500,7 +502,7 @@ flowchart LR | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-e2b`](../packages/e2b/fs-e2b) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes the scoped model policy and controls. | +| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 1050191d69..7ecda568ef 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -174,6 +174,7 @@ flowchart LR pkg_experimental_agent_team["experimental-agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] + pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_jobs["jobs"] svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] @@ -326,6 +327,7 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo + svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -502,7 +504,7 @@ flowchart LR | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-e2b`](../packages/e2b/fs-e2b) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供作用域化模型策略和控制工具。 | +| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 6f01d13ecf..a06cbd8903 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: e4d60a02367bb35965564ee88dcb4065c97a5cf4 -config-catalog.zh.md: 9dc302705780297a4dd177174613ad3346516e2a +config-catalog.md: 74834994902bd5fe1e75653ab5645aef5bf587be +config-catalog.zh.md: 77c83f46128a54b1a8f917e82ce3c6750060fcec diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e4d60a0236..7483499490 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -155,7 +155,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) +Source: [`packages/preset/agent-presets/src/preset.ts:54`](../packages/preset/agent-presets/src/preset.ts) @@ -594,7 +594,7 @@ export interface Config { } ``` -Source: [`packages/experimental/agent-team/src/types.ts:125`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts) @@ -3365,6 +3365,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts)) - `@deepseek-ai/dsh-cordis-client-runner` ([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts)) - `@deepseek-ai/dsh-deepseek-llm-api-extensions` ([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts)) +- `@deepseek-ai/dsh-experimental-client-ui-agent-team` ([`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts)) - `@deepseek-ai/dsh-fs-e2b` — requires `e2b` ([`packages/e2b/fs-e2b/src/index.ts`](../packages/e2b/fs-e2b/src/index.ts)) - `@deepseek-ai/dsh-fs-observation-policy` ([`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-round-driver` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts)) @@ -3431,6 +3432,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-code-runtime-python` ([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts)) - `@deepseek-ai/dsh-experimental-agent-team-profile` ([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) +- `@deepseek-ai/dsh-experimental-agent-team-web-profile` ([`packages/experimental/agent-team-web-profile/src/index.ts`](../packages/experimental/agent-team-web-profile/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-packer` ([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-runtime` ([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts)) - `@deepseek-ai/dsh-home-paths` ([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9dc3027057..77c83f4612 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -157,7 +157,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -来源:[`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) +来源:[`packages/preset/agent-presets/src/preset.ts:54`](../packages/preset/agent-presets/src/preset.ts) @@ -3367,6 +3367,7 @@ export interface Config { - `@deepseek-ai/dsh-commands`([`packages/interaction/commands/src/index.ts`](../packages/interaction/commands/src/index.ts)) - `@deepseek-ai/dsh-cordis-client-runner`([`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts)) - `@deepseek-ai/dsh-deepseek-llm-api-extensions`([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts)) +- `@deepseek-ai/dsh-experimental-client-ui-agent-team`([`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts)) - `@deepseek-ai/dsh-fs-e2b` — 需要 `e2b`([`packages/e2b/fs-e2b/src/index.ts`](../packages/e2b/fs-e2b/src/index.ts)) - `@deepseek-ai/dsh-fs-observation-policy`([`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-round-driver` — 需要 `agents` · `goals` · `sessions`([`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts)) @@ -3432,6 +3433,7 @@ export interface Config { - `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-code-runtime-python`([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts)) - `@deepseek-ai/dsh-experimental-agent-team-profile`([`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts)) +- `@deepseek-ai/dsh-experimental-agent-team-web-profile`([`packages/experimental/agent-team-web-profile/src/index.ts`](../packages/experimental/agent-team-web-profile/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-packer`([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts)) - `@deepseek-ai/dsh-experimental-webworker-runtime`([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts)) - `@deepseek-ai/dsh-home-paths`([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts)) diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index 7dabe7b92f..2e83a2042b 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: acd13d6d06a86378ee13557ea4e3e172670bf1af -07-into-the-harness.zh.md: b6a732f6e37217ba317a0187b499dbbdcae949da +07-into-the-harness.md: 28e6c2009eec736c92987e0da282f04db249e466 +07-into-the-harness.zh.md: 603b9cac19e05c997073ad0747622b29790bc555 diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index acd13d6d06..28e6c2009e 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -11,7 +11,7 @@ Create `greet-tool.ts` in `tmp/cordis-tutorial`: ```ts import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' export const name = 'greet-tool' export const inject = ['tools'] @@ -33,10 +33,10 @@ export function apply(ctx: Context) { })) // Drive one call through the real execution pipeline, standing in for - // the model. CallId brands the correlation id a provider would issue. + // the model. ToolCallId brands the correlation id a provider would issue. void (async () => { const result = await ctx.tools.execute({ - callId: CallId('demo-1'), + callId: ToolCallId('demo-1'), name: 'greet', arguments: { name: 'Cordis' }, signal: new AbortController().signal, diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index b6a732f6e3..603b9cac19 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -11,7 +11,7 @@ ```ts import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' export const name = 'greet-tool' export const inject = ['tools'] @@ -33,10 +33,10 @@ export function apply(ctx: Context) { })) // Drive one call through the real execution pipeline, standing in for - // the model. CallId brands the correlation id a provider would issue. + // the model. ToolCallId brands the correlation id a provider would issue. void (async () => { const result = await ctx.tools.execute({ - callId: CallId('demo-1'), + callId: ToolCallId('demo-1'), name: 'greet', arguments: { name: 'Cordis' }, signal: new AbortController().signal, diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index d217fb3d03..0f42ef8ea1 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 6353c42953fcdd460749bd19197b3bab0b936b66 -event-producer-consumer.zh.md: 9879e6a3141ed078976beede6f1192867ee3d26b +event-producer-consumer.md: ff4a530f6071824da89c88a30a9744695f4d4eef +event-producer-consumer.zh.md: 074597f038b215731e8c005afcd2fed1bc8c4bd4 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6353c42953..ff4a530f60 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 9879e6a314..074597f038 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 3eadb7c1a9..e561402860 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 3cafd70ae2cc982470d8594228764bf701ab687c -module-graph.zh.md: 0037321174ff91b59a3957d4bfa6f620af6dcc93 +module-graph.md: f2c8a499fc54ac90bd9b9a8a7f155ee56ad94dc7 +module-graph.zh.md: ff0d0c72020ed8cd23d6701aaac6e0125174e501 diff --git a/docs/module-graph.md b/docs/module-graph.md index 3cafd70ae2..f2c8a499fc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -206,6 +206,8 @@ flowchart TD subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] + pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"] + pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_webworker_packer["experimental-webworker-packer"] pkg_experimental_webworker_runtime["experimental-webworker-runtime"] @@ -378,6 +380,7 @@ flowchart TD pkg_code_runtime_python --> pkg_invariants pkg_e2b --> pkg_invariants pkg_experimental_agent_team_profile --> pkg_invariants + pkg_experimental_agent_team_web_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants @@ -893,6 +896,7 @@ flowchart TD pkg_agent_presets --> pkg_settings pkg_agent_presets --> pkg_system_prompt pkg_agent_presets --> pkg_tools + pkg_agent_presets --> pkg_typert_protocol pkg_schedule --> pkg_agent pkg_schedule --> pkg_brand pkg_schedule --> pkg_invariants @@ -1196,6 +1200,7 @@ flowchart TD pkg_experimental_agent_team --> pkg_session pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_subagent + pkg_experimental_agent_team --> pkg_typert_protocol pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver pkg_host_frontend_static --> pkg_invariants @@ -1479,6 +1484,18 @@ flowchart TD pkg_client_ui_user_questions --> pkg_session pkg_client_ui_user_questions --> pkg_typert_protocol pkg_client_ui_user_questions --> pkg_user_questions + pkg_experimental_client_ui_agent_team --> pkg_api_remotes + pkg_experimental_client_ui_agent_team --> pkg_api_session_controller + pkg_experimental_client_ui_agent_team --> pkg_client_locale + pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation + pkg_experimental_client_ui_agent_team --> pkg_client_ui_primitives + pkg_experimental_client_ui_agent_team --> pkg_client_ui_renderer + pkg_experimental_client_ui_agent_team --> pkg_client_ui_session + pkg_experimental_client_ui_agent_team --> pkg_client_ui_slots + pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team + pkg_experimental_client_ui_agent_team --> pkg_invariants + pkg_experimental_client_ui_agent_team --> pkg_session + pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_client_ui_chat --> pkg_agent pkg_client_ui_chat --> pkg_api_remotes pkg_client_ui_chat --> pkg_api_session_controller @@ -1684,6 +1701,7 @@ flowchart TD | [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1805,7 +1823,7 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1848,7 +1866,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | +| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1885,6 +1903,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 0037321174..ff0d0c7202 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -208,6 +208,8 @@ flowchart TD subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] + pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"] + pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_webworker_packer["experimental-webworker-packer"] pkg_experimental_webworker_runtime["experimental-webworker-runtime"] @@ -380,6 +382,7 @@ flowchart TD pkg_code_runtime_python --> pkg_invariants pkg_e2b --> pkg_invariants pkg_experimental_agent_team_profile --> pkg_invariants + pkg_experimental_agent_team_web_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants @@ -895,6 +898,7 @@ flowchart TD pkg_agent_presets --> pkg_settings pkg_agent_presets --> pkg_system_prompt pkg_agent_presets --> pkg_tools + pkg_agent_presets --> pkg_typert_protocol pkg_schedule --> pkg_agent pkg_schedule --> pkg_brand pkg_schedule --> pkg_invariants @@ -1198,6 +1202,7 @@ flowchart TD pkg_experimental_agent_team --> pkg_session pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_subagent + pkg_experimental_agent_team --> pkg_typert_protocol pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver pkg_host_frontend_static --> pkg_invariants @@ -1481,6 +1486,18 @@ flowchart TD pkg_client_ui_user_questions --> pkg_session pkg_client_ui_user_questions --> pkg_typert_protocol pkg_client_ui_user_questions --> pkg_user_questions + pkg_experimental_client_ui_agent_team --> pkg_api_remotes + pkg_experimental_client_ui_agent_team --> pkg_api_session_controller + pkg_experimental_client_ui_agent_team --> pkg_client_locale + pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation + pkg_experimental_client_ui_agent_team --> pkg_client_ui_primitives + pkg_experimental_client_ui_agent_team --> pkg_client_ui_renderer + pkg_experimental_client_ui_agent_team --> pkg_client_ui_session + pkg_experimental_client_ui_agent_team --> pkg_client_ui_slots + pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team + pkg_experimental_client_ui_agent_team --> pkg_invariants + pkg_experimental_client_ui_agent_team --> pkg_session + pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_client_ui_chat --> pkg_agent pkg_client_ui_chat --> pkg_api_remotes pkg_client_ui_chat --> pkg_api_session_controller @@ -1686,6 +1703,7 @@ flowchart TD | [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1807,7 +1825,7 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1850,7 +1868,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | +| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1887,6 +1905,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 30e46445f3..13c2a19a3c 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 1ff60ebdacf198ce7bc7d7c8c13eb9278320281a -persistence-catalog.zh.md: 59acfa23c81687f23498d598cb0aead3e669ceb2 +persistence-catalog.md: b9839833308afbd0d561bc227a95b236a10238a1 +persistence-catalog.zh.md: 7b5fa938b23ea9112e370133bf7575c8d689806d diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1ff60ebdac..b983983330 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -142,12 +142,12 @@ Source: [`packages/preset/agent-presets/src/session.ts:28`](../packages/preset/a 'approval/asked': { id: ApprovalRequestId toolName: string - callId?: CallId + callId?: ToolCallId reason?: string } ``` -Types: [CallId](subsystems/core.md) +Types: [ToolCallId](subsystems/core.md) Source: [`packages/interaction/user-approval/src/types.ts:44`](../packages/interaction/user-approval/src/types.ts) @@ -767,7 +767,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../p Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:206`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts) @@ -785,7 +785,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:206`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:229`](../packages/experimental/agent-team/src/types.ts) @@ -798,7 +798,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts) @@ -811,7 +811,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:208`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -840,10 +840,10 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s * JSON string exactly as the model produced it (unparsed). `callId` pairs the * call with its `tool/result`. */ -'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } +'tool/call': { turn: number; step: number; callId: ToolCallId; name: string; arguments: string } ``` -Types: [CallId](subsystems/core.md) +Types: [ToolCallId](subsystems/core.md) Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 59acfa23c8..7b5fa938b2 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -144,12 +144,12 @@ export type SessionEvent = { 'approval/asked': { id: ApprovalRequestId toolName: string - callId?: CallId + callId?: ToolCallId reason?: string } ``` -类型:[CallId](subsystems/core.zh.md) +类型:[ToolCallId](subsystems/core.zh.md) 来源:[`packages/interaction/user-approval/src/types.ts:44`](../packages/interaction/user-approval/src/types.ts) @@ -842,10 +842,10 @@ export type SessionEvent = { * JSON string exactly as the model produced it (unparsed). `callId` pairs the * call with its `tool/result`. */ -'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } +'tool/call': { turn: number; step: number; callId: ToolCallId; name: string; arguments: string } ``` -类型:[CallId](subsystems/core.zh.md) +类型:[ToolCallId](subsystems/core.zh.md) 来源:[`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index 1aaeea97b7..37a3cbfb1c 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/agent-team.md -agent-team.md: 8704bff3071d4e084e95dafcd61b7eea65278f61 -agent-team.zh.md: dfa55743e5ddbde054f87180edd37a788af84663 +agent-team.md: e3a746734b067bb7ef8f083c5c9af9cb3707dcf7 +agent-team.zh.md: 1f885a73861c091b50d27d15080ebc09f6d382ec diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index 8704bff307..e3a746734b 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -175,6 +175,29 @@ interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idl * @returns Team membership, or undefined for non-Team subagents and stale identities. */ tryMembership(agent: Agent): TeamMembership | undefined + +/** + * Read the current roster and non-deleted task board through the generated Remote API. + * @param agent - exact live Team member used as the authority credential. + * @returns detached current roster and task views. + */ +@Remote('view') remoteView(agent: Agent): TeamView + +/** + * Create one shared task through the generated Remote API. + * @param agent - exact live Team member creating the task. + * @param request - task text, blockers, and advisory write scopes. + * @returns the revision-one task or a typed Team rejection. + */ +@Remote('createTask') remoteCreateTask(agent: Agent, request: CreateTeamTaskRequest): Promise + +/** + * Apply one task mutation and preserve Team rejections as business results. + * @param agent - exact live Team member authorizing the mutation. + * @param request - task identity, expected revision, action, and action fields. + * @returns the committed task or a typed Team rejection. + */ +@Remote('updateTask') remoteUpdateTask(agent: Agent, request: UpdateTeamTaskRequest): Promise ``` Types: [Agent](core.md) diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index dfa55743e5..1f885a7386 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -175,6 +175,29 @@ interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idl * @returns Team membership, or undefined for non-Team subagents and stale identities. */ tryMembership(agent: Agent): TeamMembership | undefined + +/** + * Read the current roster and non-deleted task board through the generated Remote API. + * @param agent - exact live Team member used as the authority credential. + * @returns detached current roster and task views. + */ +@Remote('view') remoteView(agent: Agent): TeamView + +/** + * Create one shared task through the generated Remote API. + * @param agent - exact live Team member creating the task. + * @param request - task text, blockers, and advisory write scopes. + * @returns the revision-one task or a typed Team rejection. + */ +@Remote('createTask') remoteCreateTask(agent: Agent, request: CreateTeamTaskRequest): Promise + +/** + * Apply one task mutation and preserve Team rejections as business results. + * @param agent - exact live Team member authorizing the mutation. + * @param request - task identity, expected revision, action, and action fields. + * @returns the committed task or a typed Team rejection. + */ +@Remote('updateTask') remoteUpdateTask(agent: Agent, request: UpdateTeamTaskRequest): Promise ``` Types: [Agent](core.zh.md) diff --git a/docs/subsystems/approval.i18n.yaml b/docs/subsystems/approval.i18n.yaml index e70b9747e5..565f5010cc 100644 --- a/docs/subsystems/approval.i18n.yaml +++ b/docs/subsystems/approval.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/approval.md -approval.md: d9f1169b52e427cd37e7bc54fa37da59d48aecce -approval.zh.md: abc2361db3d84517c7e7497cfb39b4548160c259 +approval.md: 89130232b45da2982d0da40c8fad217b364758b2 +approval.zh.md: e8047cd5a8bf8dfa23b9e80ad97db00d0e25a061 diff --git a/docs/subsystems/approval.md b/docs/subsystems/approval.md index d9f1169b52..89130232b4 100644 --- a/docs/subsystems/approval.md +++ b/docs/subsystems/approval.md @@ -70,7 +70,7 @@ interface ApprovalRequest extends ApprovalRequestEvent { * The exact tool call being decided, when the asker has one — lets a UI * attach the prompt to the tool call it already streamed. */ - readonly callId?: CallId + readonly callId?: ToolCallId /** The asker's human-readable explanation of WHY it is asking. */ readonly reason?: string /** diff --git a/docs/subsystems/approval.zh.md b/docs/subsystems/approval.zh.md index abc2361db3..e8047cd5a8 100644 --- a/docs/subsystems/approval.zh.md +++ b/docs/subsystems/approval.zh.md @@ -70,7 +70,7 @@ interface ApprovalRequest extends ApprovalRequestEvent { * The exact tool call being decided, when the asker has one — lets a UI * attach the prompt to the tool call it already streamed. */ - readonly callId?: CallId + readonly callId?: ToolCallId /** The asker's human-readable explanation of WHY it is asking. */ readonly reason?: string /** diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index b8937331d1..4a2a20d79a 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/compaction.md -compaction.md: 019912531636848404a33d772fec3131cafa0287 -compaction.zh.md: 9acc4d8250d0274be525d826e4391dce1a606392 +compaction.md: 03642b32cc3cb3caf743d908e9c988c1a3c8fa1e +compaction.zh.md: af37c4824cc4b01515400049525fbd7b91d018f3 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 0199125316..03642b32cc 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -99,7 +99,7 @@ interface PrunedEntry { /** Newly appended pruned tool-result event. */ readonly replacementSeq: number /** Tool call shared by the original and replacement. */ - readonly callId: CallId + readonly callId: ToolCallId /** Original text size in Unicode code points. */ readonly charsBefore: number /** Replacement text size in Unicode code points. */ diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index 9acc4d8250..af37c4824c 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -99,7 +99,7 @@ interface PrunedEntry { /** Newly appended pruned tool-result event. */ readonly replacementSeq: number /** Tool call shared by the original and replacement. */ - readonly callId: CallId + readonly callId: ToolCallId /** Original text size in Unicode code points. */ readonly charsBefore: number /** Replacement text size in Unicode code points. */ diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 1b16dff02b..06bfe0a937 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 15135ad1dcacd762fbf31668c13ea0760cd9bc95 -core.zh.md: 7e0270a49f11ef01e6ffb2ca8e1af5831a738198 +core.md: dbe12dfc9bfdafe0f59d7e52eefc5695b1c8d063 +core.zh.md: 50fd2e4e76ee72653c02c2ffeca06042191aa9c4 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 15135ad1dc..dbe12dfc9b 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -301,7 +301,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str ### Branded IDs -IDs passed between packages are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. +IDs passed between packages are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `ToolCallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. The `Branded` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package. @@ -312,7 +312,7 @@ Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index type Branded = string & { readonly [BRAND]: B } ``` -The two core IDs are `CallId` (correlates a tool call with its result; dsh-llm) and `SessionId` (the shared live agent and durable session identity; dsh-session). Capability packages brand their own ids too, such as `JobId` in [jobs.md](jobs.md). +The two core IDs are `ToolCallId` (correlates a tool call with its result; dsh-llm) and `SessionId` (the shared live agent and durable session identity; dsh-session). Capability packages brand their own ids too, such as `JobId` in [jobs.md](jobs.md). @@ -400,6 +400,16 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal */ async list(): Promise +/** + * The roster off the Host: {@link list} projected to path-free rows, with + * the default marked and this deployment's authoring capability beside it. + * + * Whether a client can open a preset's directory is the Host's own opener + * capability, not a roster property — a caller needing both joins them. + * @returns the rows and the authoring capability. + */ +@Remote('list') async remoteExportList(): Promise + /** * Resolve one preset by id. * @@ -474,6 +484,15 @@ composedPreset(agentCtx: Context): string | undefined */ async read(id: string): Promise +/** + * One preset's composition text with the roster row it belongs to. + * @param agentPreset - the preset id. + * @returns the composition beside its trust and published metadata. + * @throws {TypertRemoteFailure} `bad-request` for an empty id, or + * `agent-preset-not-found` when no configured root supplies it. + */ +@Remote('read') async readDocument(agentPreset: string): Promise + /** * Create a locally authored preset by copying an existing one whole. * @@ -491,13 +510,34 @@ async read(id: string): Promise */ async copy(from: string, id: string, name?: string): Promise +/** + * Copy one preset through the Remote API. + * @param from - the source preset id. + * @param id - the new preset id. + * @param name - the copy's optional display name. + * @returns once the copy is stored. + * @throws {TypertRemoteFailure} with the corresponding stable preset code + * and details when the copy is refused. + */ +@Remote('copy') async remoteExportCopy(from: string, id: string, name?: string): Promise + /** * Delete a locally authored preset. + * * @param id - the preset id. * @throws when the preset is unknown or ships with the deployment. */ async remove(id: string): Promise +/** + * Delete one preset through the Remote API. + * @param id - the preset id. + * @returns once the preset is deleted. + * @throws {TypertRemoteFailure} with the corresponding stable preset code + * and details when deletion is refused. + */ +@Remote('deletePreset') async remoteExportDelete(id: string): Promise + /** * One agent's instance of a service its preset mounted. * @@ -540,6 +580,16 @@ serviceFor(agent: { ctx: Context }, name: K): */ async recompose(agentCtx: Context, id: string): Promise +/** + * Compose a blank session's agent from a different preset and record it. + * @param agent - the session's live agent, resolved from the wire identity. + * @param agentPreset - the preset to compose the agent from instead. + * @returns the preset id that was recorded. + * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`, + * `agent-preset-not-found`, or `agent-preset-invalid` when refused. + */ +@Remote('select') async select(agent: Agent, agentPreset: string): Promise + /** * The standing scope key of one preset, for a host reader with no agent. * diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 7e0270a49f..50fd2e4e76 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -311,7 +311,7 @@ declare module '@deepseek-ai/dsh-llm' { ### 品牌化 ID -在包之间传递的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 +在包之间传递的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `ToolCallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 `Branded` 原语位于独立的纯类型包 [dsh-brand](../../packages/util/brand) 中(没有运行时代码,也不依赖 harness 包),因此任何包都能品牌化其拥有的 id,而无需依赖无关的能力包。 @@ -322,7 +322,7 @@ declare module '@deepseek-ai/dsh-llm' { type Branded = string & { readonly [BRAND]: B } ``` -两个核心 ID 是 `CallId`(关联工具调用及其结果;dsh-llm)和 `SessionId`(活跃 agent 与持久会话共享的标识;dsh-session)。能力包也会品牌化各自的 id,例如 [jobs.md](jobs.zh.md) 中的 `JobId`。 +两个核心 ID 是 `ToolCallId`(关联工具调用及其结果;dsh-llm)和 `SessionId`(活跃 agent 与持久会话共享的标识;dsh-session)。能力包也会品牌化各自的 id,例如 [jobs.md](jobs.zh.md) 中的 `JobId`。 @@ -410,6 +410,16 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal */ async list(): Promise +/** + * The roster off the Host: {@link list} projected to path-free rows, with + * the default marked and this deployment's authoring capability beside it. + * + * Whether a client can open a preset's directory is the Host's own opener + * capability, not a roster property — a caller needing both joins them. + * @returns the rows and the authoring capability. + */ +@Remote('list') async remoteExportList(): Promise + /** * Resolve one preset by id. * @@ -484,6 +494,15 @@ composedPreset(agentCtx: Context): string | undefined */ async read(id: string): Promise +/** + * One preset's composition text with the roster row it belongs to. + * @param agentPreset - the preset id. + * @returns the composition beside its trust and published metadata. + * @throws {TypertRemoteFailure} `bad-request` for an empty id, or + * `agent-preset-not-found` when no configured root supplies it. + */ +@Remote('read') async readDocument(agentPreset: string): Promise + /** * Create a locally authored preset by copying an existing one whole. * @@ -501,13 +520,34 @@ async read(id: string): Promise */ async copy(from: string, id: string, name?: string): Promise +/** + * Copy one preset through the Remote API. + * @param from - the source preset id. + * @param id - the new preset id. + * @param name - the copy's optional display name. + * @returns once the copy is stored. + * @throws {TypertRemoteFailure} with the corresponding stable preset code + * and details when the copy is refused. + */ +@Remote('copy') async remoteExportCopy(from: string, id: string, name?: string): Promise + /** * Delete a locally authored preset. + * * @param id - the preset id. * @throws when the preset is unknown or ships with the deployment. */ async remove(id: string): Promise +/** + * Delete one preset through the Remote API. + * @param id - the preset id. + * @returns once the preset is deleted. + * @throws {TypertRemoteFailure} with the corresponding stable preset code + * and details when deletion is refused. + */ +@Remote('deletePreset') async remoteExportDelete(id: string): Promise + /** * One agent's instance of a service its preset mounted. * @@ -550,6 +590,16 @@ serviceFor(agent: { ctx: Context }, name: K): */ async recompose(agentCtx: Context, id: string): Promise +/** + * Compose a blank session's agent from a different preset and record it. + * @param agent - the session's live agent, resolved from the wire identity. + * @param agentPreset - the preset to compose the agent from instead. + * @returns the preset id that was recorded. + * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`, + * `agent-preset-not-found`, or `agent-preset-invalid` when refused. + */ +@Remote('select') async select(agent: Agent, agentPreset: string): Promise + /** * The standing scope key of one preset, for a host reader with no agent. * diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 2f11df1c7a..7b91ec4729 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 8db1d95d91095d09eeb4a0399e1449688cab52cc -llm-streaming.zh.md: fd2d483cbbecac9b1015d67e0e14aa67b67ef08e +llm-streaming.md: 331c30e1039741462df7ae3e7a9e6497281a5df0 +llm-streaming.zh.md: f05c67bc7552a4bc5a9359416d98b62f238f1c65 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 8db1d95d91..331c30e103 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -28,7 +28,7 @@ interface ContentBlockMap { } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`. @@ -205,7 +205,7 @@ type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } | { type: 'text-delta'; index: number; text: string } | { type: 'reasoning-delta'; index: number; text: string } - | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string } + | { type: 'tool-call-delta'; index: number; id: ToolCallId; name?: string; argumentsDelta: string } | { type: 'block-end'; index: number; block: ContentBlock } | { type: 'usage'; usage: TokenUsage } | { diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index fd2d483cbb..f05c67bc75 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -28,7 +28,7 @@ interface ContentBlockMap { } ``` -各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 +各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。 @@ -205,7 +205,7 @@ type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } | { type: 'text-delta'; index: number; text: string } | { type: 'reasoning-delta'; index: number; text: string } - | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string } + | { type: 'tool-call-delta'; index: number; id: ToolCallId; name?: string; argumentsDelta: string } | { type: 'block-end'; index: number; block: ContentBlock } | { type: 'usage'; usage: TokenUsage } | { diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index e5b62d616e..80666a3c88 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: ef24802d7ea567681f935fea86efaab2eb2b80c0 -session.zh.md: bee235657d1c1d1a6009eb23d51b8c88482c0748 +session.md: 87e8b33dec8eda6f7a716d71f5c4afa69a420d35 +session.zh.md: d30b7d24412a04521300c657c1b07daa1620ca5f diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index ef24802d7e..87e8b33dec 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -71,7 +71,7 @@ interface SessionEventMap { * JSON string exactly as the model produced it (unparsed). `callId` pairs the * call with its `tool/result`. */ - 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } + 'tool/call': { turn: number; step: number; callId: ToolCallId; name: string; arguments: string } /** * A completed tool call's model-facing result, optional internal failure * identity, and optional tool-private `meta` presentation payload. `meta` is diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index bee235657d..d30b7d2441 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -71,7 +71,7 @@ interface SessionEventMap { * JSON string exactly as the model produced it (unparsed). `callId` pairs the * call with its `tool/result`. */ - 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } + 'tool/call': { turn: number; step: number; callId: ToolCallId; name: string; arguments: string } /** * A completed tool call's model-facing result, optional internal failure * identity, and optional tool-private `meta` presentation payload. `meta` is diff --git a/docs/subsystems/spill.i18n.yaml b/docs/subsystems/spill.i18n.yaml index b8d92153b3..365d8f0535 100644 --- a/docs/subsystems/spill.i18n.yaml +++ b/docs/subsystems/spill.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/spill.md -spill.md: 1ea23747ddb42c7b36d71e8125dff059ba985b3c -spill.zh.md: a10512f0c6ff26be3dc2637cd8c19606ba4fbdc6 +spill.md: 366cacbef06e18e79d593e946536b062d8d83d50 +spill.zh.md: 82e2ad9efe418175642c3523614c2601b17e4450 diff --git a/docs/subsystems/spill.md b/docs/subsystems/spill.md index 1ea23747dd..366cacbef0 100644 --- a/docs/subsystems/spill.md +++ b/docs/subsystems/spill.md @@ -50,7 +50,7 @@ interface SpillSource { /** The tool whose result was spilled (e.g. `web_fetch`). */ toolName: string /** The model-issued call id the result belongs to. */ - callId: CallId + callId: ToolCallId /** A short human label for the artifact (e.g. `result`). */ label: string } diff --git a/docs/subsystems/spill.zh.md b/docs/subsystems/spill.zh.md index a10512f0c6..82e2ad9efe 100644 --- a/docs/subsystems/spill.zh.md +++ b/docs/subsystems/spill.zh.md @@ -50,7 +50,7 @@ interface SpillSource { /** The tool whose result was spilled (e.g. `web_fetch`). */ toolName: string /** The model-issued call id the result belongs to. */ - callId: CallId + callId: ToolCallId /** A short human label for the artifact (e.g. `result`). */ label: string } diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index ed327e53ce..3de50c917a 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: dd89d40c1401732dd5e6d8dfb5ccb08a89c58405 -tools.zh.md: 0d3e59bc222527e66dd5bd626203f4e1bb6aad9a +tools.md: 5ac2401d662bca52cac529c0620a39930565a7c7 +tools.zh.md: a081c58a2ba4df50b035a4dae8fa7f9ab7d9de1a diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index dd89d40c14..5ac2401d66 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -183,12 +183,12 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } * callers do not choose that token. */ interface ToolExecutionInput { - readonly callId: CallId + readonly callId: ToolCallId /** * Root model-requested call owning this execution tree. Callers omit it for * a root execution; nested dispatchers propagate the enclosing value. */ - readonly rootCallId?: CallId + readonly rootCallId?: ToolCallId readonly name: string /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown @@ -270,7 +270,7 @@ interface CodeDispatchLog { /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ readonly agent?: Agent /** Deterministic sub-call id (`:code:`). */ - readonly subCallId: CallId + readonly subCallId: ToolCallId /** The dispatched sub-tool name. */ readonly name: string /** Whether the sub-call settled as an error. */ @@ -290,7 +290,7 @@ interface CodeDispatchLog { */ interface ToolExecution extends ToolExecutionInput { /** Root model-requested call, resolved for every root and nested execution. */ - readonly rootCallId: CallId + readonly rootCallId: ToolCallId /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 0d3e59bc22..a081c58a2b 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -183,12 +183,12 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } * callers do not choose that token. */ interface ToolExecutionInput { - readonly callId: CallId + readonly callId: ToolCallId /** * Root model-requested call owning this execution tree. Callers omit it for * a root execution; nested dispatchers propagate the enclosing value. */ - readonly rootCallId?: CallId + readonly rootCallId?: ToolCallId readonly name: string /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown @@ -270,7 +270,7 @@ interface CodeDispatchLog { /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ readonly agent?: Agent /** Deterministic sub-call id (`:code:`). */ - readonly subCallId: CallId + readonly subCallId: ToolCallId /** The dispatched sub-tool name. */ readonly name: string /** Whether the sub-call settled as an error. */ @@ -290,7 +290,7 @@ interface CodeDispatchLog { */ interface ToolExecution extends ToolExecutionInput { /** Root model-requested call, resolved for every root and nested execution. */ - readonly rootCallId: CallId + readonly rootCallId: ToolCallId /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index d7d431bd5e..57e3e0cd7e 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/practice/llm-adapter.md -llm-adapter.md: 882e82d880ac622cf886c6c24e75a1987e1c6702 -llm-adapter.zh.md: 27c480af2a19a68ac35900a2ce1b5e9dbdc85bf8 +llm-adapter.md: ff5fc5657e2be0a5fe0d4e10f148865e62f92b18 +llm-adapter.zh.md: b05bfad6cbc4038f54f816b212068d85e54c2100 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 882e82d880..ff5fc5657e 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -54,7 +54,7 @@ export function apply(ctx: Context, config: Config) { `stream()` yields chunks using this protocol: ```ts -import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' async function* exampleChunks(): AsyncIterable { // 1. Start each content block with block-start. @@ -76,7 +76,7 @@ async function* exampleChunks(): AsyncIterable { yield { type: 'tool-call-delta', index: 1, - id: CallId('call-123'), + id: ToolCallId('call-123'), name: 'bash', argumentsDelta: '{"command":"ls"}', } @@ -85,7 +85,7 @@ async function* exampleChunks(): AsyncIterable { index: 1, block: { type: 'tool-call', - id: CallId('call-123'), + id: ToolCallId('call-123'), name: 'bash', arguments: '{"command":"ls"}', }, diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 27c480af2a..b05bfad6cb 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -54,7 +54,7 @@ export function apply(ctx: Context, config: Config) { `stream()` 必须按以下协议生成分片: ```ts -import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' async function* exampleChunks(): AsyncIterable { // 1. Start each content block with block-start. @@ -76,7 +76,7 @@ async function* exampleChunks(): AsyncIterable { yield { type: 'tool-call-delta', index: 1, - id: CallId('call-123'), + id: ToolCallId('call-123'), name: 'bash', argumentsDelta: '{"command":"ls"}', } @@ -85,7 +85,7 @@ async function* exampleChunks(): AsyncIterable { index: 1, block: { type: 'tool-call', - id: CallId('call-123'), + id: ToolCallId('call-123'), name: 'bash', arguments: '{"command":"ls"}', }, diff --git a/knip.json b/knip.json index 09e6327745..e5e331194b 100644 --- a/knip.json +++ b/knip.json @@ -756,6 +756,17 @@ "@deepseek-ai/.+" ] }, + "packages/experimental/agent-team": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ] + }, + "packages/experimental/agent-team-web-profile": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-experimental-client-ui-agent-team" + ] + }, "packages/bundle/web-app": { "ignoreDependencies": [ "@deepseek-ai/.+" diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index 9cd522bb92..22f1973b77 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' @@ -22,8 +22,8 @@ describe('ACP machine permission policy', () => { const agent = harness.ctx.agents.get(SessionId(sessionId))! agent.session.append('turn/start', { turn: 1 }) agent.session.append('step/start', { turn: 1, step: 1 }) - agent.session.append('tool/call', { turn: 1, step: 1, callId: CallId('call-9'), name: 'bash', arguments: '{}' }) - return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides } + agent.session.append('tool/call', { turn: 1, step: 1, callId: ToolCallId('call-9'), name: 'bash', arguments: '{}' }) + return { agent, toolName: 'bash', callId: ToolCallId('call-9'), ...overrides } } it('maps the two advertised one-shot choices', async () => { @@ -71,7 +71,7 @@ describe('ACP machine permission policy', () => { const foreign = { session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, } as unknown as Agent - await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') })) + await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: ToolCallId('call') })) .resolves.toBe('unavailable') expect(harness.permissionRequests).toHaveLength(0) }) diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index a9aa1b3a0a..85125b3661 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { AttachmentError } from '@deepseek-ai/dsh-attachment' -import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' @@ -14,11 +14,11 @@ import { startHttpMcpFixture } from '../../../mcp/mcp-client/tests/http-fixture. function oneToolCall(): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: CallId('call-switch'), name: 'switch_model', argumentsDelta: '{}' }, + { type: 'tool-call-delta', index: 0, id: ToolCallId('call-switch'), name: 'switch_model', argumentsDelta: '{}' }, { type: 'block-end', index: 0, - block: { type: 'tool-call', id: CallId('call-switch'), name: 'switch_model', arguments: '{}' }, + block: { type: 'tool-call', id: ToolCallId('call-switch'), name: 'switch_model', arguments: '{}' }, }, { type: 'finish', reason: { kind: 'tool-calls' } }, ] diff --git a/packages/acp/acp/tests/edges.spec.ts b/packages/acp/acp/tests/edges.spec.ts index 8cd4599e94..073a5893c9 100644 --- a/packages/acp/acp/tests/edges.spec.ts +++ b/packages/acp/acp/tests/edges.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { createUserMessage, CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { createUserMessage, ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' @@ -11,8 +11,8 @@ function toolCallResponse(): StreamChunk[] { { type: 'reasoning-delta', index: 0, text: 'inspect first' }, { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'inspect first' } }, { type: 'block-start', index: 1, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 1, id: CallId('call-1'), name: 'echo', argumentsDelta: '{}' }, - { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{}' } }, + { type: 'tool-call-delta', index: 1, id: ToolCallId('call-1'), name: 'echo', argumentsDelta: '{}' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: ToolCallId('call-1'), name: 'echo', arguments: '{}' } }, { type: 'usage', usage: { inputTokens: 8, outputTokens: 2, reasoningTokens: 1 } }, { type: 'finish', reason: { kind: 'tool-calls' } }, ] diff --git a/packages/acp/acp/tests/updates.spec.ts b/packages/acp/acp/tests/updates.spec.ts index 9af5e31d52..832403c6db 100644 --- a/packages/acp/acp/tests/updates.spec.ts +++ b/packages/acp/acp/tests/updates.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' -import { CallId, MessageId } from '@deepseek-ai/dsh-llm' +import { ToolCallId, MessageId } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { assistantUpdates, toolCallUpdate, toolResultUpdate } from '../src/updates.ts' @@ -33,7 +33,7 @@ describe('standard ACP update projection', () => { const session = { requestContext: () => undefined } as unknown as Session const event = assistantEvent([ { type: 'reasoning', text: '' }, - { type: 'tool-call', id: CallId('call-hidden'), name: 'hidden', arguments: '{}' }, + { type: 'tool-call', id: ToolCallId('call-hidden'), name: 'hidden', arguments: '{}' }, ]) await expect(assistantUpdates(ctx, session, event)).resolves.toEqual([]) @@ -59,7 +59,7 @@ describe('standard ACP update projection', () => { type: 'tool/call', seq: 0, time: 0, - data: { turn: 1, step: 1, callId: CallId('call-bad'), name: 'broken', arguments: '{' }, + data: { turn: 1, step: 1, callId: ToolCallId('call-bad'), name: 'broken', arguments: '{' }, }) const result = await toolResultUpdate({ get: () => undefined } as unknown as Context, { type: 'tool/result', @@ -71,10 +71,10 @@ describe('standard ACP update projection', () => { message: { id: MessageId('tool-message'), role: 'user', - source: { kind: 'tool', callId: CallId('call-bad') }, + source: { kind: 'tool', callId: ToolCallId('call-bad') }, content: [{ type: 'tool-result', - toolCallId: CallId('call-bad'), + toolCallId: ToolCallId('call-bad'), isError: true, content: [{ type: 'reasoning', text: 'hidden' }], }], diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 859230eff7..6956336198 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,6 +1,7 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ import type { Context } from '@deepseek-ai/cordis' +import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' @@ -14,6 +15,7 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' +export type {} from '@deepseek-ai/dsh-agent-presets/remote' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-file-reference/remote' export type {} from '@deepseek-ai/dsh-goal/remote' @@ -106,6 +108,7 @@ export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session- /** Failure vocabulary exposed by the assembled Client data layer. */ export type ClientFailure = | import('@deepseek-ai/dsh-client-connection/client').RpcError + | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError | import('@deepseek-ai/dsh-api-session-controller/types').SessionError | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError @@ -133,7 +136,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, + agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, sessionRemote, workspaceRemote, ]) { diff --git a/packages/api/session-controller/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts index 4722192353..0d8deb5e27 100644 --- a/packages/api/session-controller/tests/event-script.client.ts +++ b/packages/api/session-controller/tests/event-script.client.ts @@ -1,5 +1,5 @@ import { - CallId, createMessage, createToolResultMessage, createUserMessage, + ToolCallId, createMessage, createToolResultMessage, createUserMessage, } from '@deepseek-ai/dsh-llm' // Minimal SessionEvent builders for orchestration tests (shape mirrors what the // host emits; only the fields the object layer reads). @@ -52,7 +52,7 @@ export const ev = { turn, step, message: createToolResultMessage({ - callId: CallId(callId), + callId: ToolCallId(callId), content: text(body), isError: false, }), diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index a08af90309..b3aa0fe29d 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -237,19 +237,8 @@ export class FakeApiClient implements IApiClient { readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), - select: (payload: { agentPreset: string }) => - this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), - read: (payload: { agentPreset: string }) => - this.record('agentPreset.read', payload, Promise.resolve(ok({ - agentPreset: payload.agentPreset, trust: 'user' as const, content: '', - }))), - copy: (payload: { agentPreset: string }) => - this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), openDocument: (payload: { agentPreset: string }) => this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - remove: (payload: { agentPreset: string }) => - this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } readonly skills: IApiClient['skills'] = { diff --git a/packages/api/session-controller/tests/history-records.client.spec.ts b/packages/api/session-controller/tests/history-records.client.spec.ts index 4a91fb04a8..e8adbfcb1d 100644 --- a/packages/api/session-controller/tests/history-records.client.spec.ts +++ b/packages/api/session-controller/tests/history-records.client.spec.ts @@ -1,7 +1,7 @@ /** Packed history records become one event-shaped Client value per wire record. */ import { describe, expect, it } from 'vitest' -import { CallId } from '@deepseek-ai/dsh-llm/brand' +import { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionHistoryRecord } from '../src/types.ts' import { historyEntries, @@ -59,7 +59,7 @@ describe('Session history record projection', () => { turn: 2, step: 4, index: 1, - id: CallId('call-1'), + id: ToolCallId('call-1'), dt: [2, 3], args: ['', '{"x":', '1}'], }, diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index c52dda5b92..d42639922e 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import { decodeStorageRecord, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' -import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import type { @@ -110,12 +110,12 @@ describe('Session history raw journal', () => { const stream = await openFollow(history, session.id, abort.signal) const collected = collect(stream, 2, abort) const call = session.append('tool/call', { - turn: 1, step: 1, callId: CallId('raw-call'), name: 'custom', arguments: '{malformed', + turn: 1, step: 1, callId: ToolCallId('raw-call'), name: 'custom', arguments: '{malformed', }) const result = session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ - callId: CallId('raw-call'), + callId: ToolCallId('raw-call'), content: [{ type: 'text', text: 'raw output' }], isError: false, }), @@ -140,7 +140,7 @@ describe('Session history raw journal', () => { const iterator = stream[Symbol.asyncIterator]() session.append('tool/call', { - turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}', + turn: 1, step: 1, callId: ToolCallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}', }) await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } }, @@ -153,7 +153,7 @@ describe('Session history raw journal', () => { session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ - callId: CallId('live-fast'), + callId: ToolCallId('live-fast'), content: [{ type: 'text', text: 'ok' }], isError: false, }), @@ -175,12 +175,12 @@ describe('Session history raw journal', () => { const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const start = session.append('turn/start', { turn: 1 }) const call = session.append('tool/call', { - turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken', + turn: 1, step: 1, callId: ToolCallId('history-call'), name: 'custom', arguments: '{broken', }) const result = session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ - callId: CallId('history-call'), + callId: ToolCallId('history-call'), content: [{ type: 'text', text: 'failed raw output' }], isError: true, }), @@ -298,7 +298,7 @@ describe('Session history raw journal', () => { step: 1, chunk: { type: 'reasoning-delta', index: 0, text: `r${String(index)}` }, })) - const callId = CallId('packed-call') + const callId = ToolCallId('packed-call') const toolCall = [0, 1, 2].map(index => session.append('assistant/chunk', { turn: 1, step: 1, @@ -358,7 +358,7 @@ describe('Session history raw journal', () => { await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'event', event: { type: 'turn/start' } }, }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' }) + session.append('tool/call', { turn: 1, step: 1, callId: ToolCallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' }) await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'event', event: { type: 'tool/call' } }, }) @@ -373,7 +373,7 @@ describe('Session history raw journal', () => { const result = session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ - callId: CallId('c-late'), + callId: ToolCallId('c-late'), content: [{ type: 'text', text: 'ok' }], isError: false, }), diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ce2fcdae6a..756edaface 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -5,7 +5,7 @@ import { createToolResultMessage, createUserMessage, } from '@deepseek-ai/dsh-llm/message' -import { CallId, type MessageId } from '@deepseek-ai/dsh-llm/brand' +import { ToolCallId, type MessageId } from '@deepseek-ai/dsh-llm/brand' import type { AssistantMessage, ContentBlock, @@ -341,7 +341,7 @@ function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMes } function toolResultMessage(callId: string, content: ContentBlock[], isError: boolean): ToolResultMessage { - return createToolResultMessage({ callId: CallId(callId), content, isError }) + return createToolResultMessage({ callId: ToolCallId(callId), content, isError }) } const MARKDOWN_FIXTURE = [ @@ -2273,6 +2273,82 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: true, value: goalView(projection) } } + /** Canonical fixture implementation of the generated AgentPresets Remote contract. */ + const presetRemotes = { + // Both trusts appear, because a surface must present a locally authored + // preset differently from one the deployment vetted. + list(): RpcResult<{ presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[]; authorable: boolean }> { + return { + ok: true, + value: { + presets: [...fixturePresets].map(([id, preset]) => ({ + id, + trust: preset.trust, + isDefault: id === fixtureDefaultPreset, + })), + authorable: true, + }, + } + }, + select(_id: SessionId, agentPreset: string): RpcResult { + fixtureDefaultPreset = agentPreset + return { ok: true, value: agentPreset } + }, + read(agentPreset: string): RpcResult<{ agentPreset: string; trust: 'system' | 'user'; content: string }> { + const preset = fixturePresets.get(agentPreset) + if (preset === undefined) { + return { + ok: false, + error: { + code: 'agent-preset-not-found', + message: `unknown agent preset "${agentPreset}"`, + details: { agentPreset, available: [...fixturePresets.keys()] }, + }, + } + } + return { ok: true, value: { agentPreset, trust: preset.trust, content: preset.content } } + }, + copy(from: string, id: string): RpcResult { + const source = fixturePresets.get(from) + if (source === undefined) { + return { + ok: false, + error: { + code: 'agent-preset-not-found', + message: `unknown agent preset "${from}"`, + details: { agentPreset: from, available: [...fixturePresets.keys()] }, + }, + } + } + if (fixturePresets.has(id)) { + return { + ok: false, + error: { + code: 'agent-preset-invalid', + message: `agent preset "${id}" already exists`, + details: { agentPreset: id, reason: 'already exists' }, + }, + } + } + fixturePresets.set(id, { trust: 'user', content: source.content }) + return { ok: true, value: undefined } + }, + deletePreset(id: string): RpcResult { + if (fixturePresets.get(id)?.trust === 'system') { + return { + ok: false, + error: { + code: 'agent-preset-read-only', + message: `agent preset "${id}" ships with the deployment`, + details: { agentPreset: id, reason: 'it ships with the deployment' }, + }, + } + } + fixturePresets.delete(id) + return { ok: true, value: undefined } + }, + } + /** At most one in-flight replay per session; cancel clears it. */ const replays = new Map; finish(aborted: boolean): void }>() @@ -3234,57 +3310,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { openPath: request => ok(request, { opened: true as const }), }, agentPresets: { - // Both trusts appear, because a surface must present a locally authored - // preset differently from one the deployment vetted. - list: request => ok(request, { - presets: [...fixturePresets].map(([id, preset]) => ({ - id, - trust: preset.trust, - isDefault: id === fixtureDefaultPreset, - })), - authorable: true, - hasDocument: true, - }), - select: (request) => { - fixtureDefaultPreset = request.payload.agentPreset - return ok(request, { agentPreset: request.payload.agentPreset }) - }, - read: (request) => { - const { agentPreset } = request.payload - const preset = fixturePresets.get(agentPreset) - if (preset === undefined) { - return err(request, { - code: 'agent-preset-not-found', - message: `unknown agent preset "${agentPreset}"`, - details: { agentPreset, available: [...fixturePresets.keys()] }, - }) - } - return ok(request, { - agentPreset, - trust: preset.trust, - content: preset.content, - }) - }, - copy: (request) => { - const { from, agentPreset } = request.payload - const source = fixturePresets.get(from) - if (source === undefined) { - return err(request, { - code: 'agent-preset-not-found', - message: `unknown agent preset "${from}"`, - details: { agentPreset: from, available: [...fixturePresets.keys()] }, - }) - } - if (fixturePresets.has(agentPreset)) { - return err(request, { - code: 'agent-preset-invalid', - message: `agent preset "${agentPreset}" already exists`, - details: { agentPreset, reason: 'already exists' }, - }) - } - fixturePresets.set(agentPreset, { trust: 'user', content: source.content }) - return ok(request, { agentPreset }) - }, // Native opens are deterministic no-op successes in this fixture, so the // open-directory affordance renders and the path-text fallback stays a // component-test concern. @@ -3300,19 +3325,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } return ok(request, { opened: true as const }) }, - remove: (request) => { - const { agentPreset } = request.payload - const existing = fixturePresets.get(agentPreset) - if (existing?.trust === 'system') { - return err(request, { - code: 'agent-preset-read-only', - message: `agent preset "${agentPreset}" ships with the deployment`, - details: { agentPreset, reason: 'it ships with the deployment' }, - }) - } - fixturePresets.delete(agentPreset) - return ok(request, {}) - }, }, skills: { @@ -3422,6 +3434,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { query?: string images?: readonly unknown[] ref?: { id: string; revision: number } + agentPreset?: string + from?: string + id?: string request?: unknown _request?: unknown }> @@ -3449,6 +3464,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef)) case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef)) case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef)) + case 'agentPresets/list': return Promise.resolve(presetRemotes.list()) + case 'agentPresets/select': return Promise.resolve(presetRemotes.select(sessionId, args.agentPreset as string)) + case 'agentPresets/read': return Promise.resolve(presetRemotes.read(args.agentPreset as string)) + case 'agentPresets/copy': return Promise.resolve(presetRemotes.copy(args.from as string, args.id as string)) + case 'agentPresets/deletePreset': return Promise.resolve(presetRemotes.deletePreset(args.id as string)) case 'session/list': return sessionApi.list( args._request as Parameters[0], ) @@ -3580,12 +3600,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'skill.list': return this.api.skills.list(request) - case 'agentPreset.list': return this.api.agentPresets.list(request) - case 'agentPreset.select': return this.api.agentPresets.select(request) - case 'agentPreset.read': return this.api.agentPresets.read(request) - case 'agentPreset.copy': return this.api.agentPresets.copy(request) case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) - case 'agentPreset.remove': return this.api.agentPresets.remove(request) case 'settings.describe': return this.api.settings.describe(request) case 'settings.openDocument': return this.api.settings.openDocument(request, signal) case 'settings.update': return this.api.settings.update(request) diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 1d0266b8d0..90d042f171 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -97,19 +97,8 @@ export class FakeApiClient implements IApiClient { readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), - select: (payload: { agentPreset: string }) => - this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), - read: (payload: { agentPreset: string }) => - this.record('agentPreset.read', payload, Promise.resolve(ok({ - agentPreset: payload.agentPreset, trust: 'user' as const, content: '', - }))), - copy: (payload: { agentPreset: string }) => - this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), openDocument: (payload: { agentPreset: string }) => this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - remove: (payload: { agentPreset: string }) => - this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } readonly skills: IApiClient['skills'] = { diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 75691394ed..e1cb7abe83 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -176,7 +176,7 @@ describe('connection node half', () => { const methods = [ 'host.pickDirectory', 'host.openPath', 'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set', - 'llm.discoverModels', 'llm.models', 'agentPreset.read', 'agentPreset.list', + 'llm.discoverModels', 'llm.models', 'agentPreset.openDocument', ] for (const method of methods) { const denied = fakeResponse() @@ -505,8 +505,8 @@ describe('connection node half over a real HTTP server', () => { 'credentials.describe', 'credentials.set', 'credentials.unset', 'host.pickDirectory', 'host.openPath', 'llm.discoverModels', - 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', - 'llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select', + 'agentPreset.openDocument', + 'llm.providers', 'llm.models', ] for (const method of methods) { expect([method, await call(port, method, 'localhost')]).toEqual([method, 401]) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 87fe4cd6da..dfebafe874 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -451,6 +451,18 @@ function clientConfig(id: string, entry: string): UserConfig { // everything else is bundled. alwaysBundle: (specifier: string) => !isRequested(specifier), }, + // Dual-mode libraries (lexical's exports carry development/production/ + // node conditions; the node file picks its flavor with a top-level await + // a CJS bundle cannot carry) resolve their static flavor matching the + // NODE_ENV the defines below bake in. + inputOptions: { + resolve: { + conditionNames: [ + (process.env.NODE_ENV ?? 'production') === 'development' ? 'development' : 'production', + 'browser', 'import', 'module', 'default', + ], + }, + }, // Browser bundles inline node-idiom deps (zustand/immer read // process.env.NODE_ENV; zustand's esm build also probes // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 28a50c034f..b8bcecdcce 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -50,7 +50,7 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'settingsScope'] /** * Mount the General-settings row. @@ -58,11 +58,11 @@ export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope */ export function apply(ctx: ClientContext): void { const { api } = ctx.get('connection') as ConnectionHandle - const controller = new AgentPresetSettingsController(api, ctx.settingsScope.describe()) + const controller = new AgentPresetSettingsController(api, ctx.remote, ctx.settingsScope.describe()) // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() - const section = new AgentPresetSectionController(api, () => { + const section = new AgentPresetSectionController(api, ctx.remote, () => { void controller.load() for (const read of rosterReaders) read() }) @@ -104,8 +104,7 @@ export function apply(ctx: ClientContext): void { // The new-session chip and the header label: one controller, because the // staged choice belongs to the flow rather than to any one session. ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => { - const api = (scope.get('connection') as ConnectionHandle).api - const seat = new AgentPresetSeatController(api, () => { + const seat = new AgentPresetSeatController(scope.remote, () => { const state = scope.sessions.list.getSnapshot() return state.current === undefined ? undefined : state.byId[state.current] }) diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index b16e803ac5..8e2714bce1 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -10,11 +10,11 @@ * deployment default again, matching the workspace picker beside it. */ -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import { messageOf, presetOptions } from './settings-store.ts' +import { messageOf, presetOptions, readRoster } from './settings-store.ts' import type { AgentPresetOption } from './settings-store.ts' /** Hero-chip snapshot. */ @@ -53,7 +53,7 @@ export class AgentPresetSeatController { private staged: string | undefined constructor( - private readonly api: Pick, + private readonly remote: Pick, /** The session the hero is about to hand over to, when there is one. */ private readonly currentSession: () => Pick< SessionSummary, @@ -67,32 +67,28 @@ export class AgentPresetSeatController { /** * Read the roster and open the chip on the deployment default. - * @returns once the snapshot reflects the host. - */ + * @returns once the snapshot reflects the host. + */ async load(): Promise { - try { - const response = await this.api.agentPresets.list({}) - if (!response.result.ok) { - this.set({ error: response.result.error.message }) - return - } - const { presets } = response.result.value - this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '' - const session = this.currentSession() - this.set({ - options: presetOptions(presets), - // Staged pick first, then the composition the current session - // already carries, then the deployment default. The middle term is - // what keeps a late-landing load from regressing the display after - // an applied stage was consumed — the chip mounts (and loads) only - // once the flow's session is current, so the reply can arrive after - // apply() already composed it. - current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''), - error: null, - }) - } catch (error) { - this.set({ error: messageOf(error) }) + const roster = await readRoster(this.remote) + if (!roster.ok) { + this.set({ error: roster.error }) + return } + const { presets } = roster.value + this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '' + const session = this.currentSession() + this.set({ + options: presetOptions(presets), + // Staged pick first, then the composition the current session + // already carries, then the deployment default. The middle term is + // what keeps a late-landing load from regressing the display after + // an applied stage was consumed — the chip mounts (and loads) only + // once the flow's session is current, so the reply can arrive after + // apply() already composed it. + current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''), + error: null, + }) } /** @@ -153,18 +149,18 @@ export class AgentPresetSeatController { } this.set({ busy: true, error: null }) try { - const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged }) + const result = await this.remote.agentPresets.select(session.id, staged) this.staged = undefined - if (!response.result.ok) { + if (!result.ok) { this.set({ busy: false, - error: response.result.error.message, + error: result.error.message, current: presetOf(session) ?? '', }) return } // Consumed: the next new session opens on the deployment default again. - this.set({ busy: false, current: response.result.value.agentPreset }) + this.set({ busy: false, current: result.value }) } catch (error) { this.staged = undefined this.set({ diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index a4af289990..0d034cd833 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -14,7 +14,7 @@ * more than the row it targeted. */ -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' @@ -133,7 +133,8 @@ export class AgentPresetSectionController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) constructor( - private readonly api: Pick, + private readonly api: Pick, + private readonly remote: Pick, /** * Called after this page changes the roster DIRECTORY, so the other * surfaces reading the same roster re-read it. A settings field moving is @@ -162,9 +163,18 @@ export class AgentPresetSectionController { * @returns once the snapshot reflects the host. */ async load(): Promise { - const roster = await beginRosterRead(this.api, this.store) + // Whether a preset's directory can be opened is the Host's opener + // capability rather than a roster property, so the page joins the two. + // Issued together: one round trip decides the page, and a load that waited + // for them in turn would hold the section in `loading` twice as long, + // where a concurrent reload silently returns instead of refreshing. + const opener = this.api.host.describe({}) + const roster = await beginRosterRead(this.remote, this.store) + // A refused describe leaves the reveal-the-path path, which needs no opener. + const described = await opener.catch(() => undefined) if (roster === undefined) return - const { presets, authorable, hasDocument } = roster + const { presets, authorable } = roster + const hasDocument = described?.result.ok === true && described.result.value.canOpenPath if (presets.length === 0) { // Nothing to manage leaves nothing to keep a dialog open over. this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null }) @@ -193,12 +203,12 @@ export class AgentPresetSectionController { async view(id: string): Promise { this.set({ error: null }) try { - const response = await this.api.agentPresets.read({ agentPreset: id }) - if (!response.result.ok) { - this.set({ error: response.result.error.message }) + const result = await this.remote.agentPresets.read(id) + if (!result.ok) { + this.set({ error: result.error.message }) return } - const { name, content } = response.result.value + const { name, content } = result.value this.set({ view: { id, title: name ?? id, content } }) } catch (error) { this.set({ error: messageOf(error) }) @@ -256,13 +266,14 @@ export class AgentPresetSectionController { this.patchCopy({ saving: true, error: null }) try { const name = draft.name.trim() - const response = await this.api.agentPresets.copy({ - from: draft.from, - agentPreset: draft.id, - ...name === '' ? {} : { name }, - }) - if (!response.result.ok) { - this.patchCopy({ saving: false, error: response.result.error.message }) + // Every declared parameter is passed even when optional: the Remote face + // checks arity against the declaration and rejects a short call. An + // empty display name goes as `undefined` — absent rather than empty, so + // the host falls back to the id instead of labelling the row with ''. + const result = await this.remote.agentPresets.copy( + draft.from, draft.id, name === '' ? undefined : name) + if (!result.ok) { + this.patchCopy({ saving: false, error: result.error.message }) return } this.set({ copy: null }) @@ -318,9 +329,9 @@ export class AgentPresetSectionController { if (pendingDelete === null || deleting) return this.set({ deleting: true, error: null }) try { - const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete }) - if (!response.result.ok) { - this.set({ deleting: false, pendingDelete: null, error: response.result.error.message }) + const result = await this.remote.agentPresets.deletePreset(pendingDelete) + if (!result.ok) { + this.set({ deleting: false, pendingDelete: null, error: result.error.message }) return } this.set({ deleting: false, pendingDelete: null }) diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index 8c7e7444f1..cbae273243 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -1,14 +1,15 @@ /** * Agent-preset default-settings controller. * - * Options and the current default both come from one `agentPreset.list` call: + * Options and the current default both come from one `agentPresets.list` call: * the roster already reports which id a session with no explicit choice gets, * so the row needs no schema introspection. Writes target the settings * namespace's `default` field, which is what the host resolves at creation. */ -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** The agent-preset settings namespace on the host wire. */ @@ -63,33 +64,12 @@ export interface AgentPresetOption { } /** One roster entry exactly as the host reports it. */ -export interface RosterPreset { - /** Preset id and directory name. */ - id: string - /** Whether the preset ships with the deployment or was authored locally. */ - trust: 'system' | 'user' - /** Whether a session that names no preset gets this one. */ - isDefault: boolean - /** Display name the preset published, absent when it published none. */ - name?: string - /** One sentence on what the preset is for. */ - description?: string - /** Why the preset cannot compose a session, absent when it can. */ - broken?: string -} - -/** The roster the host answered with. */ -export interface RosterValue { - /** Every preset the deployment composes, in the order the host lists them. */ - presets: readonly RosterPreset[] - /** Whether this browser may author presets at all. */ - authorable: boolean - /** Whether the host can open a preset directory on a native desktop. */ - hasDocument: boolean -} +export type RosterPreset = AgentPresetRoster['presets'][number] /** The roster, or the message to show in its place. */ -export type RosterRead = { ok: true; value: RosterValue } | { ok: false; error: string } +export type RosterRead = { ok: true; value: AgentPresetRoster } | { ok: false; error: string } + +const EMPTY_ROSTER: AgentPresetRoster = { presets: [], authorable: false } /** * Read the roster, folding both refusal shapes into one message. @@ -98,15 +78,18 @@ export type RosterRead = { ok: true; value: RosterValue } | { ok: false; error: * `ok: false` envelope — and every surface treats them identically. Folding * them here keeps each store's `load` about what it does with a roster rather * than about how the call can fail. - * @param api - the agent-preset wire face. + * @param remote - the agent-preset Remote namespace. * @returns the roster, or the message to show in its place. */ -export async function readRoster(api: Pick): Promise { +export async function readRoster(remote: Pick): Promise { try { - const response = await api.agentPresets.list({}) - return response.result.ok - ? { ok: true, value: response.result.value } - : { ok: false, error: response.result.error.message } + const result = await remote.agentPresets.list() + if (result.ok) return { ok: true, value: result.value } + // Agent presets are optional: without that service every session uses the + // Host composition, so callers receive the same empty roster as a mounted + // service with no configured roots. + if (result.error.code === 'invocation-unavailable') return { ok: true, value: EMPTY_ROSTER } + return { ok: false, error: result.error.message } } catch (error) { return { ok: false, error: messageOf(error) } } @@ -119,18 +102,18 @@ export async function readRoster(api: Pick): Promise * A surface that gets `undefined` returns without touching its snapshot * further — either another read owns it, or this one already wrote the * failure. What differs between surfaces starts after this. - * @param api - the agent-preset wire face. + * @param remote - the agent-preset Remote namespace. * @param store - the surface's own snapshot store. * @returns the roster, or undefined when the caller should return. */ export async function beginRosterRead( - api: Pick, + remote: Pick, store: SnapshotStore, -): Promise { +): Promise { const before = store.getSnapshot() if (before.status === 'loading') return undefined store.set({ ...before, status: 'loading', error: null }) - const roster = await readRoster(api) + const roster = await readRoster(remote) if (roster.ok) return roster.value store.set({ ...store.getSnapshot(), status: 'error', error: roster.error }) return undefined @@ -193,11 +176,13 @@ export class AgentPresetSettingsController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) /** - * @param api - the agent-preset and settings wire faces (roster and default write). + * @param api - the settings wire face (the default write). + * @param remote - the agent-preset Remote namespace (the roster read). * @param describeFace - the shared mirror's describe face (writability source). */ constructor( - private readonly api: IApiClient, + private readonly api: Pick, + private readonly remote: Pick, private readonly describeFace: SettingsDescribeFace, ) {} @@ -212,7 +197,7 @@ export class AgentPresetSettingsController { * @returns once the snapshot reflects the host. */ async load(): Promise { - const roster = await beginRosterRead(this.api, this.store) + const roster = await beginRosterRead(this.remote, this.store) if (roster === undefined) return const { presets } = roster const [first] = presets diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index f0215a0341..43f76dc2ad 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -29,46 +29,34 @@ import { AgentPresetSeatController } from '../src/client/seat-store.ts' // FALLBACK_LOCALE (en); each bench stages zh explicitly on the locale instead. const ROSTER_ONE = { - rpcId: 'r', - result: { - ok: true as const, - value: { - presets: [{ id: 'standard', trust: 'system', isDefault: true }], - authorable: true, - hasDocument: true, - }, + ok: true as const, + value: { + presets: [{ id: 'standard', trust: 'system', isDefault: true }], + authorable: true, }, } /** The roster after this browser copied one preset of its own. */ const ROSTER_AUTHORED = { - rpcId: 'r', - result: { - ok: true as const, - value: { - presets: [ - { id: 'standard', trust: 'system', isDefault: true }, - { id: 'mine', trust: 'user', isDefault: false }, - ], - authorable: true, - hasDocument: true, - }, + ok: true as const, + value: { + presets: [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ], + authorable: true, }, } /** The same roster with a second preset carrying the default. */ const ROSTER_MOVED = { - rpcId: 'r', - result: { - ok: true as const, - value: { - presets: [ - { id: 'standard', trust: 'system', isDefault: false }, - { id: 'minimal', trust: 'system', isDefault: true }, - ], - authorable: true, - hasDocument: true, - }, + ok: true as const, + value: { + presets: [ + { id: 'standard', trust: 'system', isDefault: false }, + { id: 'minimal', trust: 'system', isDefault: true }, + ], + authorable: true, }, } @@ -84,30 +72,45 @@ async function bench() { ctx.provide('locale', locale) const remote = new TestRemote(ctx) const calls: string[] = [] + // The roster and the switch are the AgentPresets Remote namespace; the + // shared double carries no generated namespaces, so this spec stages its + // own. Registered twice on purpose: the nested key satisfies the plugin's + // `inject`, and the property is what `ctx.remote.agentPresets` reads, + // because the double is a plain provided object rather than a Service. + const agentPresets = { + list: () => { calls.push('list'); return Promise.resolve(ROSTER) }, + read: () => Promise.resolve({ + ok: true as const, + value: { agentPreset: 'standard', trust: 'system', content: '' }, + }), + copy: (_from: string, id: string) => { + calls.push(`copy:${id}`) + // The host's roster now contains it, which is the whole point of the + // copy and what every surface must converge on. + ROSTER = ROSTER_AUTHORED + return Promise.resolve({ ok: true as const, value: undefined }) + }, + deletePreset: () => Promise.resolve({ ok: true as const, value: undefined }), + select: (_agentId: SessionId, agentPreset: string) => { + calls.push(`select:${agentPreset}`) + return Promise.resolve({ ok: true as const, value: agentPreset }) + }, + } + ctx.provide('remote.agentPresets', agentPresets as never) + Object.assign(remote, { agentPresets }) ctx.provide('connection', { api: { - agentPresets: { - list: () => { calls.push('list'); return Promise.resolve(ROSTER) }, - read: () => Promise.resolve({ + host: { + describe: () => Promise.resolve({ rpcId: 'r', - result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '' } }, + result: { ok: true as const, value: { canOpenPath: true } }, }), - copy: (payload: { from: string; agentPreset: string }) => { - calls.push(`copy:${payload.agentPreset}`) - // The host's roster now contains it, which is the whole point of the - // copy and what every surface must converge on. - ROSTER = ROSTER_AUTHORED - return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }) - }, + }, + agentPresets: { openDocument: (payload: { agentPreset: string }) => { calls.push(`openDocument:${payload.agentPreset}`) return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } }) }, - remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }), - select: (payload: { agentPreset: string }) => { - calls.push(`select:${payload.agentPreset}`) - return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }) - }, }, settings: { // The row reads this to learn whether this browser may write at all. @@ -179,7 +182,7 @@ function sessionsDouble(state: { describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'settingsScope']) }) it('registers the General row and the settings section', async () => { @@ -600,8 +603,7 @@ describe('AgentPresetSeatController reconciliation', () => { { name: 'RPC rejection', select: () => Promise.resolve({ - rpcId: 'r', - result: { ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} } }, + ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} }, }), message: 'selection rejected', }, diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index dc62f74aff..ca253c69fa 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' @@ -41,6 +41,8 @@ interface FakeOptions { authorable?: boolean /** Whether the host can open a preset directory on a desktop. */ hasDocument?: boolean + /** Reject `host.describe`, as a dead transport does. */ + throwDescribe?: boolean /** Hold `remove` until this resolves, to observe the in-flight state. */ holdRemove?: Promise } @@ -49,63 +51,29 @@ const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true const fail = (message: string) => Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } }) +const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value }) +const remoteFail = (message: string) => + Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } }) + /** - * A wire face over an in-memory preset store: copies land, so the roster the - * controller re-reads after a copy is the one the copy produced. - * @param presets - the starting compositions by id. + * The carried wire face: the desktop opener, the default write, and the opener + * capability the page joins onto the roster. * @param defaultId - the preset a session with no choice gets. * @param options - failure injection and call recording. * @returns the fake client. */ function fakeApi( - presets: Map, defaultId: { id: string }, options: FakeOptions = {}, -): Pick { +): Pick { const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } return { + host: { + describe: () => (options.throwDescribe === true + ? Promise.reject(new Error('socket closed')) + : ok({ canOpenPath: options.hasDocument ?? true })), + }, agentPresets: { - list: () => { - record('list', {}) - if (options.throwList === true) return Promise.reject(new Error('socket closed')) - if (options.failList !== undefined) return fail(options.failList) - return ok({ - presets: [...presets].map(([id, preset]) => ({ - id, trust: preset.trust, isDefault: id === defaultId.id, - ...preset.name === undefined ? {} : { name: preset.name }, - })), - authorable: options.authorable ?? true, - hasDocument: options.hasDocument ?? true, - }) - }, - read: (payload: { agentPreset: string }) => { - record('read', payload) - if (options.throwRead === true) return Promise.reject(new Error('socket closed')) - if (options.failRead !== undefined) return fail(options.failRead) - const preset = presets.get(payload.agentPreset) - /* v8 ignore next -- every test reads an id the fake store holds */ - if (preset === undefined) return fail(`unknown preset ${payload.agentPreset}`) - return ok({ - agentPreset: payload.agentPreset, - trust: preset.trust, - content: preset.content, - ...preset.name === undefined ? {} : { name: preset.name }, - }) - }, - copy: (payload: { from: string; agentPreset: string; name?: string }) => { - record('copy', payload) - if (options.throwCopy === true) return Promise.reject(new Error('socket closed')) - if (options.failCopy !== undefined) return fail(options.failCopy) - const source = presets.get(payload.from) - /* v8 ignore next -- every test copies a source the fake store holds */ - if (source === undefined) return fail(`unknown preset ${payload.from}`) - presets.set(payload.agentPreset, { - trust: 'user', - content: source.content, - ...payload.name === undefined ? {} : { name: payload.name }, - }) - return ok({ agentPreset: payload.agentPreset }) - }, openDocument: (payload: { agentPreset: string }) => { record('openDocument', payload) if (options.throwOpen === true) return Promise.reject(new Error('socket closed')) @@ -114,13 +82,6 @@ function fakeApi( ? ok({ opened: true }) : ok({ opened: false, path: `/presets/${payload.agentPreset}` }) }, - remove: async (payload: { agentPreset: string }) => { - record('remove', payload) - await options.holdRemove - if (options.failRemove !== undefined) return await fail(options.failRemove) - presets.delete(payload.agentPreset) - return await ok({}) - }, }, settings: { update: (payload: { ns: string; patch: { default?: string } }) => { @@ -131,7 +92,81 @@ function fakeApi( return ok({}) }, }, - } as unknown as Pick + } as unknown as Pick +} + +/** + * The Remote namespace over an in-memory preset store: copies land, so the + * roster the controller re-reads after a copy is the one the copy produced. + * @param presets - the starting compositions by id. + * @param defaultId - the preset a session with no choice gets. + * @param options - failure injection and call recording. + * @returns the fake Remote namespace. + */ +function fakeRemote( + presets: Map, + defaultId: { id: string }, + options: FakeOptions = {}, +): Pick { + const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } + return { + agentPresets: { + list: () => { + record('list', {}) + if (options.throwList === true) return Promise.reject(new Error('socket closed')) + if (options.failList !== undefined) return remoteFail(options.failList) + return remoteOk({ + presets: [...presets].map(([id, preset]) => ({ + id, trust: preset.trust, isDefault: id === defaultId.id, + ...preset.name === undefined ? {} : { name: preset.name }, + })), + authorable: options.authorable ?? true, + }) + }, + read: (agentPreset: string) => { + record('read', { agentPreset }) + if (options.throwRead === true) return Promise.reject(new Error('socket closed')) + if (options.failRead !== undefined) return remoteFail(options.failRead) + const preset = presets.get(agentPreset) + /* v8 ignore next -- every test reads an id the fake store holds */ + if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`) + return remoteOk({ + agentPreset, + trust: preset.trust, + content: preset.content, + ...preset.name === undefined ? {} : { name: preset.name }, + }) + }, + // Arity is checked against the declaration, not against which arguments + // carry a value, so a short call rejects instead of answering. Reject + // one here too: the real face would, and a lenient double hid it once. + copy: (...args: [from: string, id: string, name?: string]) => { + if (args.length !== 3) { + return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`)) + } + const [from, id, name] = args + record('copy', { from, id, ...name === undefined ? {} : { name } }) + if (options.throwCopy === true) return Promise.reject(new Error('socket closed')) + if (options.failCopy !== undefined) return remoteFail(options.failCopy) + const source = presets.get(from) + /* v8 ignore next -- every test copies a source the fake store holds */ + if (source === undefined) return remoteFail(`unknown preset ${from}`) + presets.set(id, { + trust: 'user', + content: source.content, + ...name === undefined ? {} : { name }, + }) + return remoteOk(undefined) + }, + deletePreset: async (id: string) => { + record('deletePreset', { id }) + await options.holdRemove + if (options.failRemove !== undefined) return await remoteFail(options.failRemove) + presets.delete(id) + return await remoteOk(undefined) + }, + }, + } as unknown as Pick } function seed(): Map { @@ -146,8 +181,10 @@ function harness(options: FakeOptions = {}) { const defaultId = { id: 'standard' } const calls: Recorded[] = [] let rosterChanges = 0 + const wired = { ...options, calls: options.calls ?? calls } const controller = new AgentPresetSectionController( - fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }), + fakeApi(defaultId, wired), + fakeRemote(presets, defaultId, wired), () => { rosterChanges += 1 }, ) return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges } @@ -160,6 +197,19 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft { } describe('loading the roster', () => { + it('still lists the roster when the opener capability cannot be read', async () => { + const { controller } = harness({ throwDescribe: true }) + + await controller.load() + + // The two reads are independent: a refused `host.describe` costs the + // open-directory affordance, not the page. + const state = controller.store.getSnapshot() + expect(state.status).toBe('ready') + expect(state.hasDocument).toBe(false) + expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine']) + }) + it('maps the roster onto rows with the capability flags', async () => { const { controller } = harness({ authorable: true, hasDocument: false }) @@ -352,7 +402,7 @@ describe('submitting a copy', () => { expect(state.rows.map(row => row.id)).toContain('my-copy') expect(rosterChanges()).toBe(1) expect(calls.find(call => call.method === 'copy')?.payload) - .toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' }) + .toEqual({ from: 'standard', id: 'my-copy', name: '我的模式' }) // A preset is its files from here on, so landing in them completes the // copy rather than following it. expect(calls.find(call => call.method === 'openDocument')?.payload) @@ -369,7 +419,7 @@ describe('submitting a copy', () => { await controller.confirmCopy() expect(calls.find(call => call.method === 'copy')?.payload) - .toEqual({ from: 'standard', agentPreset: 'my-copy' }) + .toEqual({ from: 'standard', id: 'my-copy' }) }) it('reveals the new directory as text where the host has no desktop', async () => { @@ -492,7 +542,7 @@ describe('deleting', () => { await controller.remove() expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine') - expect(calls.some(call => call.method === 'remove')).toBe(false) + expect(calls.some(call => call.method === 'deletePreset')).toBe(false) }) it('ignores a second confirmation while one delete is in flight', async () => { @@ -508,7 +558,7 @@ describe('deleting', () => { release() await removal - expect(calls.filter(call => call.method === 'remove')).toHaveLength(1) + expect(calls.filter(call => call.method === 'deletePreset')).toHaveLength(1) }) it('surfaces a refusal and clears the confirmation', async () => { @@ -528,13 +578,15 @@ describe('deleting', () => { const { controller, presets } = harness() await controller.load() presets.clear() - const broken = new AgentPresetSectionController({ - agentPresets: { - list: () => Promise.reject(new Error('gone')), - remove: () => Promise.reject(new Error('socket closed')), - }, - settings: {}, - } as unknown as Pick) + const broken = new AgentPresetSectionController( + { agentPresets: {}, settings: {}, host: {} } as unknown as Pick, + { + agentPresets: { + list: () => Promise.reject(new Error('gone')), + deletePreset: () => Promise.reject(new Error('socket closed')), + }, + } as unknown as Pick, + ) broken.confirmDelete('mine') await broken.remove() @@ -548,7 +600,9 @@ describe('a controller with no roster listener', () => { // The rosterChanged callback is optional wiring, not a requirement: a // page composed without sibling surfaces still deletes cleanly. const presets = seed() - const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' })) + const defaultId = { id: 'standard' } + const alone = new AgentPresetSectionController( + fakeApi(defaultId), fakeRemote(presets, defaultId)) await alone.load() alone.confirmDelete('mine') diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 6d66b4ee6d..74ef828a35 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -14,9 +14,15 @@ import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, } from '../src/client/settings-store.ts' +/** The two faces the row reads: the roster Remote and the settings wire. */ +interface FakeWire { + api: IApiClient + remote: Pick +} + /** Controller over a real mirror derived from the same fake wire. */ -function derivedController(api: IApiClient) { - return new AgentPresetSettingsController(api, new SettingsDescribeMirror(api)) +function derivedController(wire: FakeWire) { + return new AgentPresetSettingsController(wire.api, wire.remote, new SettingsDescribeMirror(wire.api)) } import { AgentPresetSeatController } from '../src/client/seat-store.ts' @@ -24,7 +30,27 @@ type SeatSession = Pick interface Recorded { ns: string; patch: unknown } -/** A client whose roster and write outcome the test controls. */ +/** A roster Remote answering a fixed set of rows, or refusing. */ +function fakeRoster( + presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + options: { failList?: string; failListCode?: string; throwOnList?: boolean } = {}, +): Pick { + return { + agentPresets: { + list: () => { + if (options.throwOnList === true) return Promise.reject(new Error('socket closed')) + return Promise.resolve(options.failList === undefined + ? { ok: true as const, value: { presets, authorable: true } } + : { + ok: false as const, + error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} }, + }) + }, + }, + } as unknown as Pick +} + +/** A wire whose roster and write outcome the test controls. */ function fakeApi( presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], options: { @@ -34,13 +60,8 @@ function fakeApi( failWriteWith?: Error readOnly?: boolean } = {}, -): IApiClient { - return { - agentPresets: { - list: () => Promise.resolve(options.failList === undefined - ? { rpcId: 'r', result: { ok: true as const, value: { presets } } } - : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }), - }, +): FakeWire { + const api = { settings: { // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false // and the row disables its control instead of offering a refused write. @@ -65,6 +86,10 @@ function fakeApi( }, }, } as unknown as IApiClient + return { + api, + remote: fakeRoster(presets, options.failList === undefined ? {} : { failList: options.failList }), + } } describe('the agent-preset settings controller', () => { @@ -138,6 +163,20 @@ describe('the agent-preset settings controller', () => { expect(controller.store.getSnapshot().error).toBeNull() }) + it('treats an unavailable optional namespace as an empty roster', async () => { + const controller = derivedController({ + api: {} as IApiClient, + remote: fakeRoster([], { + failList: 'no active Remote method exports this endpoint', + failListCode: 'invocation-unavailable', + }), + }) + + await controller.load() + + expect(controller.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: null, options: [] }) + }) + it('writes only the default field, into the agent-presets namespace', async () => { const writes: Recorded[] = [] const controller = derivedController(fakeApi([ @@ -221,8 +260,9 @@ describe('the agent-preset settings controller', () => { it('reports a transport that rejects rather than answering', async () => { const controller = derivedController({ - agentPresets: { list: () => Promise.reject(new Error('socket closed')) }, - } as unknown as IApiClient) + api: {} as IApiClient, + remote: fakeRoster([], { throwOnList: true }), + }) await controller.load() @@ -249,27 +289,43 @@ describe('the new-session chip controller', () => { function chip( presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], current: SeatSession | undefined | (() => SeatSession | undefined), - options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {}, + options: { + writes?: Recorded[] + failSelect?: string + failList?: string + failListCode?: string + throwOn?: 'list' | 'select' + } = {}, ): AgentPresetSeatController { - const api = { + const remote = { agentPresets: { list: () => { if (options.throwOn === 'list') return Promise.reject(new Error('socket closed')) return Promise.resolve(options.failList === undefined - ? { rpcId: 'r', result: { ok: true as const, value: { presets } } } - : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }) + ? { ok: true as const, value: { presets, authorable: true } } + : { + ok: false as const, + error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} }, + }) }, - select: (payload: { agentPreset: string }) => { + select: (agentId: SessionId, agentPreset: string) => { if (options.throwOn === 'select') return Promise.reject(new Error('socket closed')) - options.writes?.push({ ns: 'select', patch: payload.agentPreset }) + options.writes?.push({ ns: 'select', patch: agentPreset }) return Promise.resolve(options.failSelect === undefined - ? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } } - : { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } }) + ? { ok: true as const, value: agentPreset } + : { + ok: false as const, + error: { + code: 'agent-preset-locked', + message: options.failSelect, + details: { sessionId: agentId, agentPreset }, + }, + }) }, }, - } as unknown as IApiClient + } as unknown as Pick return new AgentPresetSeatController( - api, + remote, typeof current === 'function' ? current : () => current, ) } @@ -325,6 +381,17 @@ describe('the new-session chip controller', () => { expect(controller.store.getSnapshot().current).toBe('') }) + it('opens on nothing when the optional namespace is unavailable', async () => { + const controller = chip([], undefined, { + failList: 'no active Remote method exports this endpoint', + failListCode: 'invocation-unavailable', + }) + + await controller.load() + + expect(controller.store.getSnapshot()).toMatchObject({ current: '', error: null, options: [] }) + }) + it('stages a pick made before any session exists', async () => { const writes: Recorded[] = [] const controller = chip(ROSTER, undefined, { writes }) @@ -500,18 +567,12 @@ describe('the new-session chip controller', () => { }) it('degrades to a read-only row while the mirror holds no answer', async () => { - const api = { - agentPresets: { - list: () => Promise.resolve({ - rpcId: 'r', - result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } }, - }), - }, + const controller = derivedController({ // The roster answered; the mirror's read is what failed, so the row // shows the current default without offering a write it never confirmed. - settings: { describe: () => Promise.reject(new Error('socket closed')) }, - } as unknown as IApiClient - const controller = derivedController(api) + api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as IApiClient, + remote: fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }]), + }) await controller.load() diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index f71a33008b..545260b9ae 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -1,5 +1,5 @@ /** Approval composer and optional correlated-detail contracts. */ -import type { CallId } from '@deepseek-ai/dsh-llm' +import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsLocale, PropsRenderSlots, PropsRuntime, @@ -45,7 +45,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Stable identity handed to an optional approval-detail renderer. */ export interface ApprovalDetailOwnerProps { /** Tool call correlated with the request. */ - callId: CallId + callId: ToolCallId } /** Client-visible fields of an approval request projected through Remote Events. */ @@ -53,7 +53,7 @@ export interface ApprovalPresentationRequest { /** Tool requesting the decision. */ readonly toolName: string /** Tool call correlated with the request. */ - readonly callId?: CallId + readonly callId?: ToolCallId /** Human-readable reason supplied by the requester. */ readonly reason?: string /** Cancellation projected from the Host waterfall. */ @@ -74,7 +74,7 @@ export class PendingApproval { /** Tool requesting the decision. */ readonly toolName: string /** Correlated Tool call, when supplied by the asker. */ - readonly callId: CallId | undefined + readonly callId: ToolCallId | undefined /** Human-readable reason supplied by the asker. */ readonly reason: string | undefined /** Result returned by the Remote Event listener to the Host waterfall. */ diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx index 665ae928a4..2a550ef514 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import type { CallId } from '@deepseek-ai/dsh-llm' +import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -122,7 +122,7 @@ describe('PendingApproval', () => { const remove = vi.spyOn(controller.signal, 'removeEventListener') const pending = new PendingApproval(id('s1'), { toolName: 'bash', - callId: 'call-1' as CallId, + callId: 'call-1' as ToolCallId, reason: 'needs access', signal: controller.signal, }) @@ -343,7 +343,7 @@ describe('ApprovalPanel', () => { it('renders correlated detail and returns allow-once', async () => { const pending = new PendingApproval(id('s1'), { toolName: 'bash', - callId: 'call-1' as CallId, + callId: 'call-1' as ToolCallId, reason: 'Run this exact command', }) const renderSlot = vi.fn(() => pnpm test) diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 9c2153b4d3..1a81c35285 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -34,6 +34,10 @@ font-size: var(--dsh-content-font-size, 14px); line-height: calc(22px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-primary); + /* The projected user text is inline runs; the bubble owns wrapping so a + decorated single-line message stays one line. */ + white-space: pre-wrap; + word-break: break-word; } .referenceSummary { @@ -288,24 +292,3 @@ animation: none; } } - -/* Inline references use domain glyphs and business-color text without another - container inside the user bubble. */ -.refChip { - display: inline-flex; - align-items: center; - gap: 4px; - margin: 0 2px; - color: var(--dsw-alias-state-business-primary); - font-weight: 500; - white-space: nowrap; - vertical-align: baseline; -} - -/* Inline reference glyphs (always ReferenceIcon svgs) ride the bubble text - size: the 16px svg edge moves by the same px delta as the surrounding font. */ -.refIcon { - flex: none; - width: calc(16px + var(--dsh-content-font-delta, 0px)); - height: calc(16px + var(--dsh-content-font-delta, 0px)); -} diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index c7368dc24d..aa3395bde1 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import { JsonBlock, MessageText, ReferenceIcon, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' @@ -144,74 +144,6 @@ function TurnMaxTokensItem({ t }: { ) } -/** - * Display projection of reference forms in a user bubble (free geometry — no - * textarea alignment constraint here); everything else stays plain text. The - * logged model text remains the single truth; this is presentation only. - * Plain-text `/name` / `@name` word-boundary tokens decorate (the sent text - * IS the reference — the bubble uses the same plainest token - * scan as the composer, minus the lexicon: sent tokens were validated at - * compose time, so shape alone decorates). - */ -function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode { - const ranges: { start: number; end: number; label: string; kind: 'session' | 'plain' }[] = [] - for (const rawLabel of [...new Set(sessionLabels)].sort((a, b) => b.length - a.length)) { - const label = `@${rawLabel}` - let start = text.indexOf(label) - while (start >= 0) { - ranges.push({ start, end: start + label.length, label, kind: 'session' }) - start = text.indexOf(label, start + label.length) - } - } - const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu - let m: RegExpExecArray | null - while ((m = re.exec(text)) !== null) { - const tokenStart = m.index + (m[1]?.length ?? 0) - const rawLabel = m[2] ?? '' - const label = rawLabel.startsWith('@"') - ? rawLabel - : rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '') - if (label.length <= 1) continue - ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) - } - ranges.sort((a, b) => a.start - b.start - || (a.kind === b.kind ? b.end - a.end : a.kind === 'session' ? -1 : 1)) - const parts: ReactNode[] = [] - let cursor = 0 - for (const range of ranges) { - if (range.start < cursor) continue - const { start: tokenStart, end, label, kind } = range - if (tokenStart > cursor) parts.push() - const referenceKind = kind === 'session' - ? 'session' - : label.startsWith('@') - ? label.endsWith('/') ? 'folder' : 'file' - : undefined - const displayLabel = referenceKind === undefined - ? label - : referenceKind === 'session' - ? label.slice(1) - : label.slice(1).replace(/^"|"$/gu, '').split(/[\\/]/u).filter(Boolean).at(-1) ?? label.slice(1) - parts.push( - - {referenceKind !== undefined && ( - - )} - {displayLabel} - , - ) - cursor = end - } - if (parts.length === 0) return - if (cursor < text.length) parts.push() - return <>{parts} -} - /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ content, renderMessageImages, actions, pending = false, referenceLabels = [], t, diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index cff42849eb..49c2cb841b 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -11,7 +11,7 @@ import type { import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { createChatStore } from '../stores.ts' -import type { CallId, SelectionTarget } from './store.ts' +import type { ToolCallId, SelectionTarget } from './store.ts' import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts' @@ -59,10 +59,10 @@ export interface ChatNodeTurnDataInjected { /** Stable owner currency delivered to a keyed Chat renderer. */ export interface ChatNodeOwnerProps { - selectedCallId?: CallId | undefined + selectedCallId?: ToolCallId | undefined cwd?: string | undefined openFile: (path: string) => void - inspectCall: (callId: CallId) => void + inspectCall: (callId: ToolCallId) => void forkAt: (seq: number) => void renderMessageImages: RenderMessageImages fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined diff --git a/packages/client/ui-chat/src/client/contract/store.ts b/packages/client/ui-chat/src/client/contract/store.ts index 41a3e58052..94d2d914a7 100644 --- a/packages/client/ui-chat/src/client/contract/store.ts +++ b/packages/client/ui-chat/src/client/contract/store.ts @@ -1,13 +1,13 @@ /** Chat-owned selection state shared by the transcript and details panel. */ /** Tool call identity as carried by Chat nodes. */ -export type CallId = string +export type ToolCallId = string /** Selection target for the Chat details linkage channel. */ export interface SelectionTarget { turnSeq: number stepSeq?: number - callId?: CallId + callId?: ToolCallId toolName?: string } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index e0d2b22b1c..62893382b6 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -25,7 +25,7 @@ export type { FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData, } from './contract/chat-nodes.ts' -export type { CallId, ChatStoreState, SelectionTarget } from './contract/store.ts' +export type { ToolCallId, ChatStoreState, SelectionTarget } from './contract/store.ts' export type { AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index e406f5da4a..1f200a2029 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -981,7 +981,7 @@ describe('ChatView', () => { const view = render() expect(view.container.querySelectorAll('h1')).toHaveLength(1) const literal = view.getByText((_content, element) => ( - element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown + element?.tagName === 'SPAN' && element.childElementCount === 0 && element.textContent === markdown )) expect(literal.querySelector('h1')).toBeNull() diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 8aa633f158..5c39be0e97 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -153,6 +153,7 @@ function menuPick(source: InputTriggerSource, name: string, session: ClientSessi session, position: 'leading', via: 'menu', + action: 'pick', span: { start: 0, end: end ?? name.length + 1, draftRev: 3 }, } return source.onPick(pick) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 122fcc3d5d..d7a0420bec 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: 276f4126328ab26f610e8a59bc18e43283290296 -README.zh.md: 3b3e2135625e521bc4362de657d259222cb3d09b +README.md: 188a4461c1ab46e8be4f96c402c4707b1c66c415 +README.zh.md: 63356b04ef07ddd8c037d46d0ad105ed01afda80 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 276f412632..188a4461c1 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ The package registers the optional-Session `conversation` shell, strict Session View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. -The resident composer survives no-Session and Session transitions. The no-Session state keeps the same textarea mounted but inert while the Workspace picker connects a blank Session. Draft text is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label), while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3b3e213562..63356b04ef 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个 textarea 保持 inert,Workspace picker 连接 blank Session;草稿文本镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 76655ba2da..c86c5f335e 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -50,7 +50,12 @@ "license": "MIT", "dependencies": { "clsx": "^2.0.0", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@lexical/history": "^0.49.0", + "@lexical/plain-text": "^0.49.0", + "@lexical/text": "^0.49.0", + "@lexical/utils": "^0.49.0", + "lexical": "^0.49.0" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -113,6 +118,9 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", + "@lexical/headless": "^0.49.0", + "react-dom": "^18.2.0", + "@types/react-dom": "~18.3.0", "zod": "^4.4.3" }, "files": [ diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 7d50fa8124..13aa8a82e8 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -2,12 +2,14 @@ * Frozen input-machine contract. Types * only. Three-tier visibility: business packages see InputState via the * InputZone currency; the scoped input events carry the mutation verbs; the - * conversation wiring layer alone sees the full SessionInput. InputMachine - * (machine.ts) is package-private and never exported. + * conversation wiring layer alone sees the full SessionInput. The draft text + * and its reference chips live in the shell's Lexical editor; the machine + * here is the submit plane (phase, claim, attempt) alone. */ import type { Context } from '@deepseek-ai/cordis' import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { Branded } from '@deepseek-ai/dsh-brand' +import type { LexicalEditor } from 'lexical' import type { QueueRow } from './queue.ts' import type { InputSubmitMode } from './composer-submission.ts' @@ -64,7 +66,7 @@ export type PickOutcome = | undefined /** Keyboard keys intercepted by an open trigger menu. */ -export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' +export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' | 'tab' /** Trigger-menu keyboard routing result. */ export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass' @@ -165,19 +167,19 @@ export type DraftAttachmentId = Branded<'DraftAttachmentId'> /** * The scoped-event application verbs: the hub's bail listeners call these, - * and the boolean answer IS the event's bail value (true ⟺ the machine - * accepted after phase and span/bare-token guards). + * and the boolean answer IS the event's bail value (true ⟺ the editor + * applied the edit after phase and span guards). */ export interface InputTarget { /** Replace the trigger span with claim.token and enter claimed (span-CAS'd). */ beginCommand(claim: CommandClaim, span: TokenSpan): boolean - /** Replace the trigger span with one reference occurrence (span-CAS'd). */ + /** Replace the trigger span with one reference chip (span-CAS'd). */ insertReference(ref: ReferenceInsert, span: TokenSpan): boolean } /** Per-session input facade owned by the conversation wiring layer. */ export interface SessionInput extends InputTarget { - /** Single write path for draft text (all mutation rides machine events). */ + /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void /** Append ordered browser-owned image ids; busy admission phases refuse. */ addImages(ids: readonly DraftAttachmentId[]): boolean @@ -212,12 +214,12 @@ export interface SessionInputResolver { /** * The public input action face provided to every session-scope slot - * component: two stable-identity void callbacks, mirroring the - * useStore+actions convention. Command-style handles (track/arbitrate/space/ - * undo/paste/…) stay InputBar-private and never ride this face. + * component: stable-identity void callbacks, mirroring the + * useStore+actions convention. Command-style handles (arbitrate/space/ + * paste/…) stay InputBar-private and never ride this face. */ export interface InputActions { - /** Single public draft write path (full next draft; occurrence math via diff scan). */ + /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void /** Append ordered browser-owned image ids; busy admission phases refuse. */ addImages(ids: readonly DraftAttachmentId[]): boolean @@ -241,13 +243,15 @@ export interface InputNotice { * returns and event-handler semantics that must not enter the public provide * channel. Handed to the composer-bar entry through its own inject — * package-internal, never across a plugin boundary. The session shell - * satisfies it structurally. + * satisfies it structurally. Text editing itself rides the shell's Lexical + * editor (exposed here for the contenteditable binding); the members below + * are the submit-plane and trigger-pipeline verbs the editor does not own. */ export interface ComposerKeyboard { /** Live machine state for event-handler reads (render reads go through useInput). */ readonly snapshot: InputState - /** Draft write with the DOM-observed edit shape (narrows occurrence math). */ - setDraft(text: string, editRange?: EditRange): void + /** The shell-owned Lexical editor the composer binds its contenteditable to. */ + readonly editor: LexicalEditor /** Submit with an explicit delivery mode resolved by the keyboard policy. */ submit(mode: InputSubmitMode): void /** @@ -256,14 +260,14 @@ export interface ComposerKeyboard { * button is the same operation applied to the whole queue). */ steerQueue(): void - undo(): void - redo(): void - /** Paste over the selection (sync components ride the same transaction). */ - pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void - /** Caret/selection gestures the machine cannot observe end the paste attempt. */ - invalidatePaste(): void - /** Feed a draft/caret change through trigger detection (guard derived from phase). */ - track(draft: string, caret: number): void + /** Insert pasted plain text over the current editor selection (reference-placeholder-sanitized). */ + paste(text: string): void + /** + * The live selection as a detect-coordinate span (menu-launcher synthetic + * hits replace it on pick); an absent selection answers a collapsed span at + * the document end. + */ + caretSpan(): EditSelection /** Keyboard arbitration while the menu is open ('pass' when no pipeline). */ arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome /** Space adjudication; true = the input applied a claim — caller preventDefaults. */ @@ -275,42 +279,33 @@ export interface ComposerKeyboard { /** One independently addressable row projected from the transient queue snapshot. */ export type QueuedMessage = QueueRow -/** Guard union of the scoped consume-token event, checked by the machine. */ +/** Guard union of the scoped consume-token event, checked by the shell. */ export type ConsumeTokenGuard = ConsumeTokenRequest['guard'] -/** Half-open [start, end) range/selection in draft character coordinates. */ +/** Half-open [start, end) range/selection in detect-projection coordinates. */ export interface EditSelection { readonly start: number readonly end: number } /** - * One edit applied to the previous draft: [start, end) in the PREVIOUS - * draft's coordinates was replaced by insertedLength characters. Supplied by - * the wiring layer when the DOM event exposes the edit shape; absent, the - * machine recovers it with a prefix/suffix common-scan diff. - */ -export interface EditRange extends EditSelection { - readonly insertedLength: number -} - -/** - * One reference occurrence backed by its complete inline display text in the - * draft. Identity is occurrenceId — same-named - * references stay independently addressable. label/appearance/clipboardText are the - * owner's insert-time projections, cached so the chip survives owner loss - * (invalid flips instead of dropping the occurrence). + * One reference occurrence projected from the editor's chip nodes, in + * clipboard-text coordinates. Identity is occurrenceId — a stable per-shell + * assignment per chip NodeKey, so same-named references stay independently + * addressable and survive undo. label/appearance/clipboardText are the + * owner's insert-time projections cached on the node (invalid flips instead + * of dropping the occurrence). */ export interface Occurrence { - /** Machine-minted stable identity (monotonic per machine). */ + /** Shell-assigned stable identity (monotonic per shell, keyed by NodeKey). */ readonly occurrenceId: number /** Owning source name (serializer routing key). */ readonly source: string /** Owner-scoped reference id. */ readonly ref: string - /** Display-text offset in the draft. */ + /** Offset in the clipboard-text projection. */ readonly offset: number - /** Display-text length; the occurrence occupies exactly [offset, offset+length). */ + /** Length in the clipboard-text projection; the occurrence occupies exactly [offset, offset+length). */ readonly length: number /** Inline display label (insert-time cache). */ readonly label: string @@ -318,57 +313,23 @@ export interface Occurrence { readonly appearance?: ReferenceInsert['appearance'] /** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */ readonly clipboardText: string - /** Owner-resolution failure flag: chip renders invalid; serialization must fail. */ + /** Owner-resolution failure flag: the chip renders the failure treatment. */ readonly invalid?: boolean } -/** One sync-matched paste component; start/end are relative to the pasted text. */ -export interface PasteComponent extends EditSelection { - readonly reference: ReferenceInsert -} - -/** - * Live paste-match attempt published while async matching may still upgrade - * pasted tokens (the clipboard round-trip). Any non-paste transaction, - * submit start, invalidate-paste, or release ends it; a paste-upgrade keeps - * it current (later tokens re-CAS against the advanced draftRev). - */ -export interface PasteAttemptState { - /** Machine-minted attempt identity (paste-upgrade must match it). */ - readonly attemptId: number - /** Pasted range in the draft as of the paste transaction. */ - readonly insertedRange: EditSelection - /** Caller-supplied projection generation echoed back (the controller drops cross-generation results). */ - readonly generation: number -} - -/** - * InputMachine construction knobs. The machine never reads an ambient clock: - * `now` is the only time source, injected by the shell (tests inject a - * fake). The default clock is constant, i.e. consecutive single-char typing - * always coalesces until a non-typing transaction intervenes. - */ -export interface InputMachineOptions { - /** Single-char typing undo-merge window in ms (default 1000). */ - readonly mergeWindowMs?: number - /** Monotonic clock for typing-merge decisions (default: constant 0). */ - readonly now?: () => number -} - /** Published input state (the currency; per-session). */ export interface InputState { + /** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */ readonly draft: string /** Ordered runtime-only image ids; bytes and URLs stay in ConversationController. */ readonly imageIds: readonly DraftAttachmentId[] - /** Monotonic draft revision (span CAS compares against this). */ + /** Monotonic editor revision (span CAS compares against this). */ readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean } - /** Reference occurrence table, sorted by offset. */ + /** Reference occurrence view of the editor's chips, sorted by offset. */ readonly occurrences: readonly Occurrence[] - /** Live paste-match attempt (absent when no paste is matchable). */ - readonly paste?: PasteAttemptState /** Read-only transient inbox projection from Session control, including pending steering. */ readonly queue: readonly QueuedMessage[] } @@ -382,56 +343,46 @@ export interface InputState { export interface SubmitAttempt { readonly seq: number readonly signal: AbortSignal - /** Draft at enter time; settlement clears it only after acceptance. */ + /** Clipboard-projection draft at enter time; settlement clears it only after acceptance. */ readonly draftSnapshot: string /** Default-message delivery intent retained while slash adjudication is pending. */ readonly mode: InputSubmitMode } /** - * InputMachine input events (the machine's single write path). Every draft - * mutation is one transaction: draft edit, occurrence reconciliation, and - * undo-log push are atomic inside dispatch(). Events carrying `at` stamp the - * injected clock reading; only single-char typing coalescing reads it. + * Submit-machine input events (the machine's single write path). Text + * mutation lives in the editor; the machine only observes the draft through + * event payloads (claim integrity, enter snapshots, settlement decisions). */ export type InputEvent = - /** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */ - | { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange } - | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan } - /** Place one inline reference at the span and mint the occurrence (scoped insert-reference event payload). */ - | { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan } - /** Delete a settled command token; success is observable as a draftRev advance. */ - | { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard } - /** Owner-resolution result: exactly the listed occurrences are invalid (style bit; not a transaction). */ - | { readonly type: 'set-invalid'; readonly invalidIds: readonly number[] } - | { readonly type: 'undo' } - | { readonly type: 'redo' } - /** - * Paste text replacing the selection, one transaction. Hot-snapshot sync - * matches ride in as components (chips minted inside the SAME transaction: - * one undo returns to pre-paste); a PasteMatchAttempt opens for the async - * remainder. Component ranges must be disjoint and inside the pasted text. - */ - | { readonly type: 'paste-begin'; readonly text: string; readonly selection: EditSelection; readonly components?: readonly PasteComponent[]; readonly generation?: number } - /** Async match landed: upgrade one pasted token to a chip as an INDEPENDENT transaction (undo #1 → text, undo #2 → pre-paste). */ - | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert } - /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */ - | { readonly type: 'invalidate-paste' } - | { readonly type: 'enter'; readonly mode: InputSubmitMode } + /** Clipboard projection changed: the claimed integrity watch runs (zero effects). */ + | { readonly type: 'draft-changed'; readonly draft: string } + /** The editor applied a claim-token replacement: enter claimed. */ + | { readonly type: 'claim'; readonly claim: CommandClaim } + /** Enter submission with the current clipboard projection. */ + | { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string } | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } - | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } + /** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */ + | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string } /** Commit an image-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } /** - * InputMachine output effects (executed by the SessionInput shell; the - * machine stays pure). Draft/occurrence mutations carry no effect — the - * shell publishes the state store after every dispatch. + * Submit-machine output effects (executed by the SessionInput shell; the + * machine stays pure). */ export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } + /** + * Clear the committed draft in the editor and cut undo history. A string + * snapshot keeps a pure suffix typed during the Host round-trip (content + * appended after the sent snapshot survives; interleaved edits cannot be + * separated and clear whole); null clears unconditionally (image-only + * sends have no draft to retain). + */ + | { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a7865d5906..b95b1ce5b1 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -215,14 +215,14 @@ export interface ComposerBarOwnerProps { variant: 'hero' | 'composer' /** A feature-owned reason that makes message input inert while leaving model selection live. */ blocked?: { readonly reason: string } - /** Lock all message actions while preserving the resident textarea. */ + /** Lock all message actions while preserving the resident composer surface. */ disabled?: boolean /** Whether the shared Workspace picker is expanded. */ workspacePickerOpen?: boolean - /** Open the Workspace picker from the inert textarea. */ + /** Open the Workspace picker from the inert composer surface. */ onRequestWorkspace?: () => void placeholder?: string - /** Optional content rendered above the textarea. */ + /** Optional content rendered above the composer surface. */ accessory?: ReactNode /** Floating overlay content rendered inside the composer card. */ overlay?: ReactNode diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts new file mode 100644 index 0000000000..5716c8108f --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -0,0 +1,66 @@ +/** + * Plain-text reference scan (the plain-text-reference decision; + * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): + * a `/name` or `@name` token whose name is on the trigger's lexicon, and + * syntax-recognizable `@dir/` folder tokens. Pure derivation — the editor's + * text-ref entity transform consumes these ranges; editing the text out of + * match shape simply drops the range next scan. + */ + +/** + * One plain-text reference range (the plain-text-reference decision; + * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): + * a `/name` or `@name` token + * whose name is on the trigger's lexicon. Pure derivation — editing the text + * out of match shape simply drops the range next scan. + */ +export interface TextRefRange { + readonly start: number + readonly end: number + readonly trigger: '/' | '@' + /** Optional icon domain for syntax-recognizable plain references. */ + readonly appearance?: 'folder' +} + +/** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ +const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g +const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g + +/** + * Scan the draft for plain-text reference tokens against the hot lexicons. + * Word-boundary discipline: the trigger must sit at the draft + * start or after whitespace ('x/name' never matches); the name must be an + * exact lexicon member. + * @param draft - draft text. + * @param lexicon - per-trigger name lists (a missing trigger scans nothing). + * @returns matched ranges in draft order. + */ +export function scanTextRefs( + draft: string, lexicon: ReadonlyMap<'/' | '@', readonly string[]>, +): TextRefRange[] { + if (draft === '') return [] + const out: TextRefRange[] = [] + if (lexicon.size > 0) { + TEXT_REF_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = TEXT_REF_RE.exec(draft)) !== null) { + const trigger = m[2] as '/' | '@' + const name = m[3] ?? '' + if (lexicon.get(trigger)?.includes(name)) { + const start = m.index + (m[1]?.length ?? 0) + out.push({ start, end: start + 1 + name.length, trigger }) + } + } + } + FOLDER_REF_RE.lastIndex = 0 + let folder: RegExpExecArray | null + while ((folder = FOLDER_REF_RE.exec(draft)) !== null) { + const token = folder[2] ?? '' + const start = folder.index + (folder[1]?.length ?? 0) + const end = start + token.length + if (!out.some(range => range.start < end && range.end > start)) { + out.push({ start, end, trigger: '@', appearance: 'folder' }) + } + } + return out.sort((left, right) => left.start - right.start) +} diff --git a/packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx b/packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx new file mode 100644 index 0000000000..b975ee1f2c --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx @@ -0,0 +1,50 @@ +/** + * The composer's contenteditable host: binds one shell-owned Lexical editor + * to a resident div. Session-maybe by design — a null editor renders the + * same DOM inert (the no-session Workspace-trigger state), so switching + * between the two never swaps the element tree. Editability has ONE writer: + * this component reflects the `editable` prop onto the editor; nothing else + * calls setEditable. + */ +import { useLayoutEffect, useRef } from 'react' +import type { HTMLAttributes, ReactNode } from 'react' +import type { LexicalEditor } from 'lexical' + +/** Host props: the editor binding plus the div passthroughs the bar owns. */ +export interface ComposerContentEditableProps extends HTMLAttributes { + /** The shell-owned editor; null renders the same div unbound and inert. */ + readonly editor: LexicalEditor | null + /** Whether the user may edit (readOnly/disabled states fold in here). */ + readonly editable: boolean +} + +/** + * Render the composer's editable surface. + * @param props - editor binding, editability, and div passthroughs. + * @returns the resident contenteditable div. + */ +export function ComposerContentEditable({ editor, editable, ...rest }: ComposerContentEditableProps): ReactNode { + const ref = useRef(null) + useLayoutEffect(() => { + const el = ref.current + if (editor === null || el === null) return + editor.setRootElement(el) + return () => { editor.setRootElement(null) } + }, [editor]) + useLayoutEffect(() => { + if (editor !== null) editor.setEditable(editable) + }, [editor, editable]) + return ( +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx b/packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx new file mode 100644 index 0000000000..08a054698c --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx @@ -0,0 +1,41 @@ +/** + * Decorator render loop: portals every decorator node's React face into its + * host element (what @lexical/react's composer does internally, scoped to + * this composer's needs). Chip DOM identity rides the NodeKey — text edits + * around a chip never remount its portal. + */ +import * as React from 'react' +import { createPortal } from 'react-dom' +import type { ReactNode } from 'react' +import type { LexicalEditor, NodeKey } from 'lexical' + +/** Portal-loop props. */ +export interface DecoratorPortalsProps { + /** The bound editor; null (no-session) renders nothing. */ + readonly editor: LexicalEditor | null +} + +/** + * Render every decorator's React face into its editor host element. + * @param props - the editor to observe. + * @returns the live portal set. + */ +export function DecoratorPortals({ editor }: DecoratorPortalsProps): ReactNode { + const [decorators, setDecorators] = React.useState>( + () => editor === null ? {} : editor.getDecorators(), + ) + React.useLayoutEffect(() => { + if (editor === null) return + setDecorators(editor.getDecorators()) + return editor.registerDecoratorListener((next) => { setDecorators(next) }) + }, [editor]) + if (editor === null) return null + return ( + <> + {Object.entries(decorators).map(([key, jsx]) => { + const el = editor.getElementByKey(key) + return el === null ? null : createPortal(jsx, el, key) + })} + + ) +} diff --git a/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css new file mode 100644 index 0000000000..84b0ed08ac --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css @@ -0,0 +1,41 @@ +/* Inline reference chip: a real DOM capsule (the three-layer backdrop trick + and its advance-preserving constraints are gone — background, padding, + radius, and label truncation are ordinary styles here). Vertical metrics + stay inside the composer's 24px line so a chip never changes line height. */ + +.chip { + display: inline-flex; + align-items: center; + gap: 3px; + max-width: 240px; + padding: 0 6px; + border-radius: 6px; + vertical-align: bottom; + line-height: 22px; + height: 22px; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-state-business-primary); + user-select: none; + cursor: default; +} + +.marker { + flex: none; + font-weight: 500; +} + +.icon { + flex: none; +} + +.label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.invalid { + color: var(--dsw-alias-state-error-primary); + text-decoration: line-through; + opacity: 0.7; +} diff --git a/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx new file mode 100644 index 0000000000..6844375184 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx @@ -0,0 +1,35 @@ +/** + * Visual body of one inline reference chip: the DecoratorNode's React + * face. Pure display — identity, invalidation, and lifecycle live on the + * ReferenceChipNode; this component renders whatever the node carries. + */ +import clsx from 'clsx' +import type { ReactNode } from 'react' +import { ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ReferenceIconKind } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './ReferenceChip.module.css' + +/** Display inputs of one chip (the node's cached owner projections). */ +export interface ReferenceChipProps { + readonly label: string + /** Domain glyph; absent renders the trigger marker instead of an icon. */ + readonly appearance?: ReferenceIconKind | undefined + /** Owner-resolution failure styling bit. */ + readonly invalid: boolean +} + +/** + * Render one inline reference chip. + * @param props - label, optional domain glyph, and the invalid bit. + * @returns the chip body (icon + truncating label). + */ +export function ReferenceChip({ label, appearance, invalid }: ReferenceChipProps): ReactNode { + return ( + + {appearance === undefined + ? @ + : } + {label} + + ) +} diff --git a/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx b/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx new file mode 100644 index 0000000000..16de06e878 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx @@ -0,0 +1,214 @@ +/** + * ReferenceChipNode: one inline reference as an atomic Lexical decorator. + * The node IS the occurrence — NodeKey carries identity, the node carries + * the owner's insert-time projections (label/appearance/clipboardText), and + * `getTextContent()` answers the clipboard/persistence projection so native + * copy and the draft mirror stay correct without expansion code. The detect + * projection (trigger scanning and TokenSpan coordinates) counts every chip + * as one U+FFFC instead; see projection.ts. + */ +import type { JSX } from 'react' +import type { + EditorConfig, LexicalNode, NodeKey, SerializedLexicalNode, Spread, +} from 'lexical' +import { DecoratorNode } from 'lexical' +import type { ReferenceInsert } from '../../contract/input.ts' +import { ReferenceChip } from './ReferenceChip.tsx' + +/** JSON form of one chip (Lexical node serialization contract). */ +export type SerializedReferenceChipNode = Spread<{ + source: string + ref: string + label: string + appearance?: ReferenceInsert['appearance'] + clipboardText: string + invalid: boolean +}, SerializedLexicalNode> + +/** One inline reference occurrence as an atomic decorator node. */ +export class ReferenceChipNode extends DecoratorNode { + /** Owning source name (serializer routing key). */ + __source: string + /** Owner-scoped reference id. */ + __ref: string + /** Inline display label (insert-time cache). */ + __label: string + /** Optional domain glyph (insert-time cache). */ + __appearance: ReferenceInsert['appearance'] + /** Clipboard / persistence projection, e.g. `/name` (never the model form). */ + __clipboardText: string + /** Owner-resolution failure flag: chip renders invalid; serialization must fail. */ + __invalid: boolean + + /** Lexical node registry type tag. */ + static override getType(): string { + return 'reference-chip' + } + + /** + * Clone with identity (Lexical writable-copy contract). + * @param node - node to clone. + * @returns a copy carrying the same NodeKey. + */ + static override clone(node: ReferenceChipNode): ReferenceChipNode { + return new ReferenceChipNode( + { + source: node.__source, + ref: node.__ref, + label: node.__label, + appearance: node.__appearance, + clipboardText: node.__clipboardText, + }, + node.__invalid, + node.__key, + ) + } + + /** + * Rebuild one chip from its JSON form. + * @param json - serialized chip. + * @returns a fresh node (new key). + */ + static override importJSON(json: SerializedReferenceChipNode): ReferenceChipNode { + return new ReferenceChipNode( + { + source: json.source, + ref: json.ref, + label: json.label, + appearance: json.appearance, + clipboardText: json.clipboardText, + }, + json.invalid, + ) + } + + /** + * @param insert - the owner's reference insertion (display projections included). + * @param invalid - owner-resolution failure bit (defaults valid). + * @param key - Lexical clone-path key; absent for fresh nodes. + */ + constructor(insert: Omit & { appearance?: ReferenceInsert['appearance'] }, invalid = false, key?: NodeKey) { + super(key) + this.__source = insert.source + this.__ref = insert.ref + this.__label = insert.label + this.__appearance = insert.appearance + this.__clipboardText = insert.clipboardText + this.__invalid = invalid + } + + /** Serialize to the JSON node form. */ + override exportJSON(): SerializedReferenceChipNode { + return { + ...super.exportJSON(), + type: 'reference-chip', + version: 1, + source: this.__source, + ref: this.__ref, + label: this.__label, + ...(this.__appearance === undefined ? {} : { appearance: this.__appearance }), + clipboardText: this.__clipboardText, + invalid: this.__invalid, + } + } + + /** + * Mount the chip's host element; the decorator portal renders into it. + * @returns an inline, non-editable span carrying the test/e2e anchor. + */ + override createDOM(_config: EditorConfig): HTMLElement { + const el = document.createElement('span') + el.setAttribute('data-composer-chip', this.__source) + el.setAttribute('contenteditable', 'false') + return el + } + + /** Host element never changes shape. */ + override updateDOM(): boolean { + return false + } + + /** Chips sit in the text line. */ + override isInline(): boolean { + return true + } + + /** + * No keyboard-selected intermediate state: arrows step across the chip in + * one move and Backspace/Delete remove it whole (the placeholder semantics + * of the old textarea). `true` would put a NodeSelection between the + * keystroke and the caret — a state the plain-text binding's handlers all + * ignore, deadlocking arrows, typing, and deletion at the chip edge. + */ + override isKeyboardSelectable(): boolean { + return false + } + + /** Clipboard / persistence projection (native copy reads this). */ + override getTextContent(): string { + return this.__clipboardText + } + + /** + * Flip the owner-resolution failure bit. + * @param invalid - next bit; no-op writes are the caller's concern. + */ + setInvalid(invalid: boolean): void { + const writable = this.getWritable() + writable.__invalid = invalid + } + + /** Owner-resolution failure bit. */ + isInvalid(): boolean { + return this.getLatest().__invalid + } + + /** Owning source name. */ + getSource(): string { + return this.getLatest().__source + } + + /** Owner-scoped reference id. */ + getReference(): string { + return this.getLatest().__ref + } + + /** Inline display label. */ + getLabel(): string { + return this.getLatest().__label + } + + /** Optional domain glyph. */ + getAppearance(): ReferenceInsert['appearance'] { + return this.getLatest().__appearance + } + + /** React face rendered into the host element by the decorator portal. */ + override decorate(): JSX.Element { + return ( + + ) + } +} + +/** + * Mint one chip node from a reference insertion. + * @param insert - the owner's reference insertion. + * @returns the fresh node. + */ +export function $createReferenceChipNode(insert: ReferenceInsert): ReferenceChipNode { + return new ReferenceChipNode(insert) +} + +/** + * Chip type guard. + * @param node - any node or nullish. + * @returns whether the node is a ReferenceChipNode. + */ +export function $isReferenceChipNode(node: LexicalNode | null | undefined): node is ReferenceChipNode { + return node instanceof ReferenceChipNode +} diff --git a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts new file mode 100644 index 0000000000..09ef7b6727 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts @@ -0,0 +1,67 @@ +/** + * Claim-token highlight: while a command claim holds, the draft's leading + * token renders in the warn color. A TextNode transform keeps the token in + * its own styled node (splitting when typing merges text into it), and the + * shell nudges the first leaf dirty when the claim flips so entering and + * leaving claimed restyles without a text edit. + */ +import type { LexicalEditor, TextNode as TextNodeType } from 'lexical' +import { $getRoot, $isElementNode, $isTextNode, TextNode } from 'lexical' + +/** Inline style carried by the claim-token node (the old backdrop's hlToken color). */ +const TOKEN_STYLE = 'color: var(--dsw-alias-state-warn-label)' + +/** The document's first text leaf, or null (empty document / leading chip). */ +function firstTextLeaf(): TextNodeType | null { + const block = $getRoot().getFirstChild() + if (!$isElementNode(block)) return null + const leaf = block.getFirstChild() + return $isTextNode(leaf) ? leaf : null +} + +/** + * Register the claim-token styling transform. + * @param editor - the shell-owned editor. + * @param activeToken - live claim token accessor; null while unclaimed. + * @returns the unregister disposer. + */ +export function registerClaimDecoration(editor: LexicalEditor, activeToken: () => string | null): () => void { + return editor.registerNodeTransform(TextNode, (node) => { + const first = firstTextLeaf() + if (first === null || node.getKey() !== first.getKey()) { + // Off the token seat: clear a stale token style (a node can move here + // by paragraph merges). + if (node.getStyle() === TOKEN_STYLE) node.setStyle('') + return + } + const token = activeToken() + const text = node.getTextContent() + if (token === null || !text.startsWith(token)) { + if (node.getStyle() === TOKEN_STYLE) node.setStyle('') + return + } + if (text.length > token.length) { + // Typing at the token boundary lands in the styled node; split the + // overflow back out so only the token itself carries the color. + const [tokenNode] = node.splitText(token.length) + if (tokenNode !== undefined && tokenNode.getStyle() !== TOKEN_STYLE) tokenNode.setStyle(TOKEN_STYLE) + return + } + if (node.getStyle() !== TOKEN_STYLE) node.setStyle(TOKEN_STYLE) + }) +} + +/** + * Nudge the token seat dirty so the transform restyles after a claim flip + * (claims change phase without a text edit; transforms only run on dirty + * nodes). + * @param editor - the shell-owned editor. + */ +export function refreshClaimDecoration(editor: LexicalEditor): void { + // Not discrete: a refresh can fire from inside an update listener, where a + // synchronous nested commit would recurse; the queued update lands on the + // next flush. + editor.update(() => { + firstTextLeaf()?.markDirty() + }) +} diff --git a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css new file mode 100644 index 0000000000..40f6f2d075 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css @@ -0,0 +1,26 @@ +/* Editor-internal decoration styles: nodes Lexical mounts inside the + contenteditable (chip hosts get their look from ReferenceChip.module.css; + this sheet covers text-level decorations). */ + +/* Plain-text reference: chip family colors over the draft's own glyphs. + clone keeps rounded ends on soft-wrap fragments. */ +.textRef { + color: var(--dsw-alias-state-business-primary); + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +/* Folder references carry the domain glyph as an icon prefix (the bubble's + IconFolderClose16 asset as a currentcolor mask). The literal token text — + trigger character included — stays intact: a Lexical text node cannot + split out its trigger character the way the old backdrop overpainted it. */ +.textRef[data-ref-appearance='folder']::before { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 2px; + vertical-align: -2px; + background-color: currentcolor; + content: ''; + mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cpath%20transform%3D%22translate(1.5%202.429)%22%20d%3D%22M5.05582%200.518756L4.50669%200.86654L5.05582%200.518756ZM13%209.4837L13.65%209.4837L13.65%203.53962L13%203.53962L12.35%203.53962L12.35%209.4837L13%209.4837ZM11.3264%201.86603L11.3264%201.21603L6.52313%201.21603L6.52313%201.86603L6.52313%202.51603L11.3264%202.51603L11.3264%201.86603ZM5.58054%201.34727L6.12968%200.999489L5.60495%200.170972L5.05582%200.518756L4.50669%200.86654L5.03141%201.69506L5.58054%201.34727ZM4.11323%201.23058e-13L4.11323%20-0.65L1.67359%20-0.65L1.67359%205.00699e-14L1.67359%200.65L4.11323%200.65L4.11323%201.23058e-13ZM0%201.67359L-0.65%201.67359L-0.65%209.4837L0%209.4837L0.65%209.4837L0.65%201.67359L0%201.67359ZM11.3264%2011.1573L11.3264%2010.5073L1.67359%2010.5073L1.67359%2011.1573L1.67359%2011.8073L11.3264%2011.8073L11.3264%2011.1573ZM0%209.4837L-0.65%209.4837C-0.65%2010.767%200.390308%2011.8073%201.67359%2011.8073L1.67359%2011.1573L1.67359%2010.5073C1.10828%2010.5073%200.65%2010.049%200.65%209.4837L0%209.4837ZM1.67359%205.00699e-14L1.67359%20-0.65C0.390307%20-0.65%20-0.65%200.390309%20-0.65%201.67359L0%201.67359L0.65%201.67359C0.65%201.10828%201.10828%200.65%201.67359%200.65L1.67359%205.00699e-14ZM5.05582%200.518756L5.60495%200.170972C5.28121%20-0.340193%204.71829%20-0.65%204.11323%20-0.65L4.11323%201.23058e-13L4.11323%200.65C4.27282%200.65%204.4213%200.731715%204.50669%200.86654L5.05582%200.518756ZM6.52313%201.86603L6.52313%201.21603C6.36354%201.21603%206.21507%201.13431%206.12968%200.999489L5.58054%201.34727L5.03141%201.69506C5.35515%202.20622%205.91808%202.51603%206.52313%202.51603L6.52313%201.86603ZM13%203.53962L13.65%203.53962C13.65%202.25634%2012.6097%201.21603%2011.3264%201.21603L11.3264%201.86603L11.3264%202.51603C11.8917%202.51603%2012.35%202.97431%2012.35%203.53962L13%203.53962ZM13%209.4837L12.35%209.4837C12.35%2010.049%2011.8917%2010.5073%2011.3264%2010.5073L11.3264%2011.1573L11.3264%2011.8073C12.6097%2011.8073%2013.65%2010.767%2013.65%209.4837L13%209.4837Z%22%20fill%3D%22black%22%2F%3E%3C%2Fsvg%3E") center / contain no-repeat; +} diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts new file mode 100644 index 0000000000..3c3e46027b --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -0,0 +1,151 @@ +/** + * Composer keymap over the Lexical command layer: menu arbitration + * (arrows/escape/enter), space adjudication, the Enter submit gesture, and + * paste routing. Registered at CRITICAL priority so it decides before + * @lexical/plain-text's own Enter/paste defaults; a handler returning false + * falls through to those defaults (Shift+Enter's line break, ordinary + * spaces, text paste the bar routes itself). + * + * IME guard: a composition-closing Enter/Space must not submit or adjudicate. + * KeyboardEvent.isComposing covers most engines; Safari delivers the closing + * keydown AFTER compositionend, so a root-element composition watch holds the + * guard for 10ms more (the old textarea's proven window); keyCode + * 229 is the legacy signal engines emit without isComposing. + */ +import type { LexicalEditor } from 'lexical' +import { + COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND, PASTE_COMMAND, +} from 'lexical' +import { mergeRegister } from '@lexical/utils' +import type { ArbitrateKey, ArbitrateOutcome } from '../../contract/input.ts' + +/** The bar-supplied behavior behind each intercepted gesture. */ +export interface ComposerKeymapHandlers { + /** Keyboard arbitration while the menu is open ('pass' when no pipeline). */ + arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome + /** Space adjudication; true = a claim was applied — the keystroke is consumed. */ + space(): boolean + /** Dismiss the popupSelect shell (Escape layering: an open overlay closes first). */ + dismissPopup(): void + /** Whether Enter may submit right now (locked/busy states refuse). */ + canSubmit(): boolean + /** The Enter gesture after every guard passed; `accelerated` = Ctrl/Cmd held. */ + submit(accelerated: boolean): void + /** Pasted files (image intake). */ + intakeFiles(files: readonly File[]): void + /** Pasted plain text (sanitized insertion through the shell). */ + pasteText(text: string): void +} + +/** Composition state a keydown can trust (see the module doc's Safari note). */ +function isComposingEvent(event: KeyboardEvent, recentlyComposing: () => boolean): boolean { + // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. + // oxlint-disable-next-line typescript/no-deprecated + return event.isComposing || event.keyCode === 229 || recentlyComposing() +} + +/** + * Register the composer keymap on one editor. + * @param editor - the shell-owned editor. + * @param handlers - bar-supplied behavior. + * @returns the unregister disposer. + */ +export function registerComposerKeymap(editor: LexicalEditor, handlers: ComposerKeymapHandlers): () => void { + // Composition watch: true through composition and for one tick after + // compositionend (Safari's late closing keydown). The listener rides the + // root element and re-arms on root swaps. + let composing = false + let composingUntil = 0 + const onCompositionStart = (): void => { + composing = true + } + const onCompositionEnd = (): void => { + composing = false + composingUntil = Date.now() + 10 + } + const recentlyComposing = (): boolean => composing || Date.now() < composingUntil + + const arrow = (key: ArbitrateKey) => (event: KeyboardEvent | null): boolean => { + const inComposition = event !== null && isComposingEvent(event, recentlyComposing) + if (handlers.arbitrate(key, inComposition) === 'consumed') { + event?.preventDefault() + return true + } + return false + } + + return mergeRegister( + editor.registerRootListener((root, prevRoot) => { + prevRoot?.removeEventListener('compositionstart', onCompositionStart) + prevRoot?.removeEventListener('compositionend', onCompositionEnd) + root?.addEventListener('compositionstart', onCompositionStart) + root?.addEventListener('compositionend', onCompositionEnd) + }), + editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL), + // Tab drills into a drillable highlighted row; otherwise it passes so the + // browser keeps its native focus traversal. + editor.registerCommand(KEY_TAB_COMMAND, arrow('tab'), COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(KEY_ESCAPE_COMMAND, (event) => { + // Escape layering: an open overlay closes; claimed without an overlay + // does NOT release (backspacing the token is the only exit gesture). + handlers.dismissPopup() + if (handlers.arbitrate('escape', isComposingEvent(event, recentlyComposing)) === 'consumed') { + event.preventDefault() + return true + } + return false + }, COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(KEY_SPACE_COMMAND, (event) => { + if (isComposingEvent(event, recentlyComposing)) return false + const consumed = handlers.space() + if (consumed) { + event.preventDefault() // claim token already carries the trailing separator + return true + } + return false + }, COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(KEY_ENTER_COMMAND, (event) => { + // Shift+Enter is the native line break UNCONDITIONALLY — decided before + // the IME guard so a composition-closing Shift+Enter still breaks the line. + if (event?.shiftKey === true) return false + if (event !== null && isComposingEvent(event, recentlyComposing)) { + // The IME consumes this Enter (candidate pick); neither submit nor + // break the line. No preventDefault: the browser owns the gesture. + return true + } + // Menu-open Enter picks the highlight through arbitration; a + // no-highlight menu passes down to the submit gesture. + if (handlers.arbitrate('enter', false) !== 'pass') { + event?.preventDefault() + return true + } + event?.preventDefault() + if (event?.repeat === true) return true // held-down Enter must not machine-gun sends + if (!handlers.canSubmit()) return true + handlers.submit(event?.ctrlKey === true || event?.metaKey === true) + return true + }, COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(PASTE_COMMAND, (event) => { + // Duck-typed: the payload union includes InputEvent, and test engines + // deliver clipboardData on plain events. + const clipboardData = (event as ClipboardEvent).clipboardData ?? null + if (clipboardData === null) return false + const files = Array.from(clipboardData.items) + .filter(item => item.kind === 'file') + .map(item => item.getAsFile()) + .filter((file): file is File => file !== null) + if (files.length > 0) handlers.intakeFiles(files) + const text = clipboardData.getData('text/plain') + if (text === '') { + if (files.length === 0) return false + event.preventDefault() + return true + } + event.preventDefault() + handlers.pasteText(text) + return true + }, COMMAND_PRIORITY_CRITICAL), + ) +} diff --git a/packages/client/ui-conversation/src/client/input/editor/projection.ts b/packages/client/ui-conversation/src/client/input/editor/projection.ts new file mode 100644 index 0000000000..1a25c95884 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/projection.ts @@ -0,0 +1,230 @@ +/** + * Composer editor projections: one EditorState, three pure text views. + * detectText feeds trigger detection and TokenSpan coordinates (every chip + * counts as one U+FFFC — the opaque-reference invariant); clipboardText + * feeds persistence, the InputState draft, and submit-plane decisions + * (chips expand to their clipboard projection); the model form is not a + * text view here — submit serializes chip nodes through their owner codec. + * All $-functions must run inside `editor.read()` / `editor.update()`. + */ +import type { ElementNode, LexicalNode, NodeKey, Point } from 'lexical' +import { + $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, +} from 'lexical' +import type { Occurrence } from '../../contract/input.ts' +import { $isReferenceChipNode } from './chip-node.tsx' + +/** The detect-projection stand-in for one chip (object replacement character). */ +export const ATOMIC_CHAR = '' + +/** One leaf (or gap) of the composer document in projection coordinates. */ +export interface ComposerSegment { + /** text/linebreak carry a node; chip is atomic; gap is the newline between block elements. */ + readonly kind: 'text' | 'chip' | 'linebreak' | 'gap' + /** The backing node; null only for gap. */ + readonly node: LexicalNode | null + readonly detectStart: number + readonly detectLength: number + readonly clipboardStart: number + readonly clipboardLength: number + /** gap only: the block elements this newline separates. */ + readonly gapBetween?: { readonly before: NodeKey; readonly after: NodeKey } +} + +/** One walk's product: segments plus the indexes point mapping needs. */ +export interface ComposerLayout { + readonly segments: readonly ComposerSegment[] + readonly detectLength: number + readonly detectText: string + readonly clipboardText: string + /** Leaf node key → its segment (text/chip/linebreak). */ + readonly byKey: ReadonlyMap + /** Element key → ordered child keys (root and every block element). */ + readonly children: ReadonlyMap + /** Element key → detect bounds of its content (gaps excluded). */ + readonly bounds: ReadonlyMap +} + +/** + * Walk the composer document once, producing every projection segment in + * document order. Blocks (paragraphs) contribute a one-newline gap between + * one another in both text projections. + * @returns the layout for this EditorState. + */ +export function $composerLayout(): ComposerLayout { + const segments: ComposerSegment[] = [] + const byKey = new Map() + const children = new Map() + const bounds = new Map() + let detect = '' + let clipboard = '' + + const pushLeaf = (kind: 'text' | 'chip' | 'linebreak', node: LexicalNode, detectPiece: string, clipboardPiece: string): void => { + const segment: ComposerSegment = { + kind, + node, + detectStart: detect.length, + detectLength: detectPiece.length, + clipboardStart: clipboard.length, + clipboardLength: clipboardPiece.length, + } + segments.push(segment) + byKey.set(node.getKey(), segment) + detect += detectPiece + clipboard += clipboardPiece + } + + const walkElement = (element: ElementNode): void => { + const start = detect.length + const kids = element.getChildren() + children.set(element.getKey(), kids.map(kid => kid.getKey())) + for (const kid of kids) { + if ($isReferenceChipNode(kid)) { + pushLeaf('chip', kid, ATOMIC_CHAR, kid.getTextContent()) + } else if ($isTextNode(kid)) { + const text = kid.getTextContent() + pushLeaf('text', kid, text, text) + } else if ($isLineBreakNode(kid)) { + pushLeaf('linebreak', kid, '\n', '\n') + } else if ($isElementNode(kid)) { + /* v8 ignore next 4 -- plain-text composition nests no block elements today; the walk stays total for imported states. */ + walkElement(kid) + } + // Unknown inline decorators contribute nothing: this composer registers + // no other decorator type, so the arm is unreachable by construction. + } + bounds.set(element.getKey(), { start, end: detect.length }) + } + + const root = $getRoot() + const blocks = root.getChildren() + children.set(root.getKey(), blocks.map(block => block.getKey())) + const rootStart = detect.length + blocks.forEach((block, index) => { + const previous = blocks[index - 1] + if (index > 0 && previous !== undefined) { + segments.push({ + kind: 'gap', + node: null, + detectStart: detect.length, + detectLength: 1, + clipboardStart: clipboard.length, + clipboardLength: 1, + gapBetween: { before: previous.getKey(), after: block.getKey() }, + }) + detect += '\n' + clipboard += '\n' + } + if ($isElementNode(block)) walkElement(block) + }) + bounds.set(root.getKey(), { start: rootStart, end: detect.length }) + + return { + segments, + detectLength: detect.length, + detectText: detect, + clipboardText: clipboard, + byKey, + children, + bounds, + } +} + +/** + * Fold one clipboard-projection offset to its detect-projection twin. + * Offsets inside a chip's clipboard expansion snap to the chip's trailing + * edge; callers only pass boundaries that were once a document end (submit + * snapshots), which never split a chip. + * @param layout - the current walk product. + * @param clipboardOffset - offset into the clipboard projection. + * @returns the detect offset covering the same document position. + */ +export function detectOffsetOfClipboardOffset(layout: ComposerLayout, clipboardOffset: number): number { + for (const segment of layout.segments) { + const end = segment.clipboardStart + segment.clipboardLength + if (clipboardOffset > end) continue + if (clipboardOffset === end) return segment.detectStart + segment.detectLength + if (segment.kind === 'chip') return segment.detectStart + segment.detectLength + return segment.detectStart + (clipboardOffset - segment.clipboardStart) + } + return layout.detectLength +} + +/** The published projection product consumed by the shell every update. */ +export interface EditorProjection { + /** Trigger/TokenSpan coordinate text (chip = one U+FFFC). */ + readonly detectText: string + /** Persistence/InputState draft text (chip = clipboardText). */ + readonly clipboardText: string + /** InputState-compatible occurrence view (clipboardText coordinates). */ + readonly occurrences: readonly Occurrence[] + /** Range selection in detect coordinates (ordered); null while absent or non-range. */ + readonly selection: { readonly start: number; readonly end: number } | null + /** Collapsed caret in detect coordinates; null while the selection is absent or ranged. */ + readonly caret: number | null +} + +/** + * Fold one selection point to a detect offset. + * @param layout - the current walk product. + * @param point - selection anchor/focus point. + * @returns detect offset, or null when the point references an unknown node. + */ +export function $detectOffsetOfPoint(layout: ComposerLayout, point: Point): number | null { + if (point.type === 'text') { + const segment = layout.byKey.get(point.key) + return segment === undefined ? null : segment.detectStart + Math.min(point.offset, segment.detectLength) + } + const kids = layout.children.get(point.key) + const elementBounds = layout.bounds.get(point.key) + if (kids === undefined || elementBounds === undefined) return null + if (point.offset >= kids.length) return elementBounds.end + const childKey = kids[point.offset] + if (childKey === undefined) return elementBounds.end + const childSegment = layout.byKey.get(childKey) + if (childSegment !== undefined) return childSegment.detectStart + const childBounds = layout.bounds.get(childKey) + return childBounds === undefined ? null : childBounds.start +} + +/** + * Project the composer document and its caret. + * @param idOf - stable occurrence-id assignment per chip NodeKey (the shell + * owns the map so ids survive across projections of the same node). + * @returns the three-view projection product. + */ +export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjection { + const layout = $composerLayout() + const occurrences: Occurrence[] = [] + for (const segment of layout.segments) { + if (segment.kind !== 'chip' || !$isReferenceChipNode(segment.node)) continue + const chip = segment.node + occurrences.push({ + occurrenceId: idOf(chip.getKey()), + source: chip.getSource(), + ref: chip.getReference(), + offset: segment.clipboardStart, + length: segment.clipboardLength, + label: chip.getLabel(), + ...(chip.getAppearance() === undefined ? {} : { appearance: chip.getAppearance() }), + clipboardText: chip.getTextContent(), + ...(chip.isInvalid() ? { invalid: true } : {}), + }) + } + const selection = $getSelection() + let range: { start: number; end: number } | null = null + if ($isRangeSelection(selection)) { + const anchor = $detectOffsetOfPoint(layout, selection.anchor) + const focus = $detectOffsetOfPoint(layout, selection.focus) + if (anchor !== null && focus !== null) { + range = { start: Math.min(anchor, focus), end: Math.max(anchor, focus) } + } + } + return { + detectText: layout.detectText, + clipboardText: layout.clipboardText, + occurrences, + selection: range, + caret: range !== null && range.start === range.end ? range.start : null, + } +} diff --git a/packages/client/ui-conversation/src/client/input/editor/span-map.ts b/packages/client/ui-conversation/src/client/input/editor/span-map.ts new file mode 100644 index 0000000000..fa715eb70e --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/span-map.ts @@ -0,0 +1,119 @@ +/** + * Detect-coordinate span application: the one place that maps a TokenSpan's + * numeric [start, end) back onto Lexical points and applies an edit there. + * Every slash/input-* event (begin-command, insert-reference, insert-text, + * consume-token) lands through here; revision CAS stays with the caller — + * this module only maps and edits. All functions + * must run inside `editor.update()`. + */ +import type { LexicalNode, RangeSelection } from 'lexical' +import { $createRangeSelection, $getRoot, $setSelection } from 'lexical' +import type { ComposerLayout } from './projection.ts' +import { $composerLayout } from './projection.ts' + +/** Half-open [start, end) range in detect coordinates (TokenSpan's plane). */ +export interface DetectSpan { + readonly start: number + readonly end: number +} + +/** One resolved selection point (set() argument triple). */ +interface ResolvedPoint { + readonly key: string + readonly offset: number + readonly type: 'text' | 'element' +} + +/** + * Resolve one detect offset to a selection point. Ownership rule: the first + * segment whose half-open [start, end) contains the offset resolves it; the + * document end falls to the last block. Atomic segments (chip, linebreak) + * resolve to element points beside them, so a span can only ever address a + * chip as a whole; a gap offset is the end of the block before it. + * @param layout - current walk product. + * @param offset - detect offset in [0, detectLength]. + * @returns the point, or null when the offset is out of bounds. + */ +function resolvePoint(layout: ComposerLayout, offset: number): ResolvedPoint | null { + if (offset < 0 || offset > layout.detectLength) return null + for (const segment of layout.segments) { + if (offset >= segment.detectStart + segment.detectLength) continue + if (segment.kind === 'text' && segment.node !== null) { + return { key: segment.node.getKey(), offset: offset - segment.detectStart, type: 'text' } + } + if (segment.kind === 'gap' && segment.gapBetween !== undefined) { + const before = segment.gapBetween.before + return { key: before, offset: layout.children.get(before)?.length ?? 0, type: 'element' } + } + // Atomic leaf (chip / linebreak): the element point on its leading side. + /* v8 ignore next -- non-gap segments always carry their node. */ + if (segment.node === null) return null + const element = segment.node.getParent() + /* v8 ignore next -- a walked leaf always has a parent element. */ + if (element === null) return null + return { key: element.getKey(), offset: segment.node.getIndexWithinParent(), type: 'element' } + } + // offset === detectLength: the end of the last block (or the empty root). + const blocks = $getRoot().getChildren() + const last = blocks[blocks.length - 1] + if (last === undefined) return { key: $getRoot().getKey(), offset: 0, type: 'element' } + return { key: last.getKey(), offset: layout.children.get(last.getKey())?.length ?? 0, type: 'element' } +} + +/** + * Build and apply a live RangeSelection over one detect span. + * @param layout - current walk product. + * @param span - detect span. + * @returns the applied selection, or null when either endpoint fails to map. + */ +function selectSpan(layout: ComposerLayout, span: DetectSpan): RangeSelection | null { + if (span.start < 0 || span.start > span.end || span.end > layout.detectLength) return null + const anchor = resolvePoint(layout, span.start) + const focus = resolvePoint(layout, span.end) + /* v8 ignore next -- bounds were checked above; resolvePoint only fails out of bounds. */ + if (anchor === null || focus === null) return null + const selection = $createRangeSelection() + selection.anchor.set(anchor.key, anchor.offset, anchor.type) + selection.focus.set(focus.key, focus.offset, focus.type) + $setSelection(selection) + return selection +} + +/** + * Select one detect span (collapsed spans place the caret). Exposed for the + * shell's caret placement and tests; the replace helpers below build on it. + * @param span - detect span to select. + * @returns whether both endpoints mapped. + */ +export function $selectDetectSpan(span: DetectSpan): boolean { + return selectSpan($composerLayout(), span) !== null +} + +/** + * Replace one detect span with plain text (empty text deletes the span). + * The caret lands after the insertion. + * @param span - detect span to replace. + * @param text - replacement text. + * @returns whether the span mapped and the edit applied. + */ +export function $replaceDetectSpanWithText(span: DetectSpan, text: string): boolean { + const selection = selectSpan($composerLayout(), span) + if (selection === null) return false + if (text === '' && !selection.isCollapsed()) selection.removeText() + else selection.insertText(text) + return true +} + +/** + * Replace one detect span with nodes (chip insertion path). The caret lands + * after the last inserted node. + * @param span - detect span to replace. + * @param nodes - replacement nodes in order. + * @returns whether the span mapped and the edit applied. + */ +export function $replaceDetectSpanWithNodes(span: DetectSpan, nodes: readonly LexicalNode[]): boolean { + const selection = selectSpan($composerLayout(), span) + if (selection === null) return false + selection.insertNodes([...nodes]) + return true +} diff --git a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts new file mode 100644 index 0000000000..b229948eba --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts @@ -0,0 +1,148 @@ +/** + * Plain-text reference decoration (the plain-text-reference decision; + * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): + * a `/name` or `@name` token whose name is on the trigger's lexicon, and + * syntax-recognizable `@dir/` folder tokens, render in the chip family + * colors. Pure derivation as before — the entity transform converts matching + * text into TextRefNode and back as edits move it in and out of match shape; + * no occurrence identity exists. + */ +import type { EditorConfig, LexicalEditor, NodeKey, SerializedTextNode, Spread } from 'lexical' +import { TextNode } from 'lexical' +import { registerLexicalTextEntity } from '@lexical/text' +import { mergeRegister } from '@lexical/utils' +import { $getRoot } from 'lexical' +import { scanTextRefs } from '../decorations.ts' +import css from './composer-editor.module.css' + +/** JSON form of one text-ref node. */ +export type SerializedTextRefNode = Spread<{ + appearance?: 'folder' +}, SerializedTextNode> + +/** One matched plain-text reference as a styled, fully editable text node. */ +export class TextRefNode extends TextNode { + /** Optional icon domain for syntax-recognizable plain references. */ + __appearance: 'folder' | undefined + + /** Lexical node registry type tag. */ + static override getType(): string { + return 'composer-text-ref' + } + + /** + * Clone with identity (Lexical writable-copy contract). + * @param node - node to clone. + * @returns a copy carrying the same NodeKey. + */ + static override clone(node: TextRefNode): TextRefNode { + return new TextRefNode(node.__text, node.__appearance, node.__key) + } + + /** + * Rebuild one text-ref from its JSON form. + * @param json - serialized node. + * @returns a fresh node. + */ + static override importJSON(json: SerializedTextRefNode): TextRefNode { + const node = new TextRefNode(json.text, json.appearance) + node.setFormat(json.format) + node.setDetail(json.detail) + node.setMode(json.mode) + node.setStyle(json.style) + return node + } + + /** + * @param text - the matched token text. + * @param appearance - optional icon domain (folder tokens). + * @param key - Lexical clone-path key; absent for fresh nodes. + */ + constructor(text: string, appearance?: 'folder', key?: NodeKey) { + super(text, key) + this.__appearance = appearance + } + + /** Serialize to the JSON node form. */ + override exportJSON(): SerializedTextRefNode { + return { + ...super.exportJSON(), + type: 'composer-text-ref', + ...(this.__appearance === undefined ? {} : { appearance: this.__appearance }), + } + } + + /** Style the span the base TextNode mounts. */ + override createDOM(config: EditorConfig): HTMLElement { + const el = super.createDOM(config) + el.classList.add(css.textRef ?? 'textRef') + el.setAttribute('data-composer-text-ref', '') + if (this.__appearance !== undefined) el.setAttribute('data-ref-appearance', this.__appearance) + return el + } + + /** Entity nodes never merge with plain siblings (the transform owns their bounds). */ + override isTextEntity(): true { + return true + } + + /** Editing continues inside; the transform re-evaluates match shape per edit. */ + override canInsertTextBefore(): boolean { + return true + } +} + +/** + * Folder-shape probe for one matched token (the appearance bit). + * @param token - matched token text. + * @returns 'folder' for `@dir/` shapes; undefined otherwise. + */ +function appearanceOf(token: string): 'folder' | undefined { + return token.startsWith('@') && token.endsWith('/') ? 'folder' : undefined +} + +/** + * Register the plain-text reference entity transform. The claim decoration + * has precedence on the leading-token seat: while a command claim holds, the + * claimed token must stay a plain TextNode (transforms register per concrete + * node class, so a TextRefNode would never receive the TextNode claim + * transform and the warn color would be lost). + * @param editor - the shell-owned editor. + * @param lexiconOf - live per-trigger name-roll accessor (the controller's aggregated store). + * @param activeToken - live claim token accessor; null while unclaimed. + * @returns the unregister disposer. + */ +export function registerTextRefDecoration( + editor: LexicalEditor, + lexiconOf: () => ReadonlyMap<'/' | '@', readonly string[]>, + activeToken: () => string | null, +): () => void { + const getMatch = (text: string): { start: number; end: number } | null => { + const claim = activeToken() + for (const range of scanTextRefs(text, lexiconOf())) { + if (claim !== null && range.start === 0 && text.slice(range.start, range.end) === claim) continue + return { start: range.start, end: range.end } + } + return null + } + return mergeRegister( + ...registerLexicalTextEntity( + editor, + getMatch, + TextRefNode, + node => new TextRefNode(node.getTextContent(), appearanceOf(node.getTextContent())), + ), + ) +} + +/** + * Force a re-scan of the whole document (transforms only visit dirty nodes; + * a lexicon roll change dirties nothing on its own). Queued, not discrete — + * the caller may sit inside an update listener. + * @param editor - the shell-owned editor. + */ +export function rescanTextRefs(editor: LexicalEditor): void { + editor.update(() => { + for (const node of $getRoot().getAllTextNodes()) node.markDirty() + }) +} diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 6d48be9371..0b588c4fd6 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -1,23 +1,39 @@ /** - * SessionInput shell over the pure input machine: the sole machine caller - * and effect executor. Owns the InputState store (machine state + the queue - * overlay), the notice channel, and the submit transaction plumbing + * SessionInput shell: owns the per-session Lexical editor (text + chip + * truth) and the pure SubmitMachine (phase/claim/attempt), and choreographs + * everything between them — projections and InputState publication, the + * scoped-event application verbs, the submit transaction plumbing * (adjudicate via the session's InputTriggerController; claim.submit; default - * sink). Package-private; the hub alone constructs it and wires the scoped - * event listeners onto it. + * sink), the notice channel, and the draft persistence mirror. + * Package-private; the hub alone constructs it and wires the scoped event + * listeners onto it. */ import type { Context } from '@deepseek-ai/cordis' import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' +import type { LexicalEditor, NodeKey } from 'lexical' +import { + $addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, + CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG, PASTE_TAG, +} from 'lexical' +import { registerPlainText } from '@lexical/plain-text' +import { createEmptyHistoryState, registerHistory } from '@lexical/history' +import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, - EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, - InputTriggerController, PasteComponent, PickOutcome, QueuedMessage, ReferenceInsert, - SessionInput, SubmitAttempt, SubmitImageAttachment, SubmitOutcome, TokenSpan, + InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome, + QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, + SubmitOutcome, TokenSpan, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' -import { InputMachine, projectClipboard } from './machine.ts' +import { SubmitMachine } from './machine.ts' +import { ReferenceChipNode, $createReferenceChipNode } from './editor/chip-node.tsx' +import { refreshClaimDecoration, registerClaimDecoration } from './editor/claim-decor.ts' +import { registerTextRefDecoration, rescanTextRefs, TextRefNode } from './editor/text-ref.ts' +import type { EditorProjection } from './editor/projection.ts' +import { $composerLayout, $projectComposer, detectOffsetOfClipboardOffset } from './editor/projection.ts' +import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText } from './editor/span-map.ts' /** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */ export interface PopupDismissFace { @@ -71,20 +87,44 @@ function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' { } } +/** Whether two projections differ in content (selection and caret excluded). */ +function projectionContentChanged(prev: EditorProjection, next: EditorProjection): boolean { + if (prev.clipboardText !== next.clipboardText || prev.detectText !== next.detectText) return true + if (prev.occurrences.length !== next.occurrences.length) return true + return next.occurrences.some((occ, i) => { + const old = prev.occurrences[i] + return old === undefined || old.occurrenceId !== occ.occurrenceId || old.invalid !== occ.invalid + }) +} + const EMPTY_QUEUE: readonly QueuedMessage[] = [] /** No-pipeline lexicon: zero text-ref decorations. */ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() +/** + * Detect-projection and legacy reference placeholders stripped from every + * external text entering the document (paste, persisted-draft seed): a chip + * is the only legitimate source of U+FFFC in the detect projection, so a + * literal one in text would forge chip positions. + */ +const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu + +/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */ +const HISTORY_MERGE_DELAY_MS = 1000 + /** * The per-session input facade: scoped-event application verbs + - * setDraft/submit + the published InputState store. + * setDraft/submit + the published InputState store, over a shell-owned + * Lexical editor. */ export class SessionInputShell implements SessionInput { - /** Published machine state + queue overlay (the InputZone currency source). */ + /** Published editor projection + submit-plane state + queue overlay (the InputZone currency source). */ readonly state: SnapshotStore /** Latest surfaced notice (null after clear); the bar renders errors as banners and information inline. */ readonly notices: SnapshotStore = createSnapshotStore(null) + /** The shell-owned editor (text + chip truth); the composer binds its contenteditable to it. */ + readonly editor: LexicalEditor /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, @@ -94,33 +134,129 @@ export class SessionInputShell implements SessionInput { submit: () => { this.submit('queue') }, } - // Real wall clock: the typing-run merge window must actually expire in - // production (the machine's no-clock default is a constant for pure tests). - private readonly core = new InputMachine({ now: () => Date.now() }) + private readonly core = new SubmitMachine() + private projection: EditorProjection = { detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null } + private rev = 0 + /** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */ + private readonly occurrenceIds = new Map() + private occurrenceSeq = 0 + private readonly unregister: () => void private noticeSeq = 0 private lastMirroredDraft = '' private imageIds: readonly DraftAttachmentId[] = [] /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ private imageSendInFlight = false private disposed = false - /** Draft persistence mirror (Conversation store write; receives the clipboard projection, never display-only ranges). */ + /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */ private mirrorFn: ((text: string) => void) | undefined + /** Live lexicon subscription disposer; undefined until the controller resolves. */ + private lexiconOff: (() => void) | undefined constructor(private readonly deps: SessionInputDeps) { + this.editor = createEditor({ + namespace: 'dsh-composer', + nodes: [ReferenceChipNode, TextRefNode], + onError: (error) => { throw error }, + }) + this.unregister = mergeRegister( + registerPlainText(this.editor), + registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS), + this.editor.registerUpdateListener(() => { this.onEditorUpdate() }), + registerClaimDecoration(this.editor, () => this.activeClaimToken()), + registerTextRefDecoration(this.editor, () => this.lexicon.getSnapshot(), () => this.activeClaimToken()), + () => { this.lexiconOff?.() }, + ) this.state = createSnapshotStore(this.compose()) deps.queue?.subscribe(() => { this.publish() }) } + // ---- editor plumbing ---- + + /** + * Run one editor edit whose result is observable on return. At the top + * level this is a discrete update. Inside this editor's own update — + * command handlers land here synchronously (space/enter picks, paste) — + * $-functions are already legal, and wrapping them in update() would DEFER + * them past the synchronous bail answer (and a nested discrete throws); + * the body runs directly and the outer update commits it. + * @param fn - the $-edit body. + */ + private applyEdit(fn: () => void, tag?: string): void { + if (this.editor._updating) { + // Nested application joins the enclosing update (the PASTE_COMMAND + // dispatch path always lands here), so the tag attaches to that update. + if (tag !== undefined) $addUpdateTag(tag) + fn() + return + } + this.editor.update(fn, { discrete: true, ...(tag === undefined ? {} : { tag }) }) + } + + + /** + * Subscribe the text-ref re-scan to the controller's lexicon once the + * controller resolves. The deps thunk cannot resolve at construction (the + * shell is created inside the sessions provide materialization), so the + * first interactive updates retry until it can. + */ + private ensureLexiconSubscription(): void { + if (this.lexiconOff !== undefined) return + const controller = this.deps.inputTriggers?.() + if (controller === undefined) return + this.lexiconOff = controller.lexicon.subscribe(() => { rescanTextRefs(this.editor) }) + } + + /** Re-project, run the claim watch, publish, and feed trigger tracking after every editor commit. */ + private onEditorUpdate(): void { + this.ensureLexiconSubscription() + const prev = this.projection + this.projection = this.editor.getEditorState().read(() => + $projectComposer(key => this.occurrenceIdOf(key))) + // Selection-only commits advance neither the revision nor the published + // state: menus still track the caret below, while draftRev moves only + // with content so a snapshot-built span (apply.ts) stays CAS-valid across + // caret motion and subscribers do not re-render per caret move. + if (projectionContentChanged(prev, this.projection)) { + this.rev += 1 + this.dispatchRun(({ type: 'draft-changed', draft: this.projection.clipboardText })) + } + const caret = this.projection.caret + if (caret !== null) { + this.deps.inputTriggers?.()?.track( + this.projection.detectText, caret, { tier: guardOf(this.core.state.phase) }, this.rev, + ) + } + } + + private occurrenceIdOf(key: NodeKey): number { + const existing = this.occurrenceIds.get(key) + if (existing !== undefined) return existing + this.occurrenceSeq += 1 + this.occurrenceIds.set(key, this.occurrenceSeq) + return this.occurrenceSeq + } + // ---- SessionInput face ---- /** - * Single draft write path (all mutation rides machine events). + * Replace the whole draft (persisted-draft seed and programmatic writes). + * Placeholder-sanitized; newlines split paragraphs; the caret lands at the + * end. Merged into history so a seed is not an undoable step of its own. * @param text - the full next draft. - * @param editRange - the DOM-observed edit shape, when the caller knows it - * (narrows the machine's occurrence math; absent → diff scan). */ - setDraft(text: string, editRange?: EditRange): void { - this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) })) + setDraft(text: string): void { + const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '') + if (clean === this.projection.clipboardText) return + this.editor.update(() => { + const root = $getRoot() + root.clear() + for (const line of clean.split('\n')) { + const paragraph = $createParagraphNode() + if (line !== '') paragraph.append($createTextNode(line)) + root.append(paragraph) + } + root.selectEnd() + }, { discrete: true, tag: HISTORY_MERGE_TAG }) } /** Append ordered image ids unless an admission transaction is locked. */ @@ -158,46 +294,39 @@ export class SessionInputShell implements SessionInput { } /** - * Clear the draft as a successful-send commit: no undo unit is recorded and - * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content - * (the command path gets the same discipline from submit-settled success). + * Clear the draft as a successful-send commit: the editor empties (no undo + * unit) and the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent + * content (the command path gets the same discipline from submit-settled). * @param imageIds - admitted image ids to remove from this draft. */ commitSend(imageIds: readonly DraftAttachmentId[]): void { const submitted = new Set(imageIds) this.imageIds = this.imageIds.filter(id => !submitted.has(id)) - this.run(this.core.dispatch({ type: 'send-committed' })) - } - - /** Undo the latest transaction (InputBar intercepts the platform chord). */ - undo(): void { - this.run(this.core.dispatch({ type: 'undo' })) - } - - /** Redo the latest undone transaction. */ - redo(): void { - this.run(this.core.dispatch({ type: 'redo' })) + this.dispatchRun(({ type: 'send-committed' })) } /** - * Paste text over the selection in one transaction, with any hot-snapshot - * sync matches componentized inside it. + * Insert pasted plain text over the current editor selection + * (placeholder-sanitized). The paste event's own default is suppressed by + * the caller; PASTE_TAG makes the paste its own history boundary, so one + * undo never removes both the paste and typing inside the merge window. * @param text - pasted plain text. - * @param selection - replaced selection in draft coordinates. - * @param components - sync-matched reference components (disjoint, inside `text`). - * @param generation - projection generation for late async-upgrade guards. */ - pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void { - this.run(this.core.dispatch({ - type: 'paste-begin', text, selection, - ...(components !== undefined ? { components } : {}), - ...(generation !== undefined ? { generation } : {}), - })) - } - - /** End the live paste-match attempt (caret/selection ops and Slash updates the machine cannot see). */ - invalidatePaste(): void { - this.run(this.core.dispatch({ type: 'invalidate-paste' })) + paste(text: string): void { + const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '') + if (clean === '') return + this.applyEdit(() => { + const selection = $getSelection() + if ($isRangeSelection(selection)) { + selection.insertText(clean) + return + } + // No selection yet (never-focused surface): land at the document end, + // growing the first paragraph when the tree is empty. + const root = $getRoot() + if (root.getChildrenSize() === 0) root.append($createParagraphNode()) + root.selectEnd().insertText(clean) + }, PASTE_TAG) } /** @@ -232,24 +361,14 @@ export class SessionInputShell implements SessionInput { this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft)) return } - this.run(this.core.dispatch({ type: 'enter', mode })) + this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText })) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { this.deps.popup?.()?.dismiss() - this.deps.inputTriggers?.()?.track(this.snapshot.draft, 0, { tier: 'frozen' }, this.snapshot.draftRev) + this.deps.inputTriggers?.()?.track(this.projection.detectText, 0, { tier: 'frozen' }, this.rev) } } - /** - * Feed a draft/caret change through trigger detection (guard derived from - * the machine phase). - * @param draft - live draft text. - * @param caret - caret position in draft coordinates. - */ - track(draft: string, caret: number): void { - this.deps.inputTriggers?.()?.track(draft, caret, { tier: guardOf(this.snapshot.phase) }, this.snapshot.draftRev) - } - /** * Keyboard arbitration while the menu is open. * @param key - the intercepted key. @@ -277,15 +396,9 @@ export class SessionInputShell implements SessionInput { space(): boolean { const inputTriggers = this.deps.inputTriggers?.() if (inputTriggers === undefined) return false - const consumed = inputTriggers.onSpace() - // Machine-driven draft replacement never passes through onChange, so - // re-track: the caret lands after the token, where detection sees - // whitespace and closes the menu. - if (consumed) { - const next = this.snapshot - inputTriggers.track(next.draft, next.draft.length, { tier: guardOf(next.phase) }, next.draftRev) - } - return consumed + return inputTriggers.onSpace() + // No re-track here: applying the claim/insert mutates the editor, and the + // update listener re-tracks at the settled caret on its own. } /** Dismiss the popupSelect shell (any interaction outside the box). */ @@ -294,9 +407,19 @@ export class SessionInputShell implements SessionInput { } /** - * Hot plain-text reference lexicon source for the decoration scan - * (the plain-text-reference decision; - * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): + * The live selection as a detect-coordinate span (menu-launcher synthetic + * hits replace it on pick); an absent selection answers a collapsed span at + * the document end. + * @returns the ordered [start, end) span in detect coordinates. + */ + caretSpan(): { start: number; end: number } { + if (this.projection.selection !== null) return this.projection.selection + const at = this.projection.detectText.length + return { start: at, end: at } + } + + /** + * Hot plain-text reference lexicon source for the decoration scan: * delegates to the controller's aggregated store. Stable * identity per shell; without a pipeline the snapshot is the empty Map and * subscribers never fire. @@ -306,28 +429,53 @@ export class SessionInputShell implements SessionInput { subscribe: fn => this.deps.inputTriggers?.()?.lexicon.subscribe(fn) ?? (() => {}), } + // ---- scoped-event application verbs ---- + /** - * Apply one command claim (scoped begin-command event listener body). + * Apply one command claim (scoped begin-command event listener body): the + * editor replaces [0, span.end) with the claim token, then the machine + * enters claimed. * @param claim - the command claim from the pick path. - * @param span - pick-time span snapshot. - * @returns whether the machine accepted (phase + span CAS passed and the draft mutated). + * @param span - pick-time span snapshot (detect coordinates). + * @returns whether the edit applied (phase, span CAS, and leading guard passed). */ beginCommand(claim: CommandClaim, span: TokenSpan): boolean { - const before = this.core.state.draftRev - this.run(this.core.dispatch({ type: 'begin-command', claim, span })) - return this.core.state.phase === 'claimed' && this.core.state.draftRev !== before + const phase = this.core.state.phase + if (phase !== 'plain' && phase !== 'claimed') return false + if (span.draftRev !== this.rev) return false + // Leading-trigger contract: only whitespace may precede the span; the + // whitespace prefix is dropped so the claimed watch (startsWith) holds. + if (this.projection.detectText.slice(0, span.start).trim() !== '') return false + let applied = false as boolean + this.applyEdit(() => { + applied = $replaceDetectSpanWithText({ start: 0, end: span.end }, claim.token) + }) + if (!applied) return false + this.dispatchRun(({ type: 'claim', claim })) + return true } /** - * Apply one reference insertion (scoped insert-reference event listener body). + * Apply one reference insertion (scoped insert-reference event listener + * body): the editor replaces the span with one chip node, followed by a + * separating space unless one is already next. * @param ref - the reference insertion from the pick path. - * @param span - pick-time span snapshot. - * @returns whether the machine accepted. + * @param span - pick-time span snapshot (detect coordinates). + * @returns whether the edit applied. */ insertReference(ref: ReferenceInsert, span: TokenSpan): boolean { - const before = this.core.state.draftRev - this.run(this.core.dispatch({ type: 'insert-ref', reference: ref, span })) - return this.core.state.draftRev !== before + const phase = this.core.state.phase + if (phase !== 'plain' && phase !== 'claimed') return false + if (span.draftRev !== this.rev) return false + const tail = this.projection.detectText.slice(span.end, span.end + 1) + let applied = false + this.applyEdit(() => { + const nodes = tail === ' ' + ? [$createReferenceChipNode(ref)] + : [$createReferenceChipNode(ref), $createTextNode(' ')] + applied = $replaceDetectSpanWithNodes(span, nodes) + }) + return applied } /** @@ -338,43 +486,40 @@ export class SessionInputShell implements SessionInput { * @returns whether the token was consumed. */ consumeToken(guard: ConsumeTokenRequest['guard']): boolean { - const snapshot = this.core.state if (guard.kind === 'span') { - if (guard.span.draftRev !== snapshot.draftRev) return false - const draft = snapshot.draft - this.setDraft(draft.slice(0, guard.span.start) + draft.slice(guard.span.end)) - return true + if (guard.span.draftRev !== this.rev || guard.span.start === guard.span.end) return false + let applied = false + this.applyEdit(() => { + applied = $replaceDetectSpanWithText(guard.span, '') + }) + return applied } - if (snapshot.draft.trim() !== guard.token) return false + if (guard.token === '' || this.projection.clipboardText.trim() !== guard.token) return false this.setDraft('') return true } /** * Insert plain reference text over the pick-time span (scoped insert-text - * event listener body; plain-text-reference decision, web-input-machine - * note). Same CAS-then-splice shape as the - * consume-token span branch: the machine sees an ordinary draft-changed - * transaction (one undo step), no occurrence is minted — the chip look is - * a scan-derived decoration, never state. + * event listener body; the plain-text reference path). The editor gains + * ordinary characters — no chip node; the chip look is a scan-derived + * decoration, never state. * @param text - the plain reference text to splice in (e.g. `/name `). - * @param span - pick-time span snapshot (draftRev CAS). - * @param keepCompleting - re-track at the caret after the splice so an open - * token (a directory pick's trailing slash) reopens the menu. + * @param span - pick-time span snapshot (detect coordinates). + * @param keepCompleting - contract passenger; completion re-opening is + * automatic here (the update listener re-tracks at the settled caret, so an + * open token — a directory pick's trailing slash — reopens the menu without + * an explicit re-track). * @returns whether the text was applied. */ insertText(text: string, span: TokenSpan, keepCompleting = false): boolean { - const snapshot = this.core.state - if (span.draftRev !== snapshot.draftRev) return false - const draft = snapshot.draft - this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end)) - if (keepCompleting) { - // Machine-driven draft replacement never passes through onChange, so - // re-track at the caret inside the still-open token (see space()). - const next = this.snapshot - this.deps.inputTriggers?.()?.track(next.draft, span.start + text.length, { tier: guardOf(next.phase) }, next.draftRev) - } - return true + void keepCompleting + if (span.draftRev !== this.rev) return false + let applied = false + this.applyEdit(() => { + applied = $replaceDetectSpanWithText(span, text) + }) + return applied } /** @@ -389,13 +534,15 @@ export class SessionInputShell implements SessionInput { // ---- wiring-layer extras (not on the frozen SessionInput face) ---- - /** Teardown: abort any in-flight attempt and stop accepting async settlements. */ + /** Teardown: abort any in-flight attempt, unbind the editor, and stop accepting async settlements. */ dispose(): void { this.disposed = true - this.run(this.core.dispatch({ type: 'release' })) + this.dispatchRun(({ type: 'release' })) + this.unregister() + this.editor.setRootElement(null) } - /** Read the live machine state (guard derivation reads here). */ + /** Read the live input state (guard derivation reads here). */ get snapshot(): InputState { return this.state.getSnapshot() } @@ -403,7 +550,7 @@ export class SessionInputShell implements SessionInput { /** * Bind the draft persistence mirror (Conversation store write). Adopt-on-bind: the * store draft may hold a persisted value from a previous mount; the caller - * seeds it via setDraft BEFORE binding, and afterwards every machine-adopted + * seeds it via setDraft BEFORE binding, and afterwards every editor-adopted * draft mirrors out. * @param write - store draft write. * @returns the unbind disposer. @@ -417,6 +564,21 @@ export class SessionInputShell implements SessionInput { // ---- effect executor ---- + /** The claim token the decoration transform styles; null while unclaimed. */ + private activeClaimToken(): string | null { + const core = this.core.state + return (core.phase === 'claimed' || core.phase === 'submitting') && core.claim !== undefined + ? core.claim.token + : null + } + + /** Dispatch + execute, refreshing the claim decoration when the styled token flips. */ + private dispatchRun(ev: Parameters[0]): void { + const beforeToken = this.activeClaimToken() + this.run(this.core.dispatch(ev)) + if (this.activeClaimToken() !== beforeToken) refreshClaimDecoration(this.editor) + } + private run(effects: readonly InputEffect[]): void { for (const fx of effects) this.execute(fx) this.publish() @@ -441,21 +603,45 @@ export class SessionInputShell implements SessionInput { this.sinkSerialized(fx.attempt, fx.draft, fx.mode) return } - default: - return // machine-internal effects (mirror rides publish) + case 'commit-draft': { + this.commitDraft(fx.retainSuffixOf) + return + } } } /** - * Prompt serialization before the sink: expand each - * inline reference range to its owner's model form via the session controller's - * codec routing. Owner missing / serialize failure / disposal blocks the - * send — notice + draft and chips retained, never a silent downgrade to - * the clipboard text. Chip-free drafts skip the async detour. + * Execute the commit-draft effect: clear the committed content (retaining + * a pure typed-during-flight suffix when the snapshot allows) and cut the + * undo history so sent content cannot resurrect. + */ + private commitDraft(retainSuffixOf: string | null): void { + this.editor.update(() => { + const layout = $composerLayout() + const clip = layout.clipboardText + if (retainSuffixOf !== null && clip !== retainSuffixOf && clip.startsWith(retainSuffixOf)) { + $replaceDetectSpanWithText( + { start: 0, end: detectOffsetOfClipboardOffset(layout, retainSuffixOf.length) }, '', + ) + return + } + const root = $getRoot() + root.clear() + root.selectEnd() + }, { discrete: true, tag: HISTORY_MERGE_TAG }) + this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined) + } + + /** + * Prompt serialization before the sink: expand each chip occurrence to its + * owner's model form via the session controller's codec routing. Owner + * missing / serialize failure / disposal blocks the send — notice + draft + * and chips retained, never a silent downgrade to the clipboard text. + * Chip-free drafts skip the async detour. */ private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void { const imageIds = [...this.imageIds] - const occurrences = this.core.state.occurrences + const occurrences = this.projection.occurrences if (occurrences.length === 0) { this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) return @@ -472,8 +658,9 @@ export class SessionInputShell implements SessionInput { })).then( (parts) => { if (this.disposed) return - // Splice model forms over their display ranges (offsets are draft-time; - // parts arrive offset-sorted since the table is). + // Splice model forms over their clipboard ranges (offsets are + // clipboard-projection; parts arrive offset-sorted since chips walk in + // document order). let out = '' let cursor = 0 for (const part of parts) { @@ -487,7 +674,9 @@ export class SessionInputShell implements SessionInput { controller.abort() if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) + this.dispatchRun(({ + type: 'submit-settled', attempt, ok: false, draft: this.projection.clipboardText, message, + })) }, ) } @@ -505,19 +694,21 @@ export class SessionInputShell implements SessionInput { const submitted = new Set(imageIds) this.imageIds = this.imageIds.filter(id => !submitted.has(id)) } - this.run(this.core.dispatch({ + this.dispatchRun(({ type: 'submit-settled', attempt, ok: outcome.kind === 'success', + draft: this.projection.clipboardText, outcome, })) }, (error: unknown) => { if (this.dead(attempt)) return - this.run(this.core.dispatch({ + this.dispatchRun(({ type: 'submit-settled', attempt, ok: false, + draft: this.projection.clipboardText, message: error instanceof Error ? error.message : String(error), })) }, @@ -529,18 +720,18 @@ export class SessionInputShell implements SessionInput { const inputTriggers = this.deps.inputTriggers?.() if (inputTriggers === undefined) { // No pipeline mounted: the '/' line is an ordinary message. - this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) + this.dispatchRun(({ type: 'adjudicated', attempt, outcome: undefined })) return } inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then( (outcome: PickOutcome) => { if (this.dead(attempt)) return - this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome })) + this.dispatchRun(({ type: 'adjudicated', attempt, outcome })) }, (error: unknown) => { if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.run(this.core.dispatch({ type: 'adjudication-failed', attempt, message })) + this.dispatchRun(({ type: 'adjudication-failed', attempt, message })) }, ) } @@ -570,15 +761,19 @@ export class SessionInputShell implements SessionInput { this.imageIds = this.imageIds.filter(id => !submitted.has(id)) this.deps.commandImages.release(imageIds) } - this.run(this.core.dispatch({ - type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome, + this.dispatchRun(({ + type: 'submit-settled', attempt, ok: outcome.kind === 'success', + draft: this.projection.clipboardText, outcome, ...(outcome.kind === 'error' && outcome.text === undefined ? { message: 'command failed' } : {}), })) }, (error: unknown) => { if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) + this.dispatchRun(({ + type: 'submit-settled', attempt, ok: false, + draft: this.projection.clipboardText, message, + })) }, ) } @@ -590,16 +785,23 @@ export class SessionInputShell implements SessionInput { private compose(): InputState { const core = this.core.state - return { ...core, imageIds: this.imageIds, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE } + return { + draft: this.projection.clipboardText, + imageIds: this.imageIds, + draftRev: this.rev, + phase: core.phase, + ...(core.claim !== undefined ? { claim: core.claim } : {}), + occurrences: this.projection.occurrences, + queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE, + } } private publish(): void { const next = this.compose() this.state.set(next) - const mirroredDraft = projectClipboard(next) - if (mirroredDraft !== this.lastMirroredDraft) { - this.lastMirroredDraft = mirroredDraft - this.mirrorFn?.(mirroredDraft) + if (next.draft !== this.lastMirroredDraft) { + this.lastMirroredDraft = next.draft + this.mirrorFn?.(next.draft) } } } diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 75ae348d8f..5f097c107a 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -1,47 +1,18 @@ /** - * InputMachine: the pure per-session input state machine. - * Events in, effects out; zero React / DOM / cordis / ambient - * clock. Package-private — the SessionInput shell is the only caller and the - * sole executor of the returned effects. + * SubmitMachine: the pure per-session submit-plane state machine. + * Events in, effects out; zero React / DOM / cordis. Package-private — the + * SessionInput shell is the only caller and the sole executor of the + * returned effects. * - * Draft truth: the draft string holds each reference's complete inline display - * text; the occurrence table carries identity, range, and the owner's cached projections. Every - * draft mutation is one transaction — draft edit, occurrence reconciliation, - * and undo-log push are atomic inside dispatch() — and bumps draftRev, which - * is what lets span CAS reduce to a revision-equality check: equal rev ⟹ - * identical draft ⟹ identical span content. Callers observe mutation success - * as a draftRev advance (begin-command / insert-ref / consume-token / - * paste-upgrade all answer their bail events this way). + * The machine owns phase, claim, and the in-flight SubmitAttempt; it never + * holds the draft. Text truth lives in the shell's Lexical editor, and every + * decision that needs the draft reads it from the event payload (claim + * integrity watch, enter snapshots, settlement suffix/re-entry decisions). */ import type { InputSubmitMode } from '../contract/composer-submission.ts' -import type { - CommandClaim, ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, - InputMachineOptions, InputState, Occurrence, PasteAttemptState, PasteComponent, - ReferenceInsert, SubmitAttempt, TokenSpan, -} from '../contract/input.ts' +import type { CommandClaim, InputEffect, InputEvent, InputState, SubmitAttempt } from '../contract/input.ts' -/** Legacy fixed-width object replacement character rejected from pasted text. */ -export const PLACEHOLDER = '' - -const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu - -/** - * Build the inline draft text whose leading marker is decorated as the - * reference icon in the backdrop. - * @param reference - reference insertion with its cached display projection. - * @returns display text with one marker glyph followed by the complete label. - */ -export function referenceDraftText(reference: Pick): string { - return `@${reference.label}` -} - -/** The machine never writes the queue; the wiring layer overlays the queue store's projection. */ -const EMPTY_QUEUE: InputState['queue'] = [] - -/** Undo ring depth (bounded self-managed transaction log). */ -const LOG_LIMIT = 100 - -/** Exhaustiveness backstop for the closed InputEvent / guard unions. */ +/** Exhaustiveness backstop for the closed InputEvent union. */ function unreachable(value: never): never { throw new Error(`unreachable input event: ${JSON.stringify(value)}`) } @@ -64,88 +35,33 @@ function argsAfter(draft: string, token: string): string { return '' } -/** - * Prefix/suffix common-scan recovering the edit range between two drafts - * (used when the wiring layer cannot supply one from the DOM event). - */ -function diffEdit(prev: string, next: string): EditRange { - let p = 0 - const maxCommon = Math.min(prev.length, next.length) - while (p < maxCommon && prev[p] === next[p]) p += 1 - let s = 0 - const maxSuffix = maxCommon - p - while (s < maxSuffix && prev[prev.length - 1 - s] === next[next.length - 1 - s]) s += 1 - return { start: p, end: prev.length - s, insertedLength: next.length - s - p } +/** The submit-plane slice of the published InputState. */ +export interface SubmitSnapshot { + readonly phase: InputState['phase'] + readonly claim?: InputState['claim'] } /** - * Expand the draft's reference ranges into their occurrences' clipboard text - * for persistence and clipboard projection. Table order is offset order, so - * one linear walk pairs ranges with entries. - * @param state - published input state. - * @returns the plain-text projection of the draft. - */ -export function projectClipboard(state: Pick): string { - const { draft, occurrences } = state - if (occurrences.length === 0) return draft - let out = '' - let cursor = 0 - for (const o of occurrences) { - out += draft.slice(cursor, o.offset) + o.clipboardText - cursor = o.offset + o.length - } - return out + draft.slice(cursor) -} - -/** One undo unit: snapshots taken before the transaction applied. */ -interface Transaction { - readonly draftBefore: string - readonly occurrencesBefore: readonly Occurrence[] - /** Pre-edit selection when the triggering event carried one (shell caret restore on undo). */ - readonly selectionBefore?: EditSelection -} - -/** - * Pure input machine, one instance per session (per-session isolation is by + * Pure submit machine, one instance per session (per-session isolation is by * construction). The machine constructs one AbortController per SubmitAttempt * at enter time and aborts it itself on release; the shell never aborts, it * only observes attempt.signal on its adjudicate/submit promises. Stale * attempts (any adjudicated / adjudication-failed / submit-settled whose seq * is not the in-flight one) are dropped: same state, zero effects. */ -export class InputMachine { - private draft = '' - private draftRev = 0 +export class SubmitMachine { private phase: InputState['phase'] = 'plain' private claim: CommandClaim | undefined - private occurrences: readonly Occurrence[] = [] - private occurrenceSeq = 0 private seq = 0 private inflight: { readonly attempt: SubmitAttempt readonly controller: AbortController } | undefined - private log: Transaction[] = [] - private redoStack: Transaction[] = [] - /** Open single-char typing run: the next contiguous char within the window coalesces. */ - private typingRun: { readonly end: number; readonly at: number } | undefined - private paste: PasteAttemptState | undefined - private pasteSeq = 0 - private readonly mergeWindowMs: number - private readonly now: () => number - constructor(options: InputMachineOptions = {}) { - this.mergeWindowMs = options.mergeWindowMs ?? 1000 - this.now = options.now ?? (() => 0) - } - - /** Read-only snapshot of the machine state (queue always empty at this tier). */ - get state(): InputState { + /** Read-only snapshot of the submit-plane state. */ + get state(): SubmitSnapshot { const c = this.claim return { - draft: this.draft, - imageIds: [], - draftRev: this.draftRev, phase: this.phase, ...(c ? { @@ -156,33 +72,19 @@ export class InputMachine { }, } : {}), - occurrences: this.occurrences, - ...(this.paste !== undefined ? { paste: this.paste } : {}), - queue: EMPTY_QUEUE, } } /** * Feed one event through the machine. - * @param ev - Input event; the single write path for all input state. + * @param ev - Input event; the single write path for all submit-plane state. * @returns Effects for the shell to execute in order; empty on no-ops, locks, and dropped stale events. */ dispatch(ev: InputEvent): readonly InputEffect[] { switch (ev.type) { - case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange) - case 'begin-command': return this.onBeginCommand(ev.claim, ev.span) - case 'insert-ref': return this.onInsertRef(ev.reference, ev.span) - case 'consume-token': return this.onConsumeToken(ev.guard) - case 'set-invalid': return this.onSetInvalid(ev.invalidIds) - case 'undo': return this.onUndo() - case 'redo': return this.onRedo() - case 'paste-begin': return this.onPasteBegin(ev.text, ev.selection, ev.components, ev.generation) - case 'paste-upgrade': return this.onPasteUpgrade(ev.attemptId, ev.span, ev.reference) - case 'invalidate-paste': { - this.paste = undefined - return [] - } - case 'enter': return this.onEnter(ev.mode) + case 'draft-changed': return this.onDraftChanged(ev.draft) + case 'claim': return this.onClaim(ev.claim) + case 'enter': return this.onEnter(ev.mode, ev.draft) case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) @@ -192,313 +94,51 @@ export class InputMachine { } } - // ---- transaction plumbing ---- - - /** Adopt a new draft: bump the revision (the span-CAS invalidation point). */ - private adopt(draft: string): void { - this.draft = draft - this.draftRev += 1 - } - - /** Push one undo unit (before-state), trim the ring, and cut the redo chain. */ - private pushTxn(selectionBefore?: EditSelection): void { - this.log.push({ - draftBefore: this.draft, - occurrencesBefore: this.occurrences, - ...(selectionBefore !== undefined ? { selectionBefore } : {}), - }) - if (this.log.length > LOG_LIMIT) this.log.shift() - this.redoStack = [] - } - - /** - * Reconcile the occurrence table with one edit (old-draft coordinates): - * entries past the range shift by the length delta; an edit that intersects - * a reference range removes its structured occurrence and leaves the edited - * characters as ordinary draft text. - */ - private reconcile(range: EditRange): void { - const delta = range.insertedLength - (range.end - range.start) - const kept: Occurrence[] = [] - for (const o of this.occurrences) { - if (o.offset + o.length <= range.start) kept.push(o) - else if (o.offset >= range.end) kept.push(delta === 0 ? o : { ...o, offset: o.offset + delta }) - } - this.occurrences = kept - } - - /** Claimed integrity watch: any mutation that breaks the token prefix releases the claim. */ - private watchClaim(): void { - if (this.phase === 'claimed' && this.claim !== undefined && !this.draft.startsWith(this.claim.token)) { + /** Claimed integrity watch: any draft that breaks the token prefix releases the claim. */ + private onDraftChanged(draft: string): InputEffect[] { + if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) { this.phase = 'plain' this.claim = undefined } - } - - /** Mint one occurrence at a draft offset. */ - private mint(reference: ReferenceInsert, offset: number, length: number): Occurrence { - this.occurrenceSeq += 1 - return { - occurrenceId: this.occurrenceSeq, - source: reference.source, - ref: reference.ref, - offset, - length, - label: reference.label, - ...reference.appearance === undefined ? {} : { appearance: reference.appearance }, - clipboardText: reference.clipboardText, - } - } - - /** Splice minted entries into the offset-sorted table. */ - private withMinted(minted: readonly Occurrence[]): void { - if (minted.length === 0) return - this.occurrences = [...this.occurrences, ...minted].sort((a, b) => a.offset - b.offset) - } - - // ---- draft transactions ---- - - private onDraftChanged(draft: string, editRange?: EditRange): InputEffect[] { - if (draft === this.draft) return [] - const range = editRange ?? diffEdit(this.draft, draft) - // Single-char typing coalesces into the open run while contiguous and - // inside the merge window; anything else opens its own transaction. - const typing = range.start === range.end && range.insertedLength === 1 - const at = this.now() - const run = this.typingRun - const merges = typing && run !== undefined && run.end === range.start && at - run.at <= this.mergeWindowMs - if (!merges) this.pushTxn({ start: range.start, end: range.end }) - this.typingRun = typing ? { end: range.start + 1, at } : undefined - this.reconcile(range) - this.adopt(draft) - this.watchClaim() - this.paste = undefined return [] } - /** Span CAS: revision equality (content identity follows) plus bounds sanity. */ - private casOk(span: TokenSpan): boolean { - return span.draftRev === this.draftRev - && span.start >= 0 && span.start <= span.end && span.end <= this.draft.length - } - - private onBeginCommand(claim: CommandClaim, span: TokenSpan): InputEffect[] { + /** The editor applied a claim-token replacement: enter claimed (busy phases refuse). */ + private onClaim(claim: CommandClaim): InputEffect[] { if (this.phase !== 'plain' && this.phase !== 'claimed') return [] - // Leading-trigger contract: only whitespace may precede the span; the - // whitespace prefix is dropped so the claimed watch (startsWith) holds. - if (!this.casOk(span) || this.draft.slice(0, span.start).trim() !== '') return [] - this.pushTxn() - this.typingRun = undefined - this.reconcile({ start: 0, end: span.end, insertedLength: claim.token.length }) - this.adopt(claim.token + this.draft.slice(span.end)) this.claim = claim this.phase = 'claimed' - this.paste = undefined - return [] - } - - private onInsertRef(reference: ReferenceInsert, span: TokenSpan): InputEffect[] { - if (this.phase !== 'plain' && this.phase !== 'claimed') return [] - if (!this.casOk(span)) return [] - this.replaceSpanWithChip(reference, span) - this.paste = undefined - return [] - } - - /** - * Shared reference-insertion transaction: replace [span) with one inline - * occurrence (insert-ref and paste-upgrade both land here). A separating - * space follows the reference unless one is already next. - * @returns the inserted length (display text plus optional gap). - */ - private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number { - this.pushTxn() - this.typingRun = undefined - const tail = this.draft.slice(span.end) - const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : '' - const displayText = referenceDraftText(reference) - const inserted = displayText + gap - this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length }) - this.withMinted([this.mint(reference, span.start, displayText.length)]) - this.adopt(this.draft.slice(0, span.start) + inserted + tail) - this.watchClaim() - return inserted.length - } - - /** - * Guarded token deletion after business success (popup settle / menu-pick - * execute). No effect signals success: the caller reads the draftRev - * advance off the published state (same currency as the other bail verbs). - */ - private onConsumeToken(guard: ConsumeTokenGuard): InputEffect[] { - if (this.phase !== 'plain' && this.phase !== 'claimed') return [] - switch (guard.kind) { - case 'span': { - const span = guard.span - if (!this.casOk(span) || span.start === span.end) return [] - this.pushTxn() - this.typingRun = undefined - this.reconcile({ start: span.start, end: span.end, insertedLength: 0 }) - this.adopt(this.draft.slice(0, span.start) + this.draft.slice(span.end)) - this.watchClaim() - this.paste = undefined - return [] - } - case 'bare-token': { - if (guard.token === '' || this.draft.trim() !== guard.token) return [] - this.pushTxn() - this.typingRun = undefined - this.occurrences = [] - this.adopt('') - this.watchClaim() - this.paste = undefined - return [] - } - default: return unreachable(guard) - } - } - - /** - * Owner-resolution style bits: exactly the listed occurrences render - * invalid. Not a transaction — the draft, revision, and undo log are - * untouched (invalidation never deletes or rewrites chips). - */ - private onSetInvalid(invalidIds: readonly number[]): InputEffect[] { - const ids = new Set(invalidIds) - if (!this.occurrences.some(o => (o.invalid === true) !== ids.has(o.occurrenceId))) return [] - this.occurrences = this.occurrences.map((o) => { - const invalid = ids.has(o.occurrenceId) - if ((o.invalid === true) === invalid) return o - const { invalid: _drop, ...rest } = o - return invalid ? { ...rest, invalid: true } : rest - }) - return [] - } - - // ---- undo / redo ---- - - private onUndo(): InputEffect[] { - const entry = this.log.pop() - if (entry === undefined) return [] - this.redoStack.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences }) - this.occurrences = entry.occurrencesBefore - this.adopt(entry.draftBefore) - this.watchClaim() - this.typingRun = undefined - this.paste = undefined - return [] - } - - private onRedo(): InputEffect[] { - const entry = this.redoStack.pop() - if (entry === undefined) return [] - // Manual log push: pushTxn would cut the redo chain being walked. - this.log.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences }) - if (this.log.length > LOG_LIMIT) this.log.shift() - this.occurrences = entry.occurrencesBefore - this.adopt(entry.draftBefore) - this.watchClaim() - this.typingRun = undefined - this.paste = undefined - return [] - } - - // ---- paste plane ---- - - /** - * Paste as one transaction: the text (reference-placeholder-sanitized) replaces the - * selection; hot-snapshot sync matches componentize inside the SAME - * transaction (one undo returns to pre-paste); a match attempt opens for - * the async remainder while the phase still accepts reference mutations. - */ - private onPasteBegin( - rawText: string, selection: EditSelection, - components: readonly PasteComponent[] = [], generation = 0, - ): InputEffect[] { - const { start, end } = selection - if (start < 0 || start > end || end > this.draft.length) return [] - const text = rawText.replace(REFERENCE_PLACEHOLDER_RE, '') - this.pushTxn(selection) - this.typingRun = undefined - // Componentize: replace each matched token range (paste-text coordinates, - // disjoint by contract) with inline display text while assembling the insert. - const sorted = [...components].sort((a, b) => a.start - b.start) - const minted: Occurrence[] = [] - let inserted = '' - let cursor = 0 - for (const c of sorted) { - inserted += text.slice(cursor, c.start) - const displayText = referenceDraftText(c.reference) - minted.push(this.mint(c.reference, start + inserted.length, displayText.length)) - inserted += displayText - cursor = c.end - } - inserted += text.slice(cursor) - this.reconcile({ start, end, insertedLength: inserted.length }) - this.withMinted(minted) - this.adopt(this.draft.slice(0, start) + inserted + this.draft.slice(end)) - this.watchClaim() - if (this.phase === 'plain' || this.phase === 'claimed') { - this.pasteSeq += 1 - this.paste = { - attemptId: this.pasteSeq, - insertedRange: { start, end: start + inserted.length }, - generation, - } - } else { - this.paste = undefined - } - return [] - } - - /** - * Async match landed: upgrade one pasted token to a chip as an INDEPENDENT - * transaction (undo #1 → the token text, undo #2 → pre-paste). The attempt - * stays current — later tokens re-CAS against the advanced draftRev. - */ - private onPasteUpgrade(attemptId: number, span: TokenSpan, reference: ReferenceInsert): InputEffect[] { - const attempt = this.paste - if (attempt === undefined || attempt.attemptId !== attemptId) return [] - if (this.phase !== 'plain' && this.phase !== 'claimed') return [] - if (!this.casOk(span) || span.start === span.end) return [] - const insertedLength = this.replaceSpanWithChip(reference, span) - this.paste = { - ...attempt, - insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + insertedLength - (span.end - span.start) }, - } return [] } // ---- submit plane ---- /** Mint the next SubmitAttempt and take the in-flight slot. */ - private beginAttempt(mode: InputSubmitMode): SubmitAttempt { + private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt { const controller = new AbortController() this.seq += 1 - const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode } + const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode } this.inflight = { attempt, controller } return attempt } - private onEnter(mode: InputSubmitMode): InputEffect[] { + private onEnter(mode: InputSubmitMode, draft: string): InputEffect[] { if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] if (this.phase === 'claimed' && this.claim !== undefined) { - const attempt = this.beginAttempt(mode) + const attempt = this.beginAttempt(mode, draft) this.phase = 'submitting' - this.paste = undefined - return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }] + return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }] } - const trimmed = this.draft.trim() + const trimmed = draft.trim() if (trimmed === '') return [] - this.paste = undefined if (trimmed.startsWith('/')) { - const attempt = this.beginAttempt(mode) + const attempt = this.beginAttempt(mode, draft) this.phase = 'adjudicating' - return [{ type: 'adjudicate', attempt, draft: this.draft }] + return [{ type: 'adjudicate', attempt, draft }] } - const attempt = this.beginAttempt(mode) + const attempt = this.beginAttempt(mode, draft) this.phase = 'submitting' - return [{ type: 'default-sink', attempt, draft: this.draft, mode }] + return [{ type: 'default-sink', attempt, draft, mode }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -514,7 +154,7 @@ export class InputMachine { args: argsAfter(attempt.draftSnapshot, outcome.claim.token), }] } - // 'handled' (source dealt internally), {insert} (no enter-time span + // 'handled' (source dealt internally), {insert}/{text} (no enter-time span // semantics), or a miss: all land plain; only the miss flows to the sink. if (outcome === undefined) { this.phase = 'submitting' @@ -545,30 +185,19 @@ export class InputMachine { if (ev.ok) { this.phase = 'plain' this.claim = undefined - this.occurrences = [] - // Text appended after the sent snapshot during the Host round-trip - // survives the commit; edits interleaved with committed content cannot - // be separated from it, so only a pure suffix is retained. - const snapshot = flight.attempt.draftSnapshot - this.adopt(this.draft !== snapshot && this.draft.startsWith(snapshot) - ? this.draft.slice(snapshot.length) - : '') - // Committed content is gone for good: undo must not resurrect a sent draft. - this.log = [] - this.redoStack = [] - this.typingRun = undefined - this.paste = undefined - return ev.outcome?.text !== undefined - ? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }] - : [] + const effects: InputEffect[] = [{ type: 'commit-draft', retainSuffixOf: flight.attempt.draftSnapshot }] + if (ev.outcome?.text !== undefined) { + effects.push({ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }) + } + return effects } const text = ev.message ?? ev.outcome?.text // Keep the same command claim only while the live draft still equals the // enter-time draft; user input typed during flight wins. // Claimed re-entry additionally requires the watch to hold — an // enter-path snapshot may carry leading whitespace the token never had. - if (this.draft === flight.attempt.draftSnapshot - && this.claim !== undefined && this.draft.startsWith(this.claim.token)) { + if (ev.draft === flight.attempt.draftSnapshot + && this.claim !== undefined && ev.draft.startsWith(this.claim.token)) { this.phase = 'claimed' return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } @@ -577,17 +206,11 @@ export class InputMachine { return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } - /** Cut undo state after an accepted image-only send. */ + /** Clear the draft after an accepted image-only send (no suffix retention: there was no draft). */ private onSendCommitted(): InputEffect[] { if (this.phase !== 'plain') return [] this.claim = undefined - this.occurrences = [] - this.adopt('') - this.log = [] - this.redoStack = [] - this.typingRun = undefined - this.paste = undefined - return [] + return [{ type: 'commit-draft', retainSuffixOf: null }] } private onRelease(): InputEffect[] { @@ -597,8 +220,6 @@ export class InputMachine { } this.phase = 'plain' this.claim = undefined - this.typingRun = undefined - this.paste = undefined return [] } } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 5948cae9a9..aadedd7d29 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -4,7 +4,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, - IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, Tooltip, + IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId } from '../contract/queue.ts' import { NS } from '../locales.ts' @@ -114,7 +114,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps }} /> ) - : {row.preview}} + : {projectUserText(row.preview, [])}} {queueMutable &&
{editing?.id === row.id ? ( diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 0a0d9206aa..8230af0199 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -111,57 +111,20 @@ height: 0; } -/* The draft's scrollport, and the ONLY scrolling box in the composer: the - caret is the textarea's and every visible glyph is the backdrop's, so the two - layers stay together only by riding one offset the browser applies to both at - once. Scrolling one box and assigning the offset to the other cannot hold — - a wheel gesture is composited off the main thread, so the assignment lands - frames late and the words visibly trail the caret. The 14-line cap lives here +/* The draft's scrollport, and the ONLY scrolling box in the composer. The + contenteditable grows with its content inside; the 14-line cap lives here because this is the box the cap describes. */ .scroll { max-height: var(--dsh-composer-text-max-height); overflow-y: auto; } -/* Mirror-div auto-grow stack: the hidden mirror is in normal flow and sets the FULL draft - height (min 2 lines in hero); backdrop and textarea ride it absolutely, so both layers are - as tall as the draft and the scrollport above shows a window onto them. Mirror and textarea - MUST share font, line-height, padding and wrapping rules or heights diverge. */ +/* Auto-grow anchor: the contenteditable is in normal flow and sets the + draft's height; the placeholder rides it absolutely. */ .grow { position: relative; } -/* Decoration backdrop: same metrics as the transparent-text textarea. It owns - every visible glyph plus the range colors and ghost hint; the textarea above - retains the native selection and caret. */ -.backdrop { - position: absolute; - inset: 0; - overflow: hidden; - color: var(--dsw-alias-label-primary); - pointer-events: none; -} - -.backdropDisabled, -.backdropDisabled :is(.hlToken, .hint, .textRef, .chip, .chipInvalid) { - color: var(--dsw-alias-label-tertiary); -} - -.hlToken { - background-color: transparent; - color: var(--dsw-alias-state-warn-label); -} - -.hlSegment { - border-radius: 4px; - background-color: transparent; - color: transparent; -} - -.hint { - color: var(--dsw-alias-label-caption); -} - /* Machine pending dot (adjudicating / submitting). */ .pending { width: 8px; @@ -176,70 +139,48 @@ to { opacity: 1; } } +/* The contenteditable draft surface (grows with its content; .scroll caps + and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */ .input { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - resize: none; - /* Never a scroller of its own: it is as tall as the draft, so it has no - scrollable overflow to hold an offset that could differ from the glyphs'. - The browser still reveals the caret — the scroll-into-view walks up to - .scroll and moves both layers together. */ - overflow: hidden; - border: none; - outline: none; - background: transparent; - color: transparent; - -webkit-text-fill-color: transparent; - /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ - caret-color: var(--dsw-alias-state-business-primary); -} - -.input, -.mirror, -.backdrop { - /* Textareas default to content-box (unlike buttons/inputs): without this the - width:100% textarea gains its padding OUTSIDE the card and text runs past - the right padding — and wraps 28px later than the mirror/backdrop layers. */ box-sizing: border-box; - /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these - metrics or the highlight ranges drift off the glyphs. */ padding: 4px 12px 0 16px; font-family: var(--dsw-font-family); font-size: inherit; - /* Three consumers, not two: the mirror sizes the stack, the layers must break - lines identically, and the caret reveal parses this value to step one line - down for a caret that sits after a newline. That parse needs a length, so a - theme resolving this to `normal` would make the reveal a silent no-op. */ line-height: inherit; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; - /* These three MUST wrap at one width: the mirror decides the box height - the other two are laid out in, and a glyph layer that breaks lines - elsewhere than the textarea puts the words under the wrong caret. They do - so by construction now that all three sit INSIDE .scroll — a scrollbar - that consumes layout space narrows the scrollport, which is their shared - containing block, so it costs all three the same width on every engine. - A textarea that scrolls itself would break this, and no property fixes - it: WebKit reserves gutter space for an overflow-y:auto textarea and not - for the overflow:hidden layers beside it, leaving them 8px apart - (768 against 776) — worth 2 to 5 wrapped lines on a long draft. */ + outline: none; + color: var(--dsw-alias-label-primary); + /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ + caret-color: var(--dsw-alias-state-business-primary); +} + +/* Lexical paragraphs are

blocks: strip the UA margins so the surface + keeps the textarea's line rhythm. */ +.input p { + margin: 0; +} + +/* Claim ghost hint as generated content after the last paragraph: the bar + sets --dsh-composer-hint (a quoted string) while the claim's args are + blank; without the variable the declaration is invalid and nothing shows. */ +.input p:last-child::after { + content: var(--dsh-composer-hint); + color: var(--dsw-alias-label-caption); } /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */ -.input::placeholder { +.placeholder { + position: absolute; + inset: 4px 12px auto 16px; color: var(--dsw-alias-label-caption); - -webkit-text-fill-color: var(--dsw-alias-label-caption); + pointer-events: none; user-select: none; } -/* The backdrop owns disabled draft color; the textarea remains caret-only so - its marker glyphs cannot cover the reference icons beneath it. */ -.input:disabled { - color: transparent; - -webkit-text-fill-color: transparent; +.inputDisabled { + color: var(--dsw-alias-label-tertiary); cursor: not-allowed; } @@ -247,14 +188,9 @@ cursor: pointer; } -.mirror { - visibility: hidden; - pointer-events: none; -} - /* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24 line + 4pt); the docked composer collapses to the content height. */ -.hero .mirror { +.hero .input { min-height: 52px; } @@ -405,64 +341,3 @@ font-size: 12px; cursor: pointer; } - -/* Plain-text reference highlight: a pure range mark over the - draft's own glyphs — advance untouched, so the two layers cannot drift. - Chip family colors; clone keeps rounded ends on soft-wrap fragments. */ -.textRef { - background-color: transparent; - color: var(--dsw-alias-state-business-primary); - box-decoration-break: clone; - -webkit-box-decoration-break: clone; -} -.textRef:after { - display: none; -} - -.textRefTrigger { - position: relative; -} - -.textRefTriggerGlyph { - color: transparent; -} - -.textRefIcon { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} - -/* Structured references use the same inline-backdrop technique as /skill: - their complete display text remains in the textarea, so wrapping and caret - geometry come from the browser's native glyph metrics. The leading marker - reserves the icon's advance while the backdrop paints the domain glyph. */ -.chip { - position: relative; - color: var(--dsw-alias-state-business-primary); - background: transparent; - -webkit-box-decoration-break: clone; - box-decoration-break: clone; -} - -.chipTrigger { - position: relative; -} - -.chipTriggerGlyph { - color: transparent; -} - -.chipIcon { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} - -.chipInvalid { - text-decoration: line-through; - opacity: 0.7; - color: var(--dsw-alias-state-error-primary); -} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 0ac5ec52b8..d6126acdfd 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -4,73 +4,39 @@ * through this entry's own inject, whose hooks compartment binds * useNotices/useLexicon; layout-phase inputs (variant, placeholder, * region-slot content) ride the owner props. Session facts - * (running/removed/promptError) are self-selected via useSession. */ + * (running/removed/promptError) are self-selected via useSession. + * + * The text surface is the shell-owned Lexical editor bound here through + * ComposerContentEditable; chips render as decorator portals, and the + * keymap registers submit/menu/paste gestures on the editor command layer. + * The no-session state renders the SAME div inert as the Workspace-picker + * trigger instead of a parallel tree. + */ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { - IconPlusOutline16, IconWarningOutline16, ReferenceIcon, Toast, Tooltip, + IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). import type {} from '@deepseek-ai/dsh-plan-mode/client' // Type-only: the `goal` projection key merge (hint disambiguation). import type {} from '@deepseek-ai/dsh-goal/client' -// The `imageLimits` projection key merge supplies the intake pre-check. +// The `imageLimits` projection key merge (intake pre-check) arrives with the +// wire types: apiproxy's sessions contract declares it, and client-runtime's +// api-remotes import already places it in every client program. import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerBarProps } from '../contract/slots.ts' -import { deriveDecorations } from './decorations.ts' -import type { DraftDecorations } from './decorations.ts' -import type { EditRange } from '../contract/input.ts' +import { ComposerContentEditable } from '../input/editor/ComposerContentEditable.tsx' +import { DecoratorPortals } from '../input/editor/DecoratorPortals.tsx' +import { registerComposerKeymap } from '../input/editor/keymap.ts' import { attachmentErrorText, imageSizeText } from '../image-labels.ts' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' -import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' import css from './InputBar.module.css' -/** Decoration product of the no-session state (no machine, empty draft). */ -const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null } - -/** The selection and edit family a `beforeinput` recorded, with the draft length it applied to. */ -interface PendingEdit { - readonly start: number - readonly end: number - readonly draftLength: number - readonly inputType: string -} - -/** - * Resolve one edit's range from the record taken before it applied. - * A selection the edit replaces is the range outright. A caret delete replaces - * nothing and reports the bare caret, so the removed span is whatever the draft - * lost, on the side `inputType` names — measured, because one caret gesture can - * remove a multi-unit grapheme, a word, or a line. - * @param pending - record taken at `beforeinput`, null when none was seen. - * @param prevLength - length of the draft the edit applied to. - * @param nextLength - length of the resulting draft. - * @returns the exact range, or undefined when the record cannot describe this - * edit and the machine's diff scan has to recover it. - */ -function editRangeOf(pending: PendingEdit | null, prevLength: number, nextLength: number): EditRange | undefined { - if (pending === null || pending.draftLength !== prevLength) return undefined - const { start, end, inputType } = pending - // A DOM selection cannot invert; the check keeps that a precondition of the - // math below rather than an assumption about the element. - if (start > end || end > prevLength) return undefined - const insertedLength = nextLength - prevLength + (end - start) - if (insertedLength >= 0) return { start, end, insertedLength } - if (start !== end) return undefined - const removed = prevLength - nextLength - if (inputType.endsWith('Backward')) { - return removed <= start ? { start: start - removed, end: start, insertedLength: 0 } : undefined - } - if (inputType.endsWith('Forward')) { - return start + removed <= prevLength ? { start, end: start + removed, insertedLength: 0 } : undefined - } - return undefined -} - export type InputBarProps = ComposerBarProps export function InputBar({ @@ -83,13 +49,13 @@ export function InputBar({ }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) - const lexicon = useLexicon(s => s) + void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms const commandMenuOpen = useMenuLauncher(source => source === 'command') const promptError = useSession(s => s.promptError) ?? null const running = useSession(s => s.running) ?? false const subagent = useSession(s => s.subagent) ?? null const removed = useSession(s => s.removed) ?? false - // Plan mode swaps the textarea placeholder (the projection is the folded + // Plan mode swaps the composer placeholder (the projection is the folded // host value; owner-prop placeholders — hero, session-unavailable — win). const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) // Absent (undefined: no frame yet) and cleared (null) both mean no goal. @@ -98,6 +64,7 @@ export function InputBar({ // current; the bar renders the same DOM inert instead of a parallel tree. const live = input !== undefined && keyboard !== undefined && inputActions !== undefined const draft = input?.draft ?? '' + const editor = keyboard?.editor ?? null const attachments = useMemo( () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds), [draftImages, input?.imageIds], @@ -132,23 +99,8 @@ export function InputBar({ useEffect(() => { if (notice?.level === 'error') showToast(notice.text) }, [notice, showToast]) - const inputRef = useRef(null) const cardRef = useRef(null) const scrollRef = useRef(null) - const mirrorRef = useRef(null) - const safari = useMemo(() => isSafariBrowser(navigator), []) - const safariNativeShrinkRef = useRef(false) - // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders; - // clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend. - const composingRef = useRef(false) - const onCompositionStart = (): void => { - composingRef.current = true - } - const onCompositionEnd = (): void => { - setTimeout(() => { - composingRef.current = false - }, 10) - } // The Access seat's data: the host-computed permissions projection // (undefined = capability absent → the chip renders nothing). @@ -170,12 +122,13 @@ export function InputBar({ // be disabled do lock it — there is no session to choose a model for. const modelSeatLocked = removed || inert || !live const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' - // The no-workspace textarea remains the resident DOM node but acts as the + // The no-workspace surface remains the resident DOM node but acts as the // existing picker trigger. Message controls stay locked until a Session // exists; the trigger itself is read-only rather than disabled so pointer // and keyboard users can reach the recovery action. const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined - const textareaDisabled = removed || (locked && !workspaceTrigger) + const editorDisabled = removed || (locked && !workspaceTrigger) + const editable = live && !locked && !machineBusy const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null && input.queue.some(row => row.placement === 'queued') @@ -186,77 +139,43 @@ export function InputBar({ } }, [attachments, input?.imageIds, inputActions]) - // A native Safari edit that shortens the draft may leave the previous - // soft-wrap layout behind after the mirror shrinks. The native-change signal - // keeps ordinary typing and programmatic draft updates from reading layout; - // the helper then repairs only measured overflow before paint while - // preserving native editing state. See - // .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md. - useLayoutEffect(() => { - const nativeShrink = safariNativeShrinkRef.current - safariNativeShrinkRef.current = false - if (safari && nativeShrink) repairSafariTextareaLayout(inputRef.current) - }, [draft, safari]) - // Scroll the draft scrollport the minimum that brings `caret` into view — the - // browser's own behavior for typing, performed for the paths where it does - // not act. - // - // The mirror is the caret's ruler: it renders the same draft at the same - // metrics and the same wrap width in the same stack (that is what makes it - // the height authority), so a Range collapsed at the caret's index reports - // where the caret is without a caret API. - const revealCaret = (caret: number): void => { + // Scroll the draft scrollport the minimum that brings the selection focus + // into view — the browser's own behavior for typing, performed for the + // paths where it does not act (programmatic focus with preventScroll, and + // session switches that land the caret off screen). The live DOM selection + // is the ruler; no mirror layer exists to consult. + const revealSelection = (): void => { const scrollEl = scrollRef.current - const mirrorEl = mirrorRef.current - const text = mirrorEl?.firstChild - if (scrollEl === null || mirrorEl === null || !(text instanceof Text)) return - // A box that cannot scroll has nothing to reveal: the draft fits, so every - // caret is already in view and the assignment below would clamp to itself. - if (scrollEl.scrollHeight <= scrollEl.clientHeight) return - const at = Math.min(caret, text.data.length) - // A caret straight after a newline sits on a line with nothing on it to - // measure — the shape a trailing-newline draft ends in — and the engines - // disagree there: chromium returns NO client rects at all (an all-zero box, - // which would scroll the wrong way), firefox reports the line above, WebKit - // the right one. Measure the newline itself instead, which is the line the - // caret just left, and step one line down; that they all agree on. - const afterNewline = at > 0 && text.data[at - 1] === '\n' - const range = document.createRange() - range.setStart(text, afterNewline ? at - 1 : at) - if (afterNewline) range.setEnd(text, at) - else range.collapse(true) - const line = afterNewline ? Number.parseFloat(getComputedStyle(mirrorEl).lineHeight) : 0 - const rect = range.getBoundingClientRect() + if (scrollEl === null || scrollEl.scrollHeight <= scrollEl.clientHeight) return + const selection = window.getSelection() + if (selection === null || selection.rangeCount === 0) return + const range = selection.getRangeAt(0) + let rect = range.getBoundingClientRect() + if (rect.height === 0 && rect.width === 0) { + // A collapsed caret at an empty line reports a zero rect in some + // engines; the anchor's element box is the line the caret sits on. + const anchor = selection.anchorNode + const el = anchor instanceof HTMLElement ? anchor : anchor?.parentElement + if (el === undefined || el === null) return + rect = el.getBoundingClientRect() + } const box = scrollEl.getBoundingClientRect() - if (rect.bottom + line > box.bottom) scrollEl.scrollTop += rect.bottom + line - box.bottom - else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line - } - - // Reveal the focus end of the current selection. Every shipped entry path leaves a - // collapsed selection, but honoring direction keeps a future range-preserving - // path from revealing its anchor instead of its focus. - const revealSelectionFocus = (el: HTMLTextAreaElement): void => { - // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. - const caret = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd - // oxlint-disable-next-line typescript/no-unnecessary-condition - revealCaret(caret ?? el.value.length) + if (rect.bottom > box.bottom) scrollEl.scrollTop += rect.bottom - box.bottom + else if (rect.top < box.top) scrollEl.scrollTop -= box.top - rect.top } // Unlock (mount / session switch) returns focus to the box, and owns the - // reveal that comes with it. `preventScroll` because this focus is ours, not - // a gesture: the textarea is as tall as the draft, so the browser's reveal - // would walk up to the conversation scrollport and move the transcript under - // a user who only switched session. That leaves the caret to us — the DOM is - // reused across sessions, so switching to a longer draft keeps the previous - // offset while the value swap puts the caret at the new draft's end, which is - // off screen (measured on all three engines: offset 0 with the caret 940px - // down). Suppress the walk, then reveal in our own box. + // reveal that comes with it. Lexical's focus() suppresses the browser's + // scroll walk (preventScroll inside), so the reveal in our own scrollport + // is ours to perform — switching to a longer draft otherwise leaves the + // caret (restored at the draft's end) off screen. useEffect(() => { - const el = inputRef.current - if (locked || el === null) return - el.focus({ preventScroll: true }) - revealSelectionFocus(el) - }, [locked, sessionId]) + if (locked || editor === null) return + // Lexical's focus() restores the editor selection but never calls the DOM + // focus itself; preventScroll keeps the conversation scrollport still. + editor.getRootElement()?.focus({ preventScroll: true }) + editor.focus(() => { revealSelection() }) + }, [locked, sessionId, editor]) // A persisted draft arrives AFTER the unlock effect: ConversationSession // adopts it in its own mount effect, and a parent's mount effect runs after @@ -265,25 +184,10 @@ export function InputBar({ // not focus: send-clear, failed-send restore, and first-character transitions // must not steal focus from another control the user moved to. useEffect(() => { - const el = inputRef.current - if (locked || draft === '' || el === null) return - revealSelectionFocus(el) + if (locked || draft === '') return + revealSelection() }, [draft !== '']) - // Caret restore after an edit the composer performs itself. The machine owns - // the draft and the undo log, so paste and cut suppress the native edit and - // write the value through the machine — and a - // programmatic selection change reveals nothing: measured in chromium and - // WebKit, pasting a long block leaves the view where it was while the caret - // sits at the end of the draft. Native typing gets its reveal from the - // browser; these two have to ask for it, so they share one restore. - const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => { - requestAnimationFrame(() => { - el.setSelectionRange(caret, caret) - revealCaret(caret) - }) - } - // Wheel chaining on the draft scrollport, one lifetime (it is never // unmounted — the inert state renders the same element disabled). While the // capped box can still move in this direction, keep the native scroll; only @@ -306,199 +210,6 @@ export function InputBar({ return () => { el.removeEventListener('wheel', onWheel) } }, []) - // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. - /* oxlint-disable typescript/no-unnecessary-condition */ - const selectionOf = (el: HTMLTextAreaElement) => ({ - start: el.selectionStart ?? 0, - end: el.selectionEnd ?? el.selectionStart ?? 0, - }) - /* oxlint-enable typescript/no-unnecessary-condition */ - - // The machine's occurrence math needs the edit's real range, and a controlled - // textarea's change event carries only the resulting string. `beforeinput` - // fires while the element still holds the pre-edit selection, which is - // exactly the range about to be replaced; a textarea exposes it no other way - // (`getTargetRanges()` is empty for form controls). Recovering the range by - // diffing the two drafts instead is ambiguous whenever the typed text repeats - // what it lands against — typing the trigger char before a reference reads as - // landing inside that reference, which drops it. One lifetime, like the wheel - // listener above: the textarea is never unmounted. - const pendingEditRef = useRef(null) - useEffect(() => { - const el = inputRef.current - if (el === null) return - const onBeforeInput = (e: InputEvent): void => { - // Only the families whose reported selection describes the edit. A - // history replay reports wherever the caret happens to sit, which would - // survive every check in editRangeOf while naming the wrong span. - if (!e.inputType.startsWith('insert') && !e.inputType.startsWith('delete')) { - pendingEditRef.current = null - return - } - const { start, end } = selectionOf(el) - pendingEditRef.current = { start, end, draftLength: el.value.length, inputType: e.inputType } - } - el.addEventListener('beforeinput', onBeforeInput) - return () => { el.removeEventListener('beforeinput', onBeforeInput) } - }, []) - - const onKeyDown = (e: KeyboardEvent): void => { - if (workspaceTrigger) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onRequestWorkspace() - } - return - } - // Absent machine without a Workspace recovery action stays disabled; the - // guard narrows the faces for the paths below. - if (input === undefined || keyboard === undefined || inputActions === undefined) return - // Shift+Enter is the native newline UNCONDITIONALLY — decided before the - // IME guard so a composition-closing Shift+Enter still breaks the line. - if (e.key === 'Enter' && e.shiftKey) return - // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. - // oxlint-disable-next-line typescript/no-deprecated - const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229 - if (!composing && !machineBusy && !locked - && (e.key === 'Backspace' || e.key === 'Delete')) { - const selection = selectionOf(e.currentTarget) - if (selection.start === selection.end) { - const occurrence = input.occurrences.find(o => e.key === 'Backspace' - ? o.offset + o.length === selection.start - : o.offset === selection.start) - if (occurrence !== undefined) { - e.preventDefault() - const start = occurrence.offset - const end = occurrence.offset + occurrence.length - keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 }) - restoreCaret(e.currentTarget, start) - keyboard.track(keyboard.snapshot.draft, start) - return - } - } - } - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault() - return - } - if (e.key === 'Escape') { - // Escape layering: an open overlay closes; claimed without an overlay - // does NOT release (backspacing the token is the only exit gesture). - keyboard.dismissPopup() - if (keyboard.arbitrate('escape', composing) === 'consumed') e.preventDefault() - return - } - if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z' || e.key === 'y')) { - // The machine owns the undo/redo log (chip transactions have semantics - // the browser stack cannot represent); never let the native stack run. - e.preventDefault() - if (machineBusy || locked) return - const redo = e.key === 'y' || e.shiftKey - if (redo) keyboard.redo() - else keyboard.undo() - return - } - if (e.key === ' ') { - if (composing) return - if (keyboard.space()) e.preventDefault() // claim token already carries the trailing separator - return - } - if (e.key !== 'Enter') return - if (composing) return - // Menu-open Enter picks the highlight through arbitration; a no-highlight - // menu passes down to the machine's own adjudication. - const arbitrated = keyboard.arbitrate('enter', composing) - if (arbitrated !== 'pass') { - e.preventDefault() - return - } - e.preventDefault() - if (e.repeat) return // held-down Enter must not machine-gun sends - if (locked || machineBusy) return - const accelerated = e.ctrlKey || e.metaKey - // Empty-draft accelerated Enter acts on the queue instead of the (empty) - // draft: the machine rejects empty drafts, so the gesture steers every - // still-pending queued message into the running turn (the dock's per-row - // steer button applied to the whole queue). Steering needs the same - // window as the per-row button: a running ordinary session. - if (accelerated && canSteerQueue) { - keyboard.steerQueue() - return - } - keyboard.submit(resolveSubmitMode( - running, - accelerated ? 'accelerated' : 'enter', - subagent === null, - )) - } - - const onChange = (e: ChangeEvent): void => { - if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft - if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock - const next = e.target.value - const pending = pendingEditRef.current - pendingEditRef.current = null - safariNativeShrinkRef.current = safari && next.length < draft.length - keyboard.setDraft(next, editRangeOf(pending, draft.length, next.length)) - // selectionStart is number|null in lib.dom; the type-aware lint program narrows it. - // oxlint-disable-next-line typescript/no-unnecessary-condition - keyboard.track(next, e.target.selectionStart ?? next.length) - } - - const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { - if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut - const el = e.currentTarget - const { start, end } = selectionOf(el) - if (start === end) return - const touched = input.occurrences.filter(o => o.offset < end && o.offset + o.length > start) - if (touched.length === 0 && !cut) return // plain copy of plain text: native path is fine - e.preventDefault() - const copyStart = touched.reduce((value, o) => Math.min(value, o.offset), start) - const copyEnd = touched.reduce((value, o) => Math.max(value, o.offset + o.length), end) - // Expand structured ranges to their owner clipboard projections. - let text = '' - let cursor = copyStart - for (const o of touched) { - text += draft.slice(cursor, o.offset) + o.clipboardText - cursor = o.offset + o.length - } - text += draft.slice(cursor, copyEnd) - e.clipboardData.setData('text/plain', text) - if (cut && !machineBusy && !locked) { - keyboard.setDraft( - draft.slice(0, copyStart) + draft.slice(copyEnd), - { start: copyStart, end: copyEnd, insertedLength: 0 }, - ) - restoreCaret(el, copyStart) - } - } - - const onPaste = (e: React.ClipboardEvent): void => { - if (keyboard === undefined) return // absent machine: no draft can accept a paste - if (machineBusy || locked) return - const files = Array.from(e.clipboardData.items) - .filter(item => item.kind === 'file') - .map(item => item.getAsFile()) - .filter((file): file is File => file !== null) - if (files.length > 0) intakeImages(files) - const text = e.clipboardData.getData('text/plain') - if (text === '') { - if (files.length > 0) e.preventDefault() - return - } - e.preventDefault() - const el = e.currentTarget - const sel = selectionOf(el) - // Sync components stay empty at this layer: hot-snapshot matching needs - // the Slash roster, which lives behind keyboard.track — the paste attempt - // opens in the machine and the controller upgrades tokens as matches - // land (paste-upgrade). The DOM layer only starts the transaction. - keyboard.pasteBegin(text, sel) - const caret = sel.start + text.length - restoreCaret(el, caret) - keyboard.track(keyboard.snapshot.draft, caret) - } - // Intake pre-check: an addition that would break // a projected limit is refused as a whole batch, announced immediately, and // never enters the rail — no more submit-time failure rolling the rail @@ -533,25 +244,67 @@ export function InputBar({ const canAcceptDrop = !locked && !machineBusy && addImages !== undefined - const onSelect = (e: React.SyntheticEvent): void => { - // Any caret/selection gesture ends a live paste attempt (the machine - // cannot observe DOM selection). Cheap no-op when none is live. - if (keyboard !== undefined && keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste() - void e - } + // The keymap handlers read live bar state through this ref so the editor + // registration survives re-renders without re-arming per keystroke. + const gate = useRef({ + locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages, + }) + gate.current = { locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages } - // Button presses steal focus from the textarea; suppress at mousedown so - // typing continues seamlessly. `preventScroll` for the same reason as the - // unlock effect, and with no reveal of its own: the caret has not moved, and - // the next keystroke gets the browser's native one. + useEffect(() => { + if (editor === null || keyboard === undefined) return + return registerComposerKeymap(editor, { + arbitrate: (key, composing) => keyboard.arbitrate(key, composing), + space: () => { + if (gate.current.machineBusy || gate.current.locked) return false + return keyboard.space() + }, + dismissPopup: () => { keyboard.dismissPopup() }, + canSubmit: () => !gate.current.locked && !gate.current.machineBusy, + submit: (accelerated) => { + const g = gate.current + // Empty-draft accelerated Enter acts on the queue instead of the + // (empty) draft: the machine rejects empty drafts, so the gesture + // steers every still-pending queued message into the running turn. + if (accelerated && g.canSteerQueue) { + keyboard.steerQueue() + return + } + keyboard.submit(g.resolveSubmitMode( + g.running, + accelerated ? 'accelerated' : 'enter', + g.subagent === null, + )) + }, + intakeFiles: (files) => { gate.current.intakeImages(files) }, + pasteText: (text) => { + if (gate.current.machineBusy || gate.current.locked) return + keyboard.paste(text) + }, + }) + }, [editor, keyboard]) + + // Button presses steal focus from the editor; suppress at mousedown so + // typing continues seamlessly. Lexical's focus() carries preventScroll and + // restores the previous selection, so no reveal is needed: the caret has + // not moved, and the next keystroke gets the browser's native one. const keepFocus = (e: MouseEvent): void => { e.preventDefault() - inputRef.current?.focus({ preventScroll: true }) + editor?.getRootElement()?.focus({ preventScroll: true }) } const onToggleCommandMenu = (): void => { - const el = inputRef.current - if (el !== null) toggleCommandMenu?.(selectionOf(el)) + if (keyboard !== undefined) toggleCommandMenu?.(keyboard.caretSpan()) + } + + // The no-session Workspace trigger: the resident editable div acts as the + // picker trigger for keyboard users (no editor is bound in this state). + const onWorkspaceKeyDown = (e: KeyboardEvent): void => { + if (!workspaceTrigger) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onRequestWorkspace() + } } // An ordinary running session keeps Stop while the composer is empty or @@ -577,102 +330,36 @@ export function InputBar({ ? null : - // Mirror-layer decorations: a visible backdrop with transparent textarea - // text. Claim tokens and references retain the draft's own glyph metrics, - // so their decoration cannot drift from wrapping, selection, or the caret. - const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon) - const backdrop: ReactNode[] = [] - { - // Segment boundaries: the token range end, every structured-reference - // offset, and every text-ref range — merged in draft order (the sources never - // overlap: structured references own their ranges, text-refs own plain tokens, the - // claim token only leads). - let cursor = 0 - const pushPlain = (upTo: number): void => { - if (upTo > cursor) backdrop.push(draft.slice(cursor, upTo)) - cursor = upTo - } - if (deco.token !== null) { - backdrop.push( - - {draft.slice(deco.token.start, deco.token.end)} - , - ) - cursor = deco.token.end - } - type Boundary = - | { at: number; kind: 'chip'; chip: (typeof deco.chips)[number] } - | { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number]; ordinal: number } - const boundaries: Boundary[] = [ - ...deco.chips.map(chip => ({ at: chip.offset, kind: 'chip' as const, chip })), - ...deco.textRefs.map((ref, ordinal) => ({ at: ref.start, kind: 'text-ref' as const, ref, ordinal })), - ].sort((a, b) => a.at - b.at) - for (const b of boundaries) { - if (b.at < cursor) continue // claim-token overlap: the leading mark wins - pushPlain(b.at) - if (b.kind === 'chip') { - const chip = b.chip - backdrop.push( - - {chip.appearance === undefined - ? chip.text[0] - : ( - - {chip.text[0]} - - - )} - {chip.text.slice(1)} - , - ) - cursor = chip.offset + chip.length - } else { - // Plain-range highlight: the glyphs stay the - // textarea's (advance untouched); the mark paints the chip look. - // The key is the draft-order ordinal: a fresh scan derives these - // ranges every render, so none of them carries identity past its - // position, and a draft-offset key would unmount the mark and its - // icon for every character typed ahead of it. Structured references - // key by occurrenceId, the identity their occurrence table owns. - const text = draft.slice(b.ref.start, b.ref.end) - backdrop.push( - - {b.ref.appearance === 'folder' - ? ( - <> - - {text[0]} - - - {text.slice(1)} - - ) - : text} - , - ) - cursor = b.ref.end - } - } - pushPlain(draft.length) - if (deco.hint !== null) { - // Claim tokens have the `/name ` format (trailing space); trim to the bare name. - const commandName = input?.claim?.token.slice(1).trim() ?? '' - const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` - // Dynamic lookup by claimed command name: unknown commands miss the - // dictionary and keep the machine's own hint, so the call is wide. - const translated = (t as Translate)(hintKey) - const displayHint = translated !== hintKey ? translated : deco.hint - backdrop.push({displayHint}) - } - } + // Claim ghost hint: rendered by CSS as generated content after the last + // paragraph while the claim's args are blank (a hint implies a single-line + // token draft). The translated per-command hint wins over the claim's own. + const claimActive = (input?.phase === 'claimed' || input?.phase === 'submitting') + && input.claim !== undefined && draft.startsWith(input.claim.token) + const rawHint = claimActive && input.claim.hint !== undefined + && draft.slice(input.claim.token.length).trim() === '' + ? input.claim.hint + : null + const hint = ((): string | null => { + if (rawHint === null) return null + // Claim tokens have the `/name ` format (trailing space); trim to the bare name. + const commandName = input?.claim?.token.slice(1).trim() ?? '' + const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` + // Dynamic lookup by claimed command name: unknown commands miss the + // dictionary and keep the machine's own hint, so the call is wide. + const translated = (t as Translate)(hintKey) + return translated !== hintKey ? translated : rawHint + })() + + const placeholderText = placeholder ?? (parentOffline + ? t('placeholder.parentOffline') + : disabled + ? t('placeholder.unavailable') + // The steer hint deliberately outranks the plan placeholder: + // while it shows, the whole-queue gesture is genuinely available + // (the gate never consults plan mode), so the actionable hint wins. + : canSteerQueue + ? t('placeholder.steerQueue') + : planActive ? t('placeholder.plan') : t('placeholder.default')) return (

@@ -690,7 +377,7 @@ export function InputBar({ {notice.text}
)} - {/* Trigger clicks land on the card, not the textarea: the toolbar row's + {/* Trigger clicks land on the card, not the editor: the toolbar row's disabled controls swallow clicks otherwise (the CSS state disarms their pointer events), so the WHOLE capsule is the pick target. pointerdown stops here so the Menu's outside-close cannot race the @@ -714,54 +401,35 @@ export function InputBar({ size: imageSizeText(imageLimits.maxImageBytes), }, })} - {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the - stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the - absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14 - lines in CSS — is the only thing that scrolls. The caret belongs to the textarea and the - glyphs to the backdrop, so they can only stay together by moving together: one scroll - offset the browser applies to both layers at once, never a JS mirror between two boxes, - which a compositor-driven gesture outruns and leaves the words trailing the caret. */} + {/* One scrollport, one text surface: the contenteditable grows with + its content and .scroll — capped at 14 lines in CSS — is the only + thing that scrolls. Chips are decorator portals inside the same + surface, so wrapping, caret geometry, and scrolling are the + browser's own. */}
-
- {backdrop} -
-