From 060323d8e2f355022cfa9dbdb9226a421a7b7638 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Thu, 10 Sep 2026 20:32:14 +0800 Subject: [PATCH 1/2] feat(web): backport feedback and file refinements to 0.1.5 Backport the selected changes from PR #3928 and PR #3902 onto dsh-v0.1.5-rc.1, retaining baseline session recordings and excluding newer master commits. Refs #3927 Refs #3903 Co-authored-by: LegGasai --- ...8-feedback-dialog-and-categories.i18n.yaml | 4 +- ...26-09-08-feedback-dialog-and-categories.md | 8 +- ...09-08-feedback-dialog-and-categories.zh.md | 8 +- ...026-09-08-shared-file-type-icons.i18n.yaml | 4 +- .../2026-09-08-shared-file-type-icons.md | 7 +- .../2026-09-08-shared-file-type-icons.zh.md | 7 +- ...tric-message-feedback-submission.i18n.yaml | 6 + ...0-symmetric-message-feedback-submission.md | 25 + ...ymmetric-message-feedback-submission.zh.md | 25 + apps/web/tests/feedback-release.e2e.ts | 13 +- apps/web/tests/message-feedback.e2e.ts | 39 +- apps/web/tests/present.e2e.ts | 67 +++ apps/web/tests/produced-files.e2e.ts | 31 ++ docs/subsystems/feedback.i18n.yaml | 4 +- docs/subsystems/feedback.md | 6 +- docs/subsystems/feedback.zh.md | 6 +- packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 6 + packages/client/ui-chat/README.zh.md | 6 + .../client/chat/TurnTailNodeView.module.css | 3 + .../tests/turn-tail-spacing.client.spec.ts | 16 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 4 +- packages/client/ui-deliverables/README.zh.md | 4 +- .../src/client/Deliverables.module.css | 21 +- .../src/client/Deliverables.tsx | 5 +- .../src/client/PresentedFileCard.tsx | 2 +- .../src/client/ProducedFiles.module.css | 2 +- .../tests/produced-files.client.spec.tsx | 13 +- .../tests/turn-tail-spacing.client.spec.ts | 24 + .../ui-message-feedback/README.i18n.yaml | 4 +- packages/client/ui-message-feedback/README.md | 11 +- .../client/ui-message-feedback/README.zh.md | 11 +- .../client/ui-message-feedback/package.json | 2 +- .../src/client/FeedbackDialog.module.css | 8 - .../src/client/FeedbackDialog.tsx | 29 +- .../src/client/MessageFeedbackActions.tsx | 46 +- .../src/client/controller.ts | 26 +- .../ui-message-feedback/src/client/dialog.ts | 25 +- .../ui-message-feedback/src/client/index.ts | 10 +- .../ui-message-feedback/src/client/locales.ts | 4 +- .../ui-message-feedback/src/client/slots.ts | 22 +- .../ui-message-feedback/src/client/surface.ts | 6 +- .../tests/browser-plugin.client.spec.tsx | 38 +- .../tests/controller.client.spec.ts | 44 +- .../tests/dialog.client.spec.ts | 40 +- .../tests/feedback-dialog.client.spec.tsx | 34 +- .../message-feedback-actions.client.spec.tsx | 109 ++-- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../client/ui-primitives/src/CodeFileIcon.tsx | 491 +----------------- .../src/code-file-icon-artwork.manifest.json | 71 +++ .../src/code-file-icon-artwork.ts | 60 +++ .../tests/code-file-icon.client.spec.tsx | 57 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 2 +- .../feedback/command-feedback/README.zh.md | 2 +- .../feedback/message-feedback/src/types.ts | 2 +- .../feedback-release.expected.json | 4 +- 63 files changed, 778 insertions(+), 778 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md create mode 100644 .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md create mode 100644 packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts create mode 100644 packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts create mode 100644 packages/client/ui-primitives/src/code-file-icon-artwork.manifest.json create mode 100644 packages/client/ui-primitives/src/code-file-icon-artwork.ts diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml index 3d3ce530c3..16c17f3405 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.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-09-08-feedback-dialog-and-categories.md -2026-09-08-feedback-dialog-and-categories.md: 02a41f08541cca85dad7cd3c2c984b5e7cd8fd6d -2026-09-08-feedback-dialog-and-categories.zh.md: 40d83360e52bff569120820b78959a7cc3f4db4f +2026-09-08-feedback-dialog-and-categories.md: ee656946a9e5d2921a02a1b642041e5965661f31 +2026-09-08-feedback-dialog-and-categories.zh.md: ef2b2d4670aad12398125925dd1e1a7e7c10a5f0 diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md index 02a41f0854..ee656946a9 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md @@ -12,9 +12,9 @@ The Web client had two disconnected feedback paths with no visible outcome. `/fe `command-feedback` owns the category taxonomy as the `FeedbackCategory` union and the `FEEDBACK_CATEGORIES` tuple in its client-safe `./types` export, and `feedback/record` becomes `{ text?, category? }`: blank text is recorded as absent, and an entry with neither member still records, because the log delivery that the feedback authorizes is the content. The same package publishes the `sessionFeedback.record` Remote through `TypertRemoteService`, resolving the live Session by id and calling the existing `recordFeedback` producer, so the dialog records the same event as the command without command bookkeeping. `message-feedback` adds the optional `category` to `MessageFeedbackItem` and `MessageFeedbackPutRequest`, validates stored values against the tuple, and counts a category change as a material edit. -`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts a negative judgment with the dialog's category and note through the message controller, the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind this PR adds to `CommandUiSpec`, a bare invocation that consumes the trigger token and runs a client callback without submitting anything. Dislike opens the same dialog for the message. Like calls `toggle`, which now reports the rating it committed, so the row acknowledges a recorded Like and stays silent on a retraction. The note popover, `clearNote`, and `clear` are removed: the dialog is the only note editor, a rating switch stores the bare judgment, and clicking a recorded rating retracts it. +`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts its selected judgment with the dialog's category and note through the message controller, while the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind in `CommandUiSpec`, which consumes the trigger token and runs a client callback without submitting anything. The later [symmetric message feedback submission](2026-09-10-symmetric-message-feedback-submission.md) decision owns the rating entry rule: either unrecorded rating opens the dialog, while clicking the recorded rating retracts it. The note popover, `clearNote`, and `clear` remain absent because the dialog is the only note editor. -The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with the code. +The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with its draft and a warning toast presents the localized failure. ## Alternatives considered @@ -24,9 +24,9 @@ The dialog is the shared Modal card at the design's width; the design's checkbox **Keep the note popover beside the dialog.** Two editors for one note with different reachability would leave the row two-line at some widths, the defect the popover was introduced to avoid, and the design shows only the thumbs. -**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves the Like path and the dialog path alike. +**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves both message-rating paths and the Session dialog. -**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; PR #3745 introduces the same `action` kind for its File row, so whichever lands second keeps one definition. +**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; the File row uses the same `action` kind, so one definition serves both entries. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md index 40d83360e5..ef2b2d4670 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md @@ -12,9 +12,9 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。` `command-feedback` 在其客户端可用的 `./types` 导出中以 `FeedbackCategory` 联合类型与 `FEEDBACK_CATEGORIES` 元组拥有分类表,`feedback/record` 变为 `{ text?, category? }`:空白文本记为缺省,两个成员都没有的条目仍会记录,因为反馈所授权的日志投递本身就是内容。同一个包通过 `TypertRemoteService` 发布 `sessionFeedback.record` Remote,按 id 找到 live Session 后调用已有的 `recordFeedback` 生产方,因此弹窗记录的是与命令相同的事件,只是没有命令簿记。`message-feedback` 给 `MessageFeedbackItem` 与 `MessageFeedbackPutRequest` 加上可选 `category`,按元组校验已存值,并把分类变化算作实质编辑。 -`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的差评,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用本 PR 给 `CommandUiSpec` 新增的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。点踩为消息打开同一个弹窗。点赞调用 `toggle`,它现在会报告自己提交的评分,因此该行只对记录成功的点赞做确认,撤回时保持沉默。备注浮层、`clearNote` 与 `clear` 被移除:弹窗是唯一的备注编辑器,切换评分只存判断本身,再次点击已记录的评分即撤回。 +`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的所选评分,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用 `CommandUiSpec` 中的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。后续的[对称消息反馈提交](2026-09-10-symmetric-message-feedback-submission.zh.md)决策拥有评分入口规则:任一未记录的评分都会打开弹窗,再次点击已记录的评分则撤回。备注浮层、`clearNote` 与 `clear` 继续保持移除,因为弹窗是唯一的备注编辑器。 -弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗带着失败码保持打开。 +弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗保留草稿并通过警告 toast 展示本地化错误。 ## 考虑过的替代方案 @@ -24,9 +24,9 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。` **在弹窗之外保留备注浮层。** 同一条备注有两个可达性不同的编辑器,会让该行在某些宽度下变成两行,正是当初引入浮层要避免的缺陷,而且设计稿只有两个拇指。 -**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务点赞路径与弹窗路径。 +**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务两种消息评分路径与 Session 弹窗。 -**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;PR #3745 为它的「文件」行引入了同一个 `action` 种类,后合并的一方保留一份定义即可。 +**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;「文件」行使用同一个 `action` 种类,一份定义即可服务两个条目。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index bf23295de5..bf3876efca 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.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-09-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: f8c87cb3259f757483a6658d79306d3f02d90488 -2026-09-08-shared-file-type-icons.zh.md: ea115cd63b3144ded2c99f818bb9364eea8adfef +2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f +2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index f8c87cb325..1e3fe72679 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -14,11 +14,11 @@ Client feature plugins can share React components only through `@deepseek-ai/dsh `FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. -Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. `LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. -Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. ## Alternatives considered @@ -32,12 +32,13 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac ## Testing -The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, every supplied technology SVG, instance-safe gradient ids, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. ## Consequences - Client packages use one filename parser and one detailed file-type table instead of importing or recreating feature-local logic. - A new suffix joins the detailed table only when an existing glyph truthfully represents it. If its link category differs from the current adapter, the change must also decide whether the 14px link appearance changes. - Code and configuration artwork preserves its embedded palette and does not accept the traditional `--dsh-file-type-icon-color` override. +- The fixed 48-entry artwork table adds about 35 kB uncompressed and 17 kB gzip to the shared browser bundle; adding categories must justify that static baseline cost. - The primitive owns no copy but does own the default file-type palette. Consumers continue to own accessible labels and surrounding text, and may replace the category color through `--dsh-file-type-icon-color`. - The detailed category names describe presentation, not MIME validation. A suffix is a display hint and does not establish file contents or trust. diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index ea115cd63b..fd1f38ebb3 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -14,11 +14,11 @@ Status: implemented `FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 -已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 `LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 -附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 ## 备选方案 @@ -32,12 +32,13 @@ Status: implemented ## 测试 -`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、每一份技术 SVG、实例安全的渐变 id、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 ## 后果 - 客户端包使用一个文件名解析器与一份精细文件类型表,不再 import 或重新实现功能包本地逻辑。 - 新后缀只在已有图形能准确表达它时加入精细表。若它的链接类别与当前适配不同,这次改动还必须决定是否改变 14px 链接外观。 - 代码与配置图稿保留内嵌调色板,不接受传统图形的 `--dsh-file-type-icon-color` 覆盖。 +- 固定的 48 项图稿表为共享浏览器 bundle 增加约 35 kB 未压缩体积和 17 kB gzip 体积;新增类别必须证明这份静态基线成本是必要的。 - primitive 不拥有文案,但拥有默认文件类型调色板。消费方继续拥有无障碍 label 与周围文字,并可通过 `--dsh-file-type-icon-color` 替换分类颜色。 - 精细类别名称描述展示意图,不是 MIME 校验。后缀只是展示提示,不能证明文件内容或可信度。 diff --git a/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml new file mode 100644 index 0000000000..bc63e89dd9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.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-09-10-symmetric-message-feedback-submission.md +2026-09-10-symmetric-message-feedback-submission.md: 51c754dbd3ecc566e15ec1b7a276fc2a3cf7c551 +2026-09-10-symmetric-message-feedback-submission.zh.md: 0ba5d1a1c53f08b76c5bbc32b84687c3dcd38ec2 diff --git a/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md new file mode 100644 index 0000000000..51c754dbd3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md @@ -0,0 +1,25 @@ +# Agent Note: Symmetric message feedback submission + +Status: implemented + +English | [中文](2026-09-10-symmetric-message-feedback-submission.zh.md) + +## Problem + +The assistant-message rating controls used different commit points. Like recorded a positive rating immediately, while Dislike opened the feedback dialog and recorded only after Submit. The asymmetry made an accidental Like durable before confirmation and prevented positive feedback from carrying the same optional category and description as negative feedback. + +## Decision + +The [feedback dialog and categories](2026-09-08-feedback-dialog-and-categories.md) decision owns the shared form and durable taxonomy. Both unrecorded ratings open the shared feedback dialog and record only after Submit. A message `FeedbackDialogTarget` carries the selected `positive` or `negative` rating, and `FeedbackSurface` passes that rating with the dialog entry to `MessageFeedbackController.rate`. The action row reads the committed item before either action: clicking its current rating calls the injected `retract` operation, while clicking an absent or opposite rating opens the dialog. `retract` rechecks the committed rating inside the controller's serialized mutation queue and becomes a no-op after a concurrent change, so it cannot turn stale UI intent into a bare rating put. The dialog remains optional-input: submitting without a category or description records the selected rating and raises the acknowledgement toast; dismissing it records nothing. + +## Alternatives considered + +**Keep Like as an immediate action.** This preserves one fewer click for positive feedback, but keeps two submission models beside each other and prevents positive reports from carrying context. + +**Require the dialog to retract a recorded rating.** Retraction has no category or description to collect, and an extra confirmation would make the existing undo action less direct. + +**Create separate positive and negative forms.** The fields, validation, failures, and acknowledgement are identical; carrying the rating in the existing target keeps one draft and submission lifecycle. + +## Consequences + +Neither rating creates a `feedback/message-put` event until the user submits the dialog. Positive and negative records can both include a category and note, while clicking the active rating continues to create `feedback/message-delete` without opening the dialog. Unit coverage pins the shared action path and target routing, and the keyless Web scenarios submit both ratings through the real dialog before checking durable events and telemetry release. diff --git a/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md new file mode 100644 index 0000000000..0ba5d1a1c5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 对称消息反馈提交 + +Status: implemented + +[English](2026-09-10-symmetric-message-feedback-submission.md) | 中文 + +## 问题 + +助手消息的两个评分控件使用不同的提交时点。点赞会立即记录正面评分,点踩则先打开反馈弹窗,仅在用户提交后记录。这种不对称会让误触点赞在确认前就持久化,也让正面反馈无法携带与负面反馈相同的可选分类和描述。 + +## 决策 + +[反馈弹窗与分类](2026-09-08-feedback-dialog-and-categories.zh.md)决策负责共用表单和持久化分类表。两种未记录的评分都打开共用反馈弹窗,并且只在提交后记录。消息 `FeedbackDialogTarget` 携带所选的 `positive` 或 `negative` 评分,`FeedbackSurface` 将该评分与弹窗条目一起传给 `MessageFeedbackController.rate`。动作行会在任一操作前读取已提交条目:点击当前评分会调用注入的 `retract` 操作,点击未记录或相反评分则打开弹窗。`retract` 会在控制器的串行变更队列内重新检查已提交评分,并在并发变更后变为无操作,因此不会把陈旧的 UI 意图转成裸评分 put。弹窗的输入仍可全部留空:不选分类也不填描述时提交会记录所选评分并弹出确认 toast;关闭弹窗不会记录任何内容。 + +## 考虑过的替代方案 + +**保留点赞立即提交。** 这能让正面反馈少一次点击,但会让并列的两个评分继续使用不同提交模式,也无法让正面反馈携带上下文。 + +**撤回已记录评分也必须经过弹窗。** 撤回没有需要收集的分类或描述,多一次确认会让现有撤销操作变得不够直接。 + +**分别创建正面和负面表单。** 两者的字段、校验、失败处理与确认完全相同;在现有目标中携带评分即可共用一套草稿与提交生命周期。 + +## 后果 + +用户提交弹窗前,两种评分都不会创建 `feedback/message-put` 事件。正面与负面记录都可以包含分类和备注,而点击当前评分仍会直接创建 `feedback/message-delete`,无需打开弹窗。单元测试固定共用动作路径与目标路由,无密钥 Web 场景则通过真实弹窗提交两种评分,再检查持久事件与遥测投递。 diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index fdb1330697..4643da9cf8 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -218,12 +218,17 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', const like = page.getByRole('button', { name: 'Good response' }) await like.hover() await like.click() + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + await dialog.getByRole('button', { name: 'Instruction understanding and following', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Clear and complete.') + expect(captured()).toHaveLength(releasedCount) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count()).toBe(0) const rated = page.getByRole('button', { name: 'Remove rating' }) await expect.poll(() => rated.getAttribute('aria-pressed')).toBe('true') await expectFeedbackRelease('feedback/message-put', 1) - // Dislike collects the category and note in the dialog; typing releases nothing. + // The second rating uses the same dialog; typing releases nothing. await page.getByRole('button', { name: 'Bad response' }).click() - const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) await dialog.getByRole('button', { name: 'Task result', exact: true }).click() await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Read both files before answering.') expect(captured()).toHaveLength(releasedCount) @@ -242,7 +247,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', { data: { text: 'the second remark' } }, ]) expect(events.filter(event => event.type === 'feedback/message-put')).toMatchObject([ - { data: { sessionId, item: { rating: 'positive' } } }, + { data: { sessionId, item: { rating: 'positive', note: 'Clear and complete.', category: 'instruction-following' } } }, { data: { sessionId, item: { rating: 'negative', note: 'Read both files before answering.', category: 'task-result' } } }, ]) expect(events.filter(event => event.type === 'feedback/message-delete')).toMatchObject([{ data: { sessionId } }]) @@ -251,7 +256,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', expect(captured()).toHaveLength(releasedCount) const wire = uploads.join('\n') for (const text of ['the diff view is unreadable', 'the second remark', - 'Read both files before answering.']) expect(wire).toContain(text) + 'Clear and complete.', 'Read both files before answering.']) expect(wire).toContain(text) const feedback = events.flatMap>((event) => { switch (event.type) { case 'feedback/record': return [{ type: event.type, text: event.data.text }] diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index 8677beb6d9..df89180c18 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -1,8 +1,8 @@ // Keyless browser regression for durable per-message feedback. Cold-seeds a -// settled two-turn transcript (zero model calls), likes one assistant message -// and sees the acknowledgement, replaces the Like through the Dislike dialog -// with a category and a note, proves the judgment survives a full page reload -// from the Host's canonical log, then retracts it. +// settled two-turn transcript (zero model calls), records a Like through the +// feedback dialog, replaces it through the same dialog with a Dislike, proves +// the judgment survives a full page reload from the Host's canonical log, then +// retracts it. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -20,6 +20,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'message-feedback-web-e2e' +const POSITIVE_NOTE = 'Clear and complete.' const NOTE = 'Read both files before answering.' describe('web e2e: durable per-message feedback', () => { @@ -58,7 +59,7 @@ describe('web e2e: durable per-message feedback', () => { await sessionRow.click() } - it.skipIf(MODE === 'record')('persists a Dislike with its category and note across a reload, then retracts', async () => { + it.skipIf(MODE === 'record')('submits both ratings through the dialog, persists the Dislike, then retracts it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback')) await openSeededSession() @@ -71,21 +72,29 @@ describe('web e2e: durable per-message feedback', () => { await like.scrollIntoViewIfNeeded() await like.hover() await like.click() - // A Like records at once and is acknowledged; a recorded rating relabels - // the button to what the next click would do. + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Stability and speed', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(POSITIVE_NOTE) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) await page.getByRole('alert').filter({ hasText: 'Thanks for your feedback' }).waitFor({ timeout: 10_000 }) const rated = page.getByRole('button', { name: 'Remove rating' }).first() await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true') - // Dislike opens the Session's feedback dialog; its submission replaces - // the Like with a negative judgment carrying the category and note. + // The same dialog records a negative judgment with its own category and + // note, replacing the positive judgment only after submission. await page.getByRole('button', { name: 'Bad response' }).first().click() - const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) await dialog.waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByRole('textbox', { name: 'Feedback details' }).getAttribute('placeholder')) .toBe('Add details to help us improve. Your submission will include the current conversation log.') await dialog.getByRole('button', { name: 'Task result', exact: true }).click() - await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(NOTE) + const details = dialog.getByRole('textbox', { name: 'Feedback details' }) + await details.fill('x'.repeat(8193)) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await page.getByRole('alert').filter({ hasText: 'The description is too long' }).waitFor({ timeout: 10_000 }) + expect(await dialog.count()).toBe(1) + await details.fill(NOTE) await dialog.getByRole('button', { name: 'Submit', exact: true }).click() await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) await expect.poll(() => rated.getAttribute('aria-label'), { timeout: 10_000 }).toBe('Remove rating') @@ -116,9 +125,11 @@ describe('web e2e: durable per-message feedback', () => { await expect.poll(() => cold.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false') const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') - const put = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put').at(-1) - expect(put?.type === 'feedback/message-put' ? put.data.item : undefined) - .toMatchObject({ rating: 'negative', note: NOTE, category: 'task-result' }) + const puts = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put') + expect(puts.map(event => event.type === 'feedback/message-put' ? event.data.item : undefined)).toMatchObject([ + { rating: 'positive', note: POSITIVE_NOTE, category: 'service-stability' }, + { rating: 'negative', note: NOTE, category: 'task-result' }, + ]) // Re-clicking the active rating retracts it, and the note goes with it. await restored.click() diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index d1b4bc713c..38e8f16cc9 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -176,6 +176,73 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(await failed.innerText()).toContain('Delivery failed') expect(await delivered.innerText()).toContain('Delivered') await page.locator('[data-turn-process]').click() + const geometry = await page.evaluate(() => { + const requiredElement = (value: T | null | undefined, name: string): T => { + if (value === null || value === undefined) throw new Error(`present layout is missing ${name}`) + return value + } + const answer = requiredElement( + [...document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .find(element => element.textContent?.includes('PRESENT_DONE')), + 'final answer', + ) + const presentedGrid = requiredElement( + document.querySelector('[data-presented-files-row]'), + 'presented grid', + ) + const presentedRoot = requiredElement(presentedGrid.parentElement, 'presented root') + const turnTail = requiredElement(presentedRoot.closest('[data-turn-tail]'), 'turn tail') + const actions = requiredElement( + turnTail.querySelector('button[aria-label="Copy"]')?.parentElement, + 'action row', + ) + const cards = [...presentedGrid.querySelectorAll('[data-presented-file]')] + const report = requiredElement( + cards.find(card => card.textContent?.includes('report.txt')), + 'report card', + ) + const title = requiredElement( + report.querySelector('span[title="report.txt"]') + ?? [...report.querySelectorAll('span')] + .find(element => element.textContent === 'report.txt'), + 'report title', + ) + const description = requiredElement(report.querySelector('span[role="status"]'), 'report status') + const open = requiredElement( + report.querySelector('button[aria-label="Open report.txt in sidebar"]'), + 'report open action', + ) + const icon = requiredElement(report.querySelector('svg'), 'report icon') + const secondCard = requiredElement(cards[1], 'second card') + const answerRect = answer.getBoundingClientRect() + const presentedRect = presentedRoot.getBoundingClientRect() + const actionsRect = actions.getBoundingClientRect() + const firstCard = report.getBoundingClientRect() + const secondCardRect = secondCard.getBoundingClientRect() + const gridStyle = getComputedStyle(presentedGrid) + return { + answerToPresented: presentedRect.top - answerRect.bottom, + presentedToActions: actionsRect.top - presentedRect.bottom, + cardHeight: firstCard.height, + cardColumnGap: secondCardRect.left - firstCard.right, + gridColumnGap: gridStyle.columnGap, + gridRowGap: gridStyle.rowGap, + iconWidth: icon.getAttribute('width'), + titleFontSize: getComputedStyle(title).fontSize, + descriptionFontSize: getComputedStyle(description).fontSize, + openFontSize: getComputedStyle(open).fontSize, + } + }) + expect(geometry.answerToPresented).toBeCloseTo(20, 1) + expect(geometry.presentedToActions).toBeCloseTo(20, 1) + expect(geometry.cardHeight).toBeCloseTo(60, 1) + expect(geometry.cardColumnGap).toBeCloseTo(10, 1) + expect(geometry.gridColumnGap).toBe('10px') + expect(geometry.gridRowGap).toBe('10px') + expect(geometry.iconWidth).toBe('20') + expect(geometry.titleFontSize).toBe('13px') + expect(geometry.descriptionFontSize).toBe('10px') + expect(geometry.openFontSize).toBe('12px') await page.setViewportSize({ width: 480, height: 900 }) const row = page.locator('[data-presented-files-row]') await row.scrollIntoViewIfNeeded() diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 38628b0e8c..8ee9644030 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -171,6 +171,37 @@ describe('web e2e: a finished turn ends with the files it produced', () => { expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0) expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1) + const turnSpacing = await page.evaluate((done) => { + const requiredElement = (value: T | null | undefined, name: string): T => { + if (value === null || value === undefined) throw new Error(`produced-file layout is missing ${name}`) + return value + } + const answer = requiredElement( + [...document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .find(element => element.textContent?.includes(done)), + 'final answer', + ) + const producedRow = requiredElement( + document.querySelector('[data-produced-files-row]'), + 'produced row', + ) + const producedRoot = requiredElement(producedRow.parentElement?.parentElement, 'produced root') + const turnTail = requiredElement(producedRoot.closest('[data-turn-tail]'), 'turn tail') + const actions = requiredElement( + turnTail.querySelector('button[aria-label="Copy"]')?.parentElement, + 'action row', + ) + const answerRect = answer.getBoundingClientRect() + const producedRect = producedRoot.getBoundingClientRect() + const actionsRect = actions.getBoundingClientRect() + return { + answerToProduced: producedRect.top - answerRect.bottom, + producedToActions: actionsRect.top - producedRect.bottom, + } + }, DONE) + expect(turnSpacing.answerToProduced).toBeCloseTo(20, 1) + expect(turnSpacing.producedToActions).toBeCloseTo(20, 1) + const tops = await row.locator(':scope > *:visible').evaluateAll(elements => elements.map(element => element.getBoundingClientRect().top)) expect(new Set(tops.map(top => Math.round(top))).size).toBe(1) diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml index ba31e1ea1d..621fcf80ff 100644 --- a/docs/subsystems/feedback.i18n.yaml +++ b/docs/subsystems/feedback.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/feedback.md -feedback.md: 2c6728d97a6a62735e4ef8b97983f08060d4a2c5 -feedback.zh.md: db9f03cb82685763bc3b424c0f6b29738f59ec99 +feedback.md: e153047bcc260bcd06847b42eb6b983ac9247d03 +feedback.zh.md: 60263ea4509c0b10ed9f57d13f1564ced1e066c7 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md index 2c6728d97a..e153047bcc 100644 --- a/docs/subsystems/feedback.md +++ b/docs/subsystems/feedback.md @@ -27,7 +27,7 @@ interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string - /** Category the human filed a negative judgment under. */ + /** Category the human filed the judgment under. */ readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion @@ -298,9 +298,9 @@ When explicitly enabled, [`session-log-deepseek`](../../packages/session/session The controls are the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` list slot, which `ui-conversation` declares and renders inside the finalized assistant message's IconActions row. `AssistantMessageNode` carries the optional `messageId` from the `assistant/message` event. The field is absent on interruption-frozen partials, and the render site skips the slot when it is absent. The strip renders once per turn, on the closing assistant message: the Host accepts every append-origin step message as a target, but earlier steps of a multi-step turn render tool rows rather than a rateable body, so the UI exposes a narrower set than the Host contract allows. -One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. A `connection/reset` refreshes only Sessions already read. +One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. The injected `retract` operation rechecks the committed rating inside that queue and becomes a no-op after a concurrent change, so stale UI cannot bypass the dialog by recording a bare rating. A `connection/reset` refreshes only Sessions already read. -Like records the bare positive judgment at once and shows the acknowledgement toast. Dislike opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts a negative judgment carrying the chosen category and the trimmed description, or neither. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback ` keeps the Host command path. Clicking a recorded rating retracts it. +Either unrecorded rating opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts the selected judgment carrying the chosen category and the trimmed description, or neither; success closes the dialog and shows the acknowledgement toast, while failure keeps the dialog and draft open and shows a warning toast. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback ` keeps the Host command path. Clicking a recorded rating retracts it without opening the dialog. ## Boundaries and limitations diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md index db9f03cb82..60263ea450 100644 --- a/docs/subsystems/feedback.zh.md +++ b/docs/subsystems/feedback.zh.md @@ -27,7 +27,7 @@ interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string - /** Category the human filed a negative judgment under. */ + /** Category the human filed the judgment under. */ readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion @@ -298,9 +298,9 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s 控件是 `conversation.chat.assistant-actions` list slot 的 `feedback` 条目(order 10),该 slot 由 `ui-conversation` 声明,并渲染在已定稿助手消息的 IconActions 行内。`AssistantMessageNode` 携带来自 `assistant/message` 事件的可选 `messageId`。被中断冻结的部分输出没有该字段,渲染点在字段缺失时跳过该 slot。该操作栏每个 Turn 渲染一次,位于收尾的助手消息上:Host 接受每条 append-origin 步骤消息作为目标,但多步骤 Turn 中较早的步骤渲染的是工具行而非可评分正文,因此 UI 暴露的范围比 Host 约定允许的更窄。 -每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。`connection/reset` 只刷新已读取过的 Session。 +每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。注入的 `retract` 操作会在该队列内重新检查已提交评分,并在并发变更后变为无操作,因此陈旧 UI 无法绕过弹窗记录裸评分。`connection/reset` 只刷新已读取过的 Session。 -点赞立即记录不带备注的好评并显示确认 toast。点踩打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 一条差评,带上所选分类与去除首尾空白的描述,两者也可都不带。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback ` 仍走宿主命令路径。再次点击已记录的评分会撤回它。 +任一未记录的评分都会打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 所选评分,带上所选分类与去除首尾空白的描述,两者也可都不带;成功会关闭弹窗并显示确认 toast,失败则保留弹窗与草稿并显示警告 toast。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback ` 仍走宿主命令路径。再次点击已记录的评分会直接撤回,不打开弹窗。 ## 边界与限制 diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 2a62fbbd35..ed24447f76 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/README.md -README.md: cf4b6aceb320d942d695fb1dce53c2e67e53a969 -README.zh.md: 1f895de009e82688ede9b3a83807bae9689ccb80 +README.md: 0894d4e0ef5e45876a58abea40cdad840e7dfacd +README.zh.md: 5604f95acbcac88a157981b936791c9284a858bb diff --git a/packages/client/README.md b/packages/client/README.md index cf4b6aceb3..0894d4e0ef 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -71,7 +71,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | -| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind Dislike and `/feedback` | — | +| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — | | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 1f895de009..5604f95acb 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -71,7 +71,7 @@ kind: "package-group" | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页 | — | | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | -| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点踩与 `/feedback` 背后的反馈弹窗 | — | +| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — | | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开 workspace 目录的会话头部分体按钮 | — | diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4db27677d0..525de1dcc3 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8 -README.zh.md: 4dd06a16b19b14cacc0ded603cc1cf8869dd1a43 +README.md: cbf8fb88670913fac83154f5450e452674b2ed65 +README.zh.md: 9ee73474b2848ef19252bb26c5f0a215e323b413 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 860dcb9eeb..cbf8fb8867 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -16,6 +16,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner - [System prompt row](#system-prompt-row) - [Turn token usage](#turn-token-usage) +- [Completed-turn footer](#completed-turn-footer) - [Turn Process Folding](#turn-process-folding) - [Scroll ownership](#scroll-ownership) - [Model Experience](#model-experience) @@ -34,6 +35,11 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total. + +## Completed-turn footer + +The completed-turn action footer starts 20px below the preceding prose or extension content. + ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 4dd06a16b1..9ee73474b2 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -16,6 +16,7 @@ kind: "package-reference" - [系统提示词行](#system-prompt-row) - [轮次 token 用量](#turn-token-usage) +- [已完成轮次的页脚](#completed-turn-footer) - [轮次过程折叠](#turn-process-folding) - [滚动归属](#scroll-ownership) - [模型体验](#model-experience) @@ -36,6 +37,11 @@ kind: "package-reference" 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成 Turn 才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。 + +## 已完成轮次的页脚 + +已完成轮次的操作页脚位于前方正文或扩展内容下方 20px。 + ----- diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css index 831e6e212b..bfa386d31c 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css @@ -5,5 +5,8 @@ } .actions { + /* With tail content, this adds to the root gap; without it, the Chat flow's + preceding-item offset supplies the other 16px. */ + margin-top: 4px; margin-left: -6px; } diff --git a/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts new file mode 100644 index 0000000000..1955fd41cd --- /dev/null +++ b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts @@ -0,0 +1,16 @@ +/** CSS checks for the completed-turn footer's 20px content spacing. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const read = (name: string): string => + readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8') + +describe('completed-turn spacing', () => { + it('combines the flow and footer offsets around turn-tail content', () => { + expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 16px\)/) + const tail = read('TurnTailNodeView.module.css') + expect(tail).toMatch(/\.root\s*\{[^}]*gap:\s*16px/s) + expect(tail).toMatch(/\.actions\s*\{[^}]*margin-top:\s*4px/s) + }) +}) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 74d4c89ee7..d044cf5fea 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: 857b71c5e0f3e4ba0dac0e8453492684c76ca0e4 -README.zh.md: 8e801a164c8a60de4aea8ff6a224e4823e2d3ccc +README.md: aaf18126e2ebbc0beabe7781b0c5ad30049769ee +README.zh.md: 445401890639c936d4300f85ff9a44b4fd8236d1 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 857b71c5e0..aaf18126e2 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,13 +30,13 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row -The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. It uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. +The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the row, and the action footer starts 20px below the last file section. The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. ### Inline-code links diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 8e801a164c..4454018906 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,13 +30,13 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 -“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 +“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于该行下方 16px,操作页脚位于最后一个文件区块下方 20px。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 ### 行内代码链接 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index 5125edd381..c78725bdcb 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -1,27 +1,30 @@ /** Delivery cards use one full-width row or a two-column, four-card summary. */ -.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 16px; } +.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 4px; } +/* The turn-tail gap alone separates adjacent file sections. The default top + margin remains when deliveries are the first file section after prose. */ +.root[data-after-produced-files='true'] { margin-top: 0; } :global(body[data-ds-dark-theme]) .root { --deliverable-fill: var(--dsw-static-neutral-850); --deliverable-hover: var(--dsw-static-neutral-800); } .hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); } -.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; min-width: 0; } +.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; min-width: 0; } .presented[data-single='true'] { grid-template-columns: minmax(0, 1fr); } -.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 72px; padding: 12px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; } +.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 60px; padding: 8px 10px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; } .file:hover { background: var(--deliverable-hover); } .cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } .cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } -.fileIcon { position: relative; z-index: 2; flex: 0 0 auto; display: grid; place-items: center; width: 48px; height: 48px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--deliverable-fill); color: var(--dsw-alias-link); } +.fileIcon { position: relative; z-index: 2; box-sizing: border-box; flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 40px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--deliverable-fill); color: var(--dsw-alias-link); } .fileBody { position: relative; z-index: 2; display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; pointer-events: none; } .details { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; } -.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; line-height: 22px; } -.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 12px; font-weight: 400; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; } +.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } +.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 10px; font-weight: 400; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; } .description[data-error='true'] { color: var(--dsw-alias-state-error-primary); } .previewHint { display: none; } .file:hover .secondaryText { display: none; } .file:hover .previewHint { display: inline; } -.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 32px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 12px; background: var(--dsw-alias-button-floating-fill); } +.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 28px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); } .menuAnchor { align-self: stretch; } .open, .chevron { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dsw-alias-label-primary); cursor: pointer; font-family: var(--dsw-font-family); } -.open { padding: 5px 10px; font-size: 14px; line-height: 22px; } -.chevron { padding: 5px 6px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); } +.open { padding: 4px 8px; font-size: 12px; line-height: 18px; } +.chevron { padding: 4px 5px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); } .open:hover, .open:focus-visible, .chevron:hover:not(:disabled), .chevron:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } .chevron:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; } .menuActionIcon { display: block; width: 16px; height: 16px; } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index dac421a577..6099cfdf72 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -58,7 +58,10 @@ export function Deliverables({ matched, openFile, t, sessionId, useSessions, ope }, [matched.presented.length, host, reloadPresentedHost]) return <> {matched.produced.length > 0 && } - {matched.presented.length > 0 &&
+ {matched.presented.length > 0 &&
0 || undefined} + > {host === 'error' &&
{t('presented.hostError')} diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index ef848827e8..5466473999 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -51,7 +51,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, return