From 92dd231ce4b24d77a2259120a08bcb833f8cc315 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 4 Aug 2026 14:14:12 +0800 Subject: [PATCH] =?UTF-8?q?polish(web):=20=E6=89=93=E7=A3=A8=20Web=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=95=8C=E9=9D=A2=E7=9A=84=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E3=80=81=E8=87=AA=E9=80=82=E5=BA=94=E4=B8=8E=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 统一会话列宽度轴:新增 --dsh-chat-content-width(748px),输入框、todo、goal、queue、approval、plan review、ask question 等容器的宽度与边距全部由该变量推导,消除各面板之间的像素漂移,窄视口下的边缘留白也保持一致。 2. 输入框自适应与细节:控制行改为容器查询(460px 阈值以下权限选择器只显示图标+下拉,隐藏文字);卡片圆角 20→22、行内边距调整并整体下移 2px(发送按钮除外);permission/model 触发器统一 24px 圆角;Plan 与 Read Only 间距 +8。 3. 修复浮层菜单溢出:slash 菜单与命令弹层钳制到输入卡片宽度,超长行以省略号截断;Tooltip 增加 12px 视口边缘安全距离。 4. 增加与替换图标:Add provider 改用输入框同款加号图标(IconPlusOutline16),统一图标尺寸与字号。 5. 统一 Settings → Models 组件:补齐按钮 hover 态、select 下拉箭头不再贴边、标题区与 provider 卡片间距 +12。 6. 侧边栏交互:add workspace / group by / create session / 收起侧边栏四个图标按钮增加 500ms 延迟 tooltip(前两个向下弹出);展开态的 New Session 不再重复显示 tooltip;侧边栏窄屏自适应收起逻辑微调。 7. 其他:hero 区 workspace 徽章右移对齐;附带 Agent Note(中英双语)记录共享宽度轴与容器查询的设计取舍。 --- ...4-web-composer-shared-width-axis.i18n.yaml | 6 + ...26-08-04-web-composer-shared-width-axis.md | 31 ++++ ...08-04-web-composer-shared-width-axis.zh.md | 31 ++++ .../src/client/PopupSelectView.module.css | 11 +- .../src/client/chat/ChatView.module.css | 13 +- .../src/client/chat/MessageIconActions.tsx | 21 ++- .../src/client/chat/StatsLine.module.css | 16 +- .../ui-conversation/src/client/locales.ts | 10 +- .../src/client/queue/QueueDock.module.css | 31 ++-- .../src/client/queue/QueueDock.tsx | 144 ++++++++++-------- .../client/skeleton/ApprovalPanel.module.css | 40 +---- .../src/client/skeleton/ApprovalPanel.tsx | 9 +- .../skeleton/ConversationRoot.module.css | 33 ++-- .../src/client/skeleton/InputBar.module.css | 42 +++-- .../src/client/skeleton/InputBar.tsx | 68 +++++---- .../skeleton/PermissionSelect.module.css | 32 +++- .../src/client/skeleton/PermissionSelect.tsx | 53 ++++++- .../src/client/skeleton/TodoPanel.module.css | 18 ++- .../src/client/skeleton/TodoPanel.tsx | 14 +- .../tests/assembly-surfaces.spec.tsx | 2 +- .../tests/chat-branch-tails.spec.tsx | 29 ++++ .../ui-conversation/tests/todo-panel.spec.tsx | 24 ++- .../ui-goal/src/client/GoalBar.module.css | 13 +- .../client/ui-goal/src/client/GoalBar.tsx | 94 +++++++----- .../client/ui-layout/src/client/AppFrame.tsx | 25 ++- .../client/ui-layout/src/client/columns.ts | 7 + .../client/ui-layout/src/client/stores.ts | 31 +++- .../client/ui-layout/tests/app-frame.spec.tsx | 44 ++++++ .../ui-layout/tests/layout-store.spec.ts | 30 +++- .../client/ui-layout/tests/service.spec.ts | 1 + .../src/client/ModelSelect.module.css | 3 +- .../src/client/ModelsSection.module.css | 41 ++++- .../ui-models/src/client/ModelsSection.tsx | 8 +- .../ui-models/src/client/ProviderEditor.tsx | 2 +- .../ui-models/tests/components.spec.tsx | 14 +- .../ui-primitives/src/Tooltip.module.css | 21 ++- packages/client/ui-primitives/src/Tooltip.tsx | 30 +++- .../client/ui-primitives/src/icons/index.tsx | 45 +++++- .../client/ui-primitives/tests/icons.spec.tsx | 4 +- .../ui-primitives/tests/tooltip.spec.tsx | 64 +++++++- .../src/client/PlanReviewPanel.module.css | 18 ++- .../src/client/PlanReviewPanel.tsx | 6 +- .../src/client/QuestionComposer.module.css | 9 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 5 +- .../ui-slash/src/client/MenuView.module.css | 7 +- .../src/client/WorkspaceBrowser.tsx | 20 +-- 46 files changed, 893 insertions(+), 327 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.md create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.i18n.yaml new file mode 100644 index 0000000000..b35cca4b38 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.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-04-web-composer-shared-width-axis.md +2026-08-04-web-composer-shared-width-axis.md: 96cddda25bf79e9f2df7a0298039c27f316befca +2026-08-04-web-composer-shared-width-axis.zh.md: 9a9f5a513bbce8f3e97698d58ab48b0abdefb142 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.md b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.md new file mode 100644 index 0000000000..96cddda25b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.md @@ -0,0 +1,31 @@ +# Agent Note: Web composer shared width axis and control-row polish + +Status: implemented + +English | [中文](2026-08-04-web-composer-shared-width-axis.zh.md) + +## Problem + +The web conversation column sized each surface independently: the transcript column, the input card, the todo/goal/queue dock cards, and the ask-question/approval/plan-review takeover cards each carried their own hardcoded max-width (736/752/776/800px variants) and their own side paddings. The surfaces drifted a few pixels apart at full width and diverged further on narrow viewports, where some panels kept clearance from the screen edge and others went flush. Separately, the composer's control row had no adaptive behavior — on a narrow card the permission trigger's label squeezed the row — and the overlay menus anchored to the card could render wider than the card itself, painting past its right edge. + +## Decision + +One content width variable owns the whole column. `--dsh-chat-content-width` (748px) is declared on ConversationRoot's `.root` — the transcript and the composer seat are sibling subtrees, so the declaration must sit on their common ancestor for CSS custom-property inheritance to reach both. Every other geometry derives from it: the input card caps at `content + 32px` (`--dsh-composer-card-max-width`), the dock cards subtract four dock insets (4 × 8px) from the card and land back on the content width, and the takeover cards use the content width directly. The narrow-viewport invariant is expressed structurally, not numerically: content-width surfaces pad `calc(var(--dsh-composer-side-clearance) + 16px)` per side while the input card clears the bare clearance (16px), so "input card = content + 32px" holds at every viewport width, not just at the cap. + +The control row inside the card is a `container-type: inline-size` container, and the permission trigger drops its text label (keeping glyph + chevron) under a 460px container query. The query is anonymous on purpose: CSS modules hash `container-name` per module, so a name declared in InputBar's sheet can never match a query written in PermissionSelect's sheet — the two hashed names silently differ and the query never fires. Only triggers that carry a mode glyph collapse (`:has(.triggerIcon)`); a host-configured mode without one keeps its text as its sole identifier. + +Overlay menus anchored to the card (slash menu, command popupSelect) clamp to the anchor's width (`max-width: min(, 100%)`), truncating long rows with ellipses instead of overflowing the card. Tooltip bubbles keep a 12px viewport-edge safety margin in the clamp (ui-primitives Tooltip). + +## Alternatives considered + +**Keep per-surface widths and align the numbers by hand.** Rejected: the drift this change removes was exactly the residue of hand-aligned constants; any future width change would need five coordinated edits with nothing enforcing the relation. + +**Declare the variables on `.composerStack`.** Rejected after trying it: the takeover panels are siblings of the stack in the composer seat and the transcript is a different subtree entirely, so the variables never reached them; the common ancestor (`.root`) is the only correct home. + +**A named container query for the label collapse.** Rejected by measurement: CSS modules scope `container-name` per module, so the cross-module name never matched and the query was dead. The anonymous query resolves against the nearest ancestor container, which is unambiguous here (the row is the only container). + +**JS ResizeObserver for the label collapse.** Rejected: a container query is declarative, needs no listener lifecycle, and the 460px threshold is a design choice either way. + +## Consequences + +Changing the column width is now a one-line edit with the ratio relations preserved by construction, which the 736 → 748 retune during review already exercised. The cost is indirection: the widths of five surfaces are no longer readable off their own stylesheets and require following the variable chain to ConversationRoot. The container-query collapse adds the constraint that InputBar's row stays a size container; removing that declaration silently disables the permission trigger's adaptive behavior. The anonymous query also means any future second container between the row and the trigger would capture it — if that happens, the query must move or the intermediate container must be avoided. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.zh.md new file mode 100644 index 0000000000..9a9f5a513b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-composer-shared-width-axis.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Web 输入区共享宽度轴与控制行打磨 + +Status: implemented + +[English](2026-08-04-web-composer-shared-width-axis.md) | 中文 + +## Problem + +Web 会话列的各个界面各自独立设定尺寸:转录列、输入卡片、todo/goal/queue 停靠卡片、ask-question/approval/plan-review 接管卡片各自硬编码 max-width(736/752/776/800px 等变体)与各自的侧边内边距。这些界面在全宽下彼此漂移几个像素,在窄视口下偏差更大——有的面板保留了到屏幕边缘的间隙,有的却贴边。另外,输入卡片的控制行没有自适应行为——窄卡片下权限触发器的文字标签会挤压整行;锚定在卡片上的浮层菜单也可能渲染得比卡片更宽,越过其右边缘。 + +## Decision + +一个内容宽度变量拥有整列。`--dsh-chat-content-width`(748px)声明在 ConversationRoot 的 `.root` 上——转录与 composer 座位是兄弟子树,声明必须放在共同祖先上,CSS 自定义属性才能通过继承同时到达两者。其他几何全部由它推导:输入卡片上限为 `content + 32px`(`--dsh-composer-card-max-width`),停靠卡片从卡片宽度中减去四个停靠 inset(4 × 8px)正好回到内容宽度,接管卡片直接使用内容宽度。窄视口不变式以结构而非数值表达:内容宽度的界面每侧 pad `calc(var(--dsh-composer-side-clearance) + 16px)`,而输入卡片只留裸 clearance(16px),因此"输入卡片 = 内容 + 32px"在任意视口宽度下都成立,而不只是在上限处。 + +卡片内的控制行是一个 `container-type: inline-size` 容器,权限触发器在 460px 容器查询下收起文字标签(保留图标 + 下拉箭头)。查询刻意匿名:CSS modules 按模块哈希 `container-name`,InputBar 样式表里声明的名字永远无法匹配 PermissionSelect 样式表里写的查询——两个哈希后的名字悄然不同,查询永不触发。只有带模式图标的触发器才收起(`:has(.triggerIcon)`);没有图标的宿主自定义模式保留文字作为其唯一标识。 + +锚定在卡片上的浮层菜单(slash 菜单、command popupSelect)钳制到锚点宽度(`max-width: min(<设计上限>, 100%)`),过长的行以省略号截断而不是溢出卡片。Tooltip 气泡在钳制中保留 12px 的视口边缘安全距离(ui-primitives Tooltip)。 + +## Alternatives considered + +**保留各界面独立宽度,手工对齐数值。** 否决:本次改动消除的漂移正是手工对齐常量的残留;未来任何宽度调整都需要五处协同编辑,且没有任何机制强制这组关系。 + +**把变量声明在 `.composerStack` 上。** 尝试后否决:接管面板在 composer 座位中是 stack 的兄弟节点,转录更是完全不同的子树,变量根本到不了它们;共同祖先(`.root`)是唯一正确的家。 + +**用命名容器查询实现标签收起。** 经实测否决:CSS modules 按模块作用域化 `container-name`,跨模块名字永不匹配,查询是死的。匿名查询解析到最近的祖先容器,在这里没有歧义(该行是唯一的容器)。 + +**用 JS ResizeObserver 实现标签收起。** 否决:容器查询是声明式的,无需监听器生命周期,而 460px 阈值无论哪种方案都是设计选择。 + +## Consequences + +修改列宽现在是一行编辑,比例关系由构造保证——评审期间 736 → 748 的重调已经验证了这一点。代价是间接性:五个界面的宽度不再能从各自的样式表直接读出,需要沿变量链追到 ConversationRoot。容器查询收起增加了一个约束:InputBar 的行必须保持为尺寸容器;删掉那条声明会静默禁用权限触发器的自适应行为。匿名查询也意味着未来若在行与触发器之间出现第二个容器,它会截获该查询——届时查询必须迁移,或避免中间容器。 diff --git a/packages/client/ui-command/src/client/PopupSelectView.module.css b/packages/client/ui-command/src/client/PopupSelectView.module.css index 16e1e5c11d..e9a803cd4b 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.module.css +++ b/packages/client/ui-command/src/client/PopupSelectView.module.css @@ -12,7 +12,10 @@ padding: 4px; display: flex; flex-direction: column; - min-width: 220px; + min-width: min(220px, 100%); + /* Never wider than the composer card (the overlay anchor's width): long + rows truncate instead of pushing the card past the composer's edge. */ + max-width: 100%; /* Height cap: the 320px design maximum, clamped at runtime to the space * above the composer (inline max-height set in PopupSelectView.tsx). */ max-height: 320px; @@ -51,7 +54,9 @@ } .label { - flex: 1; + /* Grows (detail stays right-aligned) but never shrinks: when the row runs + out of width the detail alone truncates, keeping the name readable. */ + flex: 1 0 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -61,6 +66,8 @@ font-size: 12px; color: var(--dsw-alias-label-tertiary); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .check { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 55270946ba..182fc9e7e5 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -16,7 +16,9 @@ flex: 1 1 auto; min-height: 0; overflow-y: auto; - padding: 16px 24px; + /* Sides = composer clearance + 16px: on narrow viewports the transcript + stays exactly 32px narrower than the input card (the shared width rule). */ + padding: 16px calc(var(--dsh-composer-side-clearance) + 16px); } :global([data-conversation-scroll]) .root { @@ -31,10 +33,11 @@ min-height: auto; } -/* Message column: 736px fixed width, centered on the same axis as the - input box; the scroller itself stays full-bleed. */ +/* Message column: shared chat width (ConversationRoot --dsh-chat-content-width), + centered on the same axis as the input box (which caps at chat + 16px); the + scroller itself stays full-bleed. */ .column { - max-width: 736px; + max-width: var(--dsh-chat-content-width); width: 100%; margin: 0 auto; display: flex; @@ -151,7 +154,7 @@ height: 0; display: flex; justify-content: flex-end; - padding-right: max(0px, calc((100% - 736px) / 2)); + padding-right: max(0px, calc((100% - var(--dsh-chat-content-width)) / 2)); pointer-events: none; } diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index 6d77d4db68..92a074eef0 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,9 +1,9 @@ // Shared IconActions chrome for user, steering, and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { useCallback, useId } from 'react' +import { useCallback, useId, useState } from 'react' import { - IconBranchOutline16, IconCopyOutline16, Tooltip, + IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { formatMessageClock, writeClipboard } from './message-chrome.ts' @@ -39,9 +39,16 @@ export function MessageIconActions({ }: MessageIconActionsProps) { const day = useCalendarDay() const reasonId = useId() + // Same success chrome as CodeBlock: a short check swap after the write, + // gated so re-clicks during the window neither re-copy nor stack timers. + const [copied, setCopied] = useState(false) const onCopy = useCallback(() => { - void writeClipboard(text) - }, [text]) + if (copied) return + void writeClipboard(text).then(() => { + setCopied(true) + window.setTimeout(() => { setCopied(false) }, 1000) + }) + }, [copied, text]) const clockEl = time === undefined ? null : ( {formatMessageClock(time, t, day)} @@ -50,9 +57,9 @@ export function MessageIconActions({ return (
{clock === 'start' ? clockEl : null} - - {showBranch && onBranch !== undefined && ( diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.module.css b/packages/client/ui-conversation/src/client/chat/StatsLine.module.css index e66afba2c8..c397e7856b 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.module.css +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.module.css @@ -1,23 +1,25 @@ /* Session stats row: 12/20 tertiary text under the flow, aligned to the - 736px message column axis. */ + shared message column axis (--dsh-chat-content-width). */ .root { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - max-width: 736px; + /* Block, not flex: text-overflow only elides a block's inline content, so + an overlong line ends in … instead of a mid-glyph clip. */ + display: block; + text-align: center; + max-width: var(--dsh-chat-content-width); width: 100%; margin: 0 auto; box-sizing: border-box; - padding: 4px 24px 0px; + padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px; font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary); white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; } .sep { color: var(--dsw-alias-separator-primary); + margin: 0 10px; /* carries the former flex gap */ } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 7bc01b4d95..8866c381b1 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -42,8 +42,10 @@ export const zh = { 'details.input': '输入', 'details.output': '输出', 'details.running': '运行中…', - 'todo.title': '任务清单', - 'todo.progress': '{done}/{total} 项任务 · {active} 项进行中', + 'todo.title': '任务', + 'todo.progress.done': '{done} 已完成', + 'todo.progress.active': '{active} 进行中', + 'todo.progress.pending': '{pending} 待处理', 'todo.rowTitle': '更新任务清单', 'todo.completed': '{done}/{total} 已完成', 'chat.loadingHistory': '载入历史…', @@ -151,7 +153,9 @@ export const en = { 'details.output': 'Output', 'details.running': 'Running…', 'todo.title': 'To-dos', - 'todo.progress': '{done}/{total} tasks · {active} in progress', + 'todo.progress.done': '{done} completed', + 'todo.progress.active': '{active} in progress', + 'todo.progress.pending': '{pending} pending', 'todo.rowTitle': 'Update to-do list', 'todo.completed': '{done}/{total} completed', 'chat.loadingHistory': 'Loading history…', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 5bdaa43c51..eca51941ca 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -16,20 +16,21 @@ var(--dsh-composer-dock-inset) - var(--dsh-composer-dock-inset) ); - /* Flex gap still applies after this item; subtract it together with the - design's overlap so the later composer paints over the queue edge. */ - margin: 0 auto calc( - 0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap) - ); - padding: 2px 12px; + /* Cancel the stack gap after this item and tuck 3px under the input card + (square bottom), reading as one attached surface. */ + margin: 0 auto calc(0px - var(--dsh-composer-stack-gap) - 3px); + /* Horizontal padding completes the shared dock inset (this wrapper only + subtracts two insets from its width); no vertical padding, so the visual + gap above the panel stays the uniform stack gap. */ + padding: 0 var(--dsh-composer-dock-inset); } .panel { position: relative; overflow: hidden; width: 100%; - padding-top: 2px; - border-radius: 14px 14px 0 0; + padding: 2px 0; + border-radius: 12px 12px 0 0; background: var(--dsw-specific-tip); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); @@ -39,6 +40,7 @@ position: absolute; inset: 0; border: 1px solid var(--dsw-alias-border-l1); + /* The input card's own top border closes the shape below. */ border-bottom: none; border-radius: inherit; content: ''; @@ -52,7 +54,9 @@ gap: 10px; width: 100%; height: 36px; - padding: 4px 16px 4px 12px; + /* Right inset 12px puts the chevron on the same vertical line as the Todo + header's chevron (12px body padding there). */ + padding: 4px 12px; border: none; border-radius: 8px; background: transparent; @@ -70,11 +74,18 @@ cursor: default; } +.lead { + display: grid; + flex: none; + place-items: center; + color: var(--dsw-alias-label-tertiary); +} + .count { flex: 1 1 auto; min-width: 0; font-family: Inter, var(--dsw-font-family); - font-size: 14px; + font-size: 13px; font-weight: 500; line-height: 24px; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 67d6153519..010ec05678 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -8,8 +8,8 @@ import { useEffect, useId, useMemo, useState } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { - IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, - IconCloseOutline16, IconEditOutline16, IconSendOutline16, IconTrashOutline16, + IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, + IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId } from '../contract/queue.ts' import { NS } from '../locales.ts' @@ -87,6 +87,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps disabled={interactionActive} onClick={() => { setCollapsed(value => !value) }} > + {t('queue.count', { n: queue.length })} {expanded ? : } @@ -96,6 +97,8 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
} diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css index 872620092f..87cdc07a0c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css @@ -8,13 +8,15 @@ display: flex; flex-direction: column; align-items: center; - padding: 8px 32px 12px; + /* Sides = clearance + 16px so the card lands on the shared content width + (input card - 32) at every viewport. */ + padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px; } .card { overflow: hidden; width: 100%; - max-width: 776px; + max-width: var(--dsh-chat-content-width); border: 1px solid var(--dsw-alias-state-warn-secondary); border-radius: 20px; background: var(--dsw-specific-input-major); @@ -84,7 +86,9 @@ /* Card-level row, not body content. Its padding reproduces the metrics the row had inside the body: 14px above (the flex gap of 6 plus the row's 8px top margin, neither of which reaches it out here) and the body's former 14px - bottom pad below, so the resting card is unchanged. */ + bottom pad below, so the resting card is unchanged. Buttons are the shared + outline/primary capsules (Button atom, matching QuestionComposer's footer); + only the reject's danger hover is local. */ .actionRow { display: flex; justify-content: flex-end; @@ -92,36 +96,6 @@ padding: 14px 16px 14px; } -.allow, -.reject { - padding: 6px 16px; - border-radius: 10px; - font-size: 13px; - line-height: 18px; - cursor: pointer; -} - -.allow:disabled, -.reject:disabled { - opacity: 0.5; - cursor: default; -} - -/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped - always-allow button). */ -.allow { - border: none; - background: var(--dsw-alias-label-primary); - color: var(--dsw-alias-label-primary-foreground); -} - -/* Secondary: quiet outline. */ -.reject { - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - background: transparent; - color: var(--dsw-alias-label-secondary); -} - .reject:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger); color: var(--dsw-alias-state-error-primary); diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx index 5bd2008c35..a1a0a120f5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx @@ -14,6 +14,7 @@ // grant storage. import { useMemo, useState } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client' import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts' import css from './ApprovalPanel.module.css' @@ -69,12 +70,12 @@ function ApprovalFlow({ pending, command, t }: { {command !== undefined &&
{command}
}
- - + +
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 1a83efb551..6111209834 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -9,6 +9,20 @@ height: 100%; min-width: 0; background: var(--dsw-alias-bg-base); + + /* Shared width axis for the whole column: one content width W + (--dsh-chat-content-width) for the transcript, the dock cards + (todo/goal/queue: card minus four insets, 4 x 8 = 32), and the takeover + cards (question/approval/plan review); the input card alone is W + 32px. + The relation also holds when a narrow viewport shrinks everything: the + chat scroller and the takeover frames pad clearance + 16px per side while + the input card clears the bare clearance, so the input card stays exactly + content + 32px at every width. Declared on the root because the + transcript and the composer seat are sibling subtrees. */ + --dsh-chat-content-width: 748px; + --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px); + --dsh-composer-side-clearance: 16px; + --dsh-composer-dock-inset: 8px; } .header { @@ -134,16 +148,11 @@ } /* Composer context stack (Figma 9:937): standalone dock cards share one - rhythm; the terminal queue strip additionally tucks under the input card. */ + rhythm above the input card. */ .composerStack { + /* Horizontal geometry (card width, clearance, dock inset) rides the shared + .root variables above so takeover siblings match the stack. */ --dsh-composer-stack-gap: 6px; - --dsh-queue-composer-overlap: 5px; - - /* InputBar and dock registrants derive their horizontal geometry from the - same card width, outer clearance, and dock inset. */ - --dsh-composer-card-max-width: 800px; - --dsh-composer-side-clearance: 32px; - --dsh-composer-dock-inset: 12px; display: flex; flex-direction: column; @@ -239,7 +248,9 @@ gap: 12px; /* Foot inside the centered box floats the stack a bit above true center. */ padding-bottom: 32px; - width: min(776px, calc(100% - 48px)); + /* Card cap + both clearances: the hero input card lands at exactly the same + width as the docked composer at every viewport. */ + width: min(calc(var(--dsh-composer-card-max-width) + 2 * var(--dsh-composer-side-clearance)), 100%); z-index: 1; } @@ -263,7 +274,9 @@ display: flex; align-items: center; min-width: 0; - padding-left: 8px; + /* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to + the card's inner controls below. */ + padding-left: 20px; } /* Hero: the composer sits inside the session scroll body; center there so 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 1badf3091f..543c9dd387 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -11,23 +11,27 @@ /* Floating capsule input (figma Input_Bottom 75:8208): card floats above the viewport bottom inside the centered message column; textarea on top, action row below, one primary circle button bottom-right. Input width rides the - column (800 is a cap, not a fixed size — layout rule: the box shrinks with - the center column keeping its padding). Hero variant = the same card - centered in the empty state; the transition between the two is a position - move of one component. */ + column (--dsh-composer-card-max-width = chat content + 16px is a cap, not a + fixed size — layout rule: the box shrinks with the center column keeping + its clearance). Hero variant = the same card centered in the empty state; + the transition between the two is a position move of one component. */ .root { display: flex; flex-direction: column; align-items: center; - /* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by - the chat scroller. No top pad: the composer stack's gap owns the space - above; error/status strips still carry their own margin. */ + /* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8; + the sides narrowed with the chat+16 width axis); the bottom gradient mask + is owned by the chat scroller. No top pad: the composer stack's gap owns + the space above; error/status strips still carry their own margin. */ padding: 0 var(--dsh-composer-side-clearance) 8px; } .hero { - padding: 0; + /* No bottom pad in the centered hero, but the side clearance must survive: + the hero wrapper is full-width on narrow viewports, so this padding is + the only thing keeping the card off the edges there. */ + padding: 0 var(--dsh-composer-side-clearance); } .error, @@ -83,7 +87,7 @@ the input border is one notch weaker than buttons) — exactly the l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */ border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 20px; + border-radius: 22px; background: var(--dsw-specific-input-major); box-shadow: var(--dsw-shadow-lv2); font-size: 16px; @@ -241,8 +245,16 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 0 10px 10px 10px; + /* 2px moved from the bottom pad to the top: the whole control row sits 2px + lower in the card (it read too high against the textarea) while the card + height and the controls' own centering stay untouched. */ + padding: 2px 8px 6px; min-width: 0; + /* Size container so the chips inside can collapse to icon-only when the + card runs out of row width (PermissionSelect @container rule). Anonymous + on purpose: CSS modules hash container-name per module, so a name declared + here can never match a query in another module's sheet. */ + container-type: inline-size; } .tools, @@ -253,13 +265,15 @@ min-width: 0; } -/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */ +/* figma 75:8208 drew 16 between + and the mode chips and 4 between Plan / + Read-only; the chip gap widened to 12 so the pill chips read as separate + controls. */ .tools { gap: 16px; } .modes { - gap: 4px; + gap: 12px; } .trailing { @@ -339,6 +353,10 @@ color: #fff; cursor: pointer; transition: background-color 100ms ease; + /* Opts out of the row's 2px downward shift (.row top pad): the send circle + keeps its original seat while the smaller chips sit lower. Transform, not + margin, so flex centering math is untouched. */ + transform: translateY(-2px); } .primary:hover:not(:disabled) { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index c2a718b84a..fdad9f4293 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' -import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconPlusOutline16, 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' @@ -431,19 +431,20 @@ export function InputBar({
- + + +
{accessSelect} {renderSlot('conversation.input.plan', { locked })} @@ -454,25 +455,26 @@ export function InputBar({ {rightItems} {renderSlot('conversation.input.model', { locked })} {/* {machineBusy && } */} - + + +
diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index 50dce3913f..60aceaa120 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -7,7 +7,8 @@ height: 28px; padding: 0 4px 0 8px; border: none; - border-radius: 8px; + /* Rounded chip chrome, matching the sibling model trigger. */ + border-radius: 24px; outline: none; background: transparent; color: var(--dsw-alias-label-secondary); @@ -30,6 +31,18 @@ cursor: default; } +.triggerIcon { + display: inline-flex; + flex: 0 0 auto; +} + +/* The shared 16px glyphs render one step smaller on the exposed trigger; + the dropdown rows keep the full 16px. */ +.triggerIcon svg { + width: 14px; + height: 14px; +} + .triggerLabel { min-width: 0; overflow: hidden; @@ -40,4 +53,21 @@ .chevron { flex: 0 0 auto; color: var(--dsw-alias-label-caption); + transition: transform 120ms ease; +} + +/* Narrow composer: the trigger collapses to icon + chevron so the row keeps + fitting. Only triggers that actually carry a mode glyph drop their label — + a host-configured mode without one keeps its text as the sole identifier. + The 460px cut is the point where the row (attach + modes + model + send) + starts squeezing labels; the container is the composer row (InputBar .row — + anonymous query because CSS modules hash container-names per module). */ +@container (max-width: 460px) { + .trigger:has(.triggerIcon) .triggerLabel { + display: none; + } +} + +.chevronOpen { + transform: rotate(180deg); } diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 16974880dd..632c8c020b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,12 +1,50 @@ import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' -import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutline14, Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerBarProps } from '../contract/slots.ts' import css from './PermissionSelect.module.css' const FULL_ACCESS = 'danger-full-access' +/* Shield glyphs (design set 1556): check = read-only, pencil = workspace + write, exclamation = full access. currentColor so the trigger and menu + rows tint them with their own text color. */ + +const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z' + +const permissionGlyphs = { + 'read-only': ( + + + + + ), + 'workspace-write': ( + + + + + + + + ), + [FULL_ACCESS]: ( + + + + + + ), +} as Record + +/** Glyph for a permission option value; host-configured names outside the design set get none. */ +function permissionGlyph(value: string): ReactNode | undefined { + return permissionGlyphs[value] +} + /** * Display transform: kebab-case machine names render as title-case labels * (`workspace-write` → `Workspace Write`); non-kebab host-configured names @@ -52,7 +90,10 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect const items: MenuEntry[] = value.options .filter(o => o.value !== 'custom') - .map(option => ({ id: option.value, label: optionLabel(option) })) + .map((option) => { + const icon = permissionGlyph(option.value) + return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } } + }) const submit = (id: string): void => { setPick(id) @@ -102,10 +143,12 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect disabled={locked || busy} onClick={() => { setOpen(!open) }} > + {permissionGlyph(currentValue) !== undefined && ( + {permissionGlyph(currentValue)} + )} {current === undefined ? displayName(currentValue) : optionLabel(current)} - - - + {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} + } /> diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 56e7244d21..e67037db7d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,6 +1,7 @@ /* Todo strip in the composer context stack (Figma 1236:32276): tip surface, - 14px radius, status icons + secondary item labels. Its visible card aligns - with the GoalBar and the Queue panel inside their shared dock column. */ + status icons + secondary item labels. Its visible card aligns with the + GoalBar and the Queue panel inside their shared dock column: 12px radius, + 36px collapsed row, 12px side padding, 14px tertiary leading glyph. */ .root { box-sizing: border-box; @@ -24,7 +25,7 @@ var(--dsh-composer-dock-inset) ); border: 1px solid var(--dsw-alias-border-l1); - border-radius: 14px; + border-radius: 12px; background: var(--dsw-specific-tip); /* Elevated surface: `--dsw-specific-tip` is the same dark rung as the menu surface, and `.list` scrolls inside this card, so the thumb takes the l2 @@ -39,7 +40,7 @@ display: flex; flex-direction: column; gap: 8px; - padding: 9px 15px; + padding: 6px 12px; } .header { @@ -54,9 +55,16 @@ cursor: pointer; } +.lead { + display: grid; + flex: none; + place-items: center; + color: var(--dsw-alias-label-tertiary); +} + .title { flex: none; - font-size: 14px; + font-size: 13px; line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index c820902ef5..f4a0946e83 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -13,7 +13,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots // declare) and the payload type. Type-only by construction — the outlet is // free of host value imports, so no host Context merge enters this program. import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' -import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChecklistOutline14, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { NS } from '../locales.ts' import css from './TodoPanel.module.css' @@ -78,11 +78,18 @@ function StatusGlyph({ status }: { status: TodoItem['status'] }) { } } -/** Header summary: "/ tasks · in progress". */ +/** Header summary: "·"-joined per-status counts; zero-count segments are omitted as noise (a non-empty list keeps at least one). */ function progressLabel(todos: readonly TodoItem[], t: TodoPanelProps['t']): string { const done = todos.filter(item => item.status === 'completed').length const active = todos.filter(item => item.status === 'in_progress').length - return t('todo.progress', { done, total: todos.length, active }) + const pending = todos.length - done - active + // En spaces (U+2002): HTML collapses runs of ASCII spaces, so widening the + // separator breathing room needs a literal wide space. + return [ + ...done > 0 ? [t('todo.progress.done', { done })] : [], + ...active > 0 ? [t('todo.progress.active', { active })] : [], + ...pending > 0 ? [t('todo.progress.pending', { pending })] : [], + ].join('\u2002·\u2002') } export function TodoPanel({ todos, t }: TodoPanelProps) { @@ -98,6 +105,7 @@ export function TodoPanel({ todos, t }: TodoPanelProps) { aria-expanded={!collapsed} onClick={() => { setCollapsed(v => !v) }} > + {t('todo.title')} {progressLabel(todos, t)} diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 7cae1606b9..3a1a968bea 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -122,7 +122,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => { // (default-collapsed: the header summary shows; rows appear on expand). const panel = view.container.querySelector('[data-testid="todo-panel"]') expect(panel).not.toBeNull() - expect(panel!.textContent).toContain('1/3 项任务 · 1 项进行中') + expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理') fireEvent.click(panel!.querySelector('button')!) expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status'))) .toEqual(['completed', 'in_progress', 'pending']) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 89a09ca28d..72f56befe0 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -130,6 +130,35 @@ describe('MessageItem arms', () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) }) + it('copy swaps to the check success chrome, gates re-clicks, and reverts after a second', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: '复制' })) + // Two microtask ticks: writeClipboard's own await, then the .then that + // lands the success chrome. + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + const done = screen.getByRole('button', { name: '复制成功' }) + fireEvent.click(done) + expect(writeText).toHaveBeenCalledTimes(1) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + it('consumed steering renders copy and branch actions without a badge', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index fdaad9ab4a..3bcb473437 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -38,15 +38,24 @@ describe('TodoPanel', () => { expect(container.innerHTML).toBe('') }) - it('starts collapsed with the progress summary visible', () => { + it('starts collapsed with the per-status count summary visible', () => { render() expect(screen.getByTestId('todo-panel')).toBeTruthy() - expect(screen.getByText('任务清单')).toBeTruthy() - expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy() + expect(screen.getByText('任务')).toBeTruthy() + expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy() expect(screen.getByRole('button', { expanded: false })).toBeTruthy() expect(screen.queryByRole('list')).toBeNull() }) + it('omits the completed segment while nothing is done yet', () => { + render() + expect(screen.getByText('1 进行中 · 1 待处理')).toBeTruthy() + expect(screen.queryByText(/已完成/)).toBeNull() + }) + it('expands to show one row per item with its status glyph', () => { render() fireEvent.click(screen.getByRole('button', { expanded: false })) @@ -65,17 +74,18 @@ describe('TodoPanel', () => { fireEvent.click(header) expect(screen.queryByRole('list')).toBeNull() // Collapsed header is title + progress only (no in-progress content hint). - expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy() + expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy() expect(screen.queryByText('写组件')).toBeNull() fireEvent.click(screen.getByRole('button', { expanded: false })) expect(screen.getAllByRole('listitem')).toHaveLength(3) }) - it('collapsed header still shows zero in-progress when nothing is active', () => { + it('an all-completed list collapses the summary to the done count alone', () => { render() expect(screen.getByRole('button', { expanded: false })).toBeTruthy() expect(screen.queryByText('都完了')).toBeNull() - expect(screen.getByText('1/1 项任务 · 0 项进行中')).toBeTruthy() + expect(screen.getByText('1 已完成')).toBeTruthy() + expect(screen.queryByText(/进行中|待处理/)).toBeNull() }) }) @@ -93,7 +103,7 @@ describe('TodoDock', () => { // Capability absent (no baseline/frame yet) renders nothing. expect(screen.queryByTestId('todo-panel')).toBeNull() act(() => { store.set({ value: LIST }) }) - expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy() + expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy() // The pre-first-write whole value (null) retires the strip (the panel owns no data). act(() => { store.set({ value: null }) }) expect(screen.queryByTestId('todo-panel')).toBeNull() diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index 62249947c7..cbd6349d88 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -1,5 +1,6 @@ /* GoalBar: the second standalone card in the composer context stack (Figma - 1236:32276). Its 752px column matches Todo and the Queue panel. */ + 1236:32276). Its dock column (card cap minus four insets) matches Todo and + the Queue panel. */ .dock { box-sizing: border-box; @@ -21,12 +22,12 @@ align-items: center; gap: 10px; width: 100%; - max-width: 752px; + max-width: calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset)); height: 36px; margin: 0 auto; padding: 4px 5px 4px 12px; border: 1px solid var(--dsw-alias-border-l1); - border-radius: 14px; + border-radius: 12px; background: var(--dsw-specific-tip); } @@ -36,12 +37,14 @@ color: var(--dsw-alias-label-tertiary); } +/* Matches the Todo/Queue panel titles (13/24 medium, primary) so the three + composer-stack cards read as one family. */ .label { flex: none; font-size: 13px; - line-height: 20px; + line-height: 24px; font-weight: 500; - color: var(--dsw-alias-label-primary-dimmed); + color: var(--dsw-alias-label-primary); } .objective { diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx index ca569dff6d..ceeee27b07 100644 --- a/packages/client/ui-goal/src/client/GoalBar.tsx +++ b/packages/client/ui-goal/src/client/GoalBar.tsx @@ -1,6 +1,6 @@ /** * GoalBar: the goal indicator docked above the message composer (input dock - * strip). A present goal shows a sparkle, a phase label, the truncated + * strip). A present goal shows a goal glyph, a phase label, the truncated * objective, and icon actions — resume when paused, edit (inline form in the * same strip), and clear. Goal creation lives on the `/goal` command, not * here: loading (undefined), no goal (null), and complete goals render @@ -11,7 +11,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client' import { - IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPauseOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16, + IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconGoalOutline16, + IconPauseOutline16, IconPlayOutline16, IconTrashOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { GoalActionResult, GoalBarActions } from './slots.ts' @@ -94,26 +95,28 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar /> {actionError !== null && {actionError}}
- - + + + + + +
@@ -124,34 +127,41 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar return (
- + {t(PHASE_LABELS[goal.phase])} {goal.objective} {actionError !== null && {actionError}}
{goal.phase === 'active' && ( - + + + )} {goal.phase === 'paused' && ( - + + + )} - - + + + + + +
diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 8aa16d8675..967066c056 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -13,7 +13,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import { computeColumns } from './columns.ts' +import { computeColumns, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts' import type { createLayoutStore } from './stores.ts' import css from './AppFrame.module.css' @@ -127,7 +127,19 @@ export function AppFrame({ } }, []) - const cols = computeColumns(viewport, panels.sidebar, detailsSession === undefined ? 0 : panels.details) + // Narrow viewports auto-collapse the sidebar; the store mirror keeps + // toggleSidebar's semantics right (narrow toggles flip the manual + // re-expand override, stores.ts). Collapsed is decided here, so the + // solver stays breakpoint-free: a narrow re-expand passes the preference + // (or the default when the wide preference is closed) and the center + // absorbs the squeeze. + const narrow = viewport < SIDEBAR_AUTO_COLLAPSE + useEffect(() => { actions.setNarrow(narrow) }, [actions, narrow]) + const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0 + const sidebarPreference = sidebarCollapsed + ? 0 + : panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar + const cols = computeColumns(viewport, sidebarPreference, detailsSession === undefined ? 0 : panels.details) const colsRef = useRef(cols) colsRef.current = cols @@ -154,7 +166,7 @@ export function AppFrame({ ref={frameRef} className={css.frame} style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }} - data-sidebar-collapsed={panels.sidebar === 0 || undefined} + data-sidebar-collapsed={sidebarCollapsed || undefined} data-details-collapsed={cols.details === 0 || undefined} data-dragging={dragging || undefined} > @@ -162,9 +174,10 @@ export function AppFrame({ {/* Render-site slot call with live concession output: a closed sidebar keeps the mounted slot at the compact-rail width, and the component sees its rendered state as owner params decided here - (collapsed follows the preference, not the resolved width). */} + (collapsed follows the resolved rail, so a derived auto-collapse + renders the rail UI too). */} {renderSlot('sidebar', { - collapsed: panels.sidebar === 0, + collapsed: sidebarCollapsed, width: cols.sidebar, })} @@ -178,7 +191,7 @@ export function AppFrame({ {renderSlot('details', {})} {/* The collapsed rail is fixed-width: no resize handle while closed. */} - {panels.sidebar > 0 && } + {!sidebarCollapsed && } {cols.details > 0 && } ) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 125bb92a70..51a944ef2a 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -8,6 +8,9 @@ * deficit as the last resort. Inputs are the layout store's plain width * preferences (0 = closed); a closed sidebar resolves to the fixed * SIDEBAR_COLLAPSED control rail while closed details resolve to zero width. + * The SIDEBAR_AUTO_COLLAPSE breakpoint is consumed by AppFrame, which decides + * the effective sidebar preference before solving; the solver itself stays + * breakpoint-free. */ /** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */ @@ -24,6 +27,10 @@ export const SIDEBAR_MAX = 420 export const SIDEBAR_DEFAULT = 280 /** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */ export const SIDEBAR_COLLAPSED = 56 +/** Viewport width below which the sidebar auto-collapses to the rail (deepsuite + * LG breakpoint); a manual toggle below it re-expands over the squeezed center + * (stores.ts narrowExpanded). */ +export const SIDEBAR_AUTO_COLLAPSE = 1024 /** Details drag clamp floor. */ export const DETAILS_MIN = 300 /** Details drag clamp ceiling. */ diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index d2c7811381..d2de668a9c 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -13,8 +13,14 @@ import { SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, } from './columns.ts' -/** Layout store state: panel width preferences in px (0 = closed). */ -type LayoutState = { sidebar: number; details: number } +/** + * Layout store state: panel width preferences in px (0 = closed), plus the + * narrow-viewport pair — `narrow` mirrors AppFrame's breakpoint reading + * (viewport < SIDEBAR_AUTO_COLLAPSE) so toggleSidebar can pick semantics, and + * `narrowExpanded` is the manual override that re-expands the auto-collapsed + * sidebar over the squeezed center without rewriting the width preference. + */ +type LayoutState = { sidebar: number; details: number; narrow: boolean; narrowExpanded: boolean } /** * Annotation twin of the actions literal below (the export needs a declared @@ -24,6 +30,7 @@ type LayoutActions = { setSidebar: (draft: LayoutState, px: number) => void setDetails: (draft: LayoutState, px: number) => void toggleSidebar: (draft: LayoutState) => void + setNarrow: (draft: LayoutState, narrow: boolean) => void openDetails: (draft: LayoutState) => void closeDetails: (draft: LayoutState) => void } @@ -33,16 +40,30 @@ type LayoutActions = { * closing a panel forgets its drag width — reopening restores the contract * default. Actions are the complete write set: drag writes clamp * into the panel's contract range and never cross the open/closed line; - * open/close transitions write 0 / the default explicitly. + * open/close transitions write 0 / the default explicitly. Below the + * auto-collapse breakpoint (AppFrame feeds setNarrow) the sidebar toggle + * flips the narrowExpanded override instead of the preference. * @returns the store handle (spec + type + identity + factory in one). */ export function createLayoutStore(): EngineStoreHandle { const handle = defineStore({ - init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }), + init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false }), actions: { setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) }, setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) }, - toggleSidebar: (d) => { d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 }, + // Narrow toggles flip only the override: the width preference survives + // untouched, so re-widening restores the pre-squeeze layout. + toggleSidebar: (d) => { + if (d.narrow) d.narrowExpanded = !d.narrowExpanded + else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 + }, + // Crossing the breakpoint in either direction drops the override: the + // narrow default is auto-collapsed, the wide state is the preference. + setNarrow: (d, narrow: boolean) => { + if (d.narrow === narrow) return + d.narrow = narrow + d.narrowExpanded = false + }, openDetails: (d) => { if (d.details === 0) d.details = DETAILS_DEFAULT }, closeDetails: (d) => { d.details = 0 }, }, diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 11b5e48e0a..a8a988574b 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -284,6 +284,50 @@ describe('AppFrame', () => { }) }) +describe('AppFrame — narrow-viewport auto-collapse', () => { + it('mounts collapsed below the breakpoint with no sidebar handle', () => { + frameWidth = 980 + const { frame, slotCalls } = mountFrame() + expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0]) + expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true) + expect(slotCalls.filter(c => c.key === 'sidebar').at(-1)!.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED }) + expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0) + }) + + it('narrow toggle re-expands over the squeezed center and back', () => { + frameWidth = 980 + const { frame, instance } = mountFrame() + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)).toEqual([280, 0]) + expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(false) + expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1) + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0]) + }) + + it('a wide-closed preference re-expands at the contract default while narrow', () => { + frameWidth = 1920 + const { frame, instance } = mountFrame() + act(() => { instance.actions.toggleSidebar() }) // close while wide: preference 0 + frameWidth = 980 + act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) + act(() => { instance.actions.toggleSidebar() }) + expect(tracks(frame)).toEqual([280, 0]) + expect(instance.getSnapshot().sidebar).toBe(0) // preference untouched + }) + + it('shrinking across the breakpoint auto-collapses; re-widening restores the drag width', () => { + const { frame, instance } = mountFrame() + act(() => { instance.actions.setSidebar(400) }) + frameWidth = 980 + act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) + expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0]) + frameWidth = 1920 + act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) + expect(tracks(frame)).toEqual([400, 0]) + }) +}) + describe('AppFrame — guard branches', () => { it('pointer moves without capture are ignored (no width write)', () => { const { frame, instance } = mountFrame() diff --git a/packages/client/ui-layout/tests/layout-store.spec.ts b/packages/client/ui-layout/tests/layout-store.spec.ts index ddb3f4a5e1..c6f0069197 100644 --- a/packages/client/ui-layout/tests/layout-store.spec.ts +++ b/packages/client/ui-layout/tests/layout-store.spec.ts @@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels' beforeEach(() => { localStorage.clear() }) describe('createLayoutStore', () => { - it('initializes the sidebar at its default width and details closed', () => { + it('initializes the sidebar at its default width, details closed, wide viewport assumed', () => { const { store } = createLayoutStore().create() - expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 }) + expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false }) }) it('each create() is an independent instance (factory is not a singleton)', () => { @@ -50,6 +50,30 @@ describe('createLayoutStore', () => { expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT) }) + it('narrow toggleSidebar flips only the re-expand override; the width preference survives', () => { + const { store, actions } = createLayoutStore().create() + actions.setSidebar(400) + actions.setNarrow(true) + actions.toggleSidebar() + expect(store.getSnapshot()).toEqual({ sidebar: 400, details: 0, narrow: true, narrowExpanded: true }) + actions.toggleSidebar() + expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().sidebar).toBe(400) + }) + + it('crossing the breakpoint drops the override; a same-value setNarrow keeps it', () => { + const { store, actions } = createLayoutStore().create() + actions.setNarrow(true) + actions.toggleSidebar() + expect(store.getSnapshot().narrowExpanded).toBe(true) + actions.setNarrow(true) + expect(store.getSnapshot().narrowExpanded).toBe(true) + actions.setNarrow(false) + expect(store.getSnapshot()).toMatchObject({ narrow: false, narrowExpanded: false }) + actions.setNarrow(true) + expect(store.getSnapshot().narrowExpanded).toBe(false) + }) + it('openDetails uses the contract default, preserves an open width, and closeDetails zeroes', () => { const { store, actions } = createLayoutStore().create() actions.openDetails() @@ -72,6 +96,8 @@ describe('createLayoutStore', () => { expect(second.store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0, + narrow: false, + narrowExpanded: false, }) }) }) diff --git a/packages/client/ui-layout/tests/service.spec.ts b/packages/client/ui-layout/tests/service.spec.ts index a01f39b810..c7c9cca5ba 100644 --- a/packages/client/ui-layout/tests/service.spec.ts +++ b/packages/client/ui-layout/tests/service.spec.ts @@ -13,6 +13,7 @@ function fakePanels(): PanelActions { setSidebar: vi.fn(), setDetails: vi.fn(), toggleSidebar: vi.fn(), + setNarrow: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(), } diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css index f9d6cc10e6..13688823fd 100644 --- a/packages/client/ui-model/src/client/ModelSelect.module.css +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -15,7 +15,8 @@ height: 28px; padding: 0 4px 0 8px; border: none; - border-radius: 8px; + /* Rounded chip chrome, matching the sibling permission trigger. */ + border-radius: 24px; outline: none; background: transparent; color: var(--dsw-alias-label-secondary); diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index a8b28db46c..299e4694c1 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -26,7 +26,8 @@ .rows { list-style: none; - margin: 0; + /* Extra air between the title/intro block and the first provider card. */ + margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; @@ -70,6 +71,10 @@ cursor: pointer; } +.primaryButton:hover:not(:disabled) { + background: var(--dsw-alias-button-primary-hover); +} + .secondaryButton { border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; @@ -80,14 +85,24 @@ cursor: pointer; } +.secondaryButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-solid); +} + .dangerButton { border: none; + border-radius: 8px; + padding: 4px 8px; background: none; color: var(--dsw-alias-state-error-primary); font: inherit; cursor: pointer; } +.dangerButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + .primaryButton:disabled, .secondaryButton:disabled, .dangerButton:disabled { @@ -147,6 +162,10 @@ cursor: pointer; } +.linkButton:hover:not(:disabled) { + color: var(--dsw-alias-label-secondary); +} + .linkButton:disabled { opacity: 0.5; cursor: default; @@ -171,17 +190,23 @@ } .addButton { + display: inline-flex; + align-items: center; + gap: 6px; align-self: flex-start; border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 8px 16px; font: inherit; - font-size: 13px; background: var(--dsw-alias-bg-layer-3); color: inherit; cursor: pointer; } +.addButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-solid); +} + .addButton:disabled { opacity: 0.5; cursor: default; @@ -246,6 +271,18 @@ color: var(--dsw-alias-label-dimmed); } +/* Select variant of .input: replaces the OS arrow (which sits flush against + the right edge) with the shared 12px chevron inset like the composer's + .select chips; the right pad reserves its cell. */ +.selectInput { + appearance: none; + padding-right: 32px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 12px 12px; +} + .error { margin: 0; font-size: 12px; diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index c206dbd864..b170df1fa0 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -12,7 +12,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' -import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { messageOf } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' @@ -245,7 +245,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
{t('provider')} { expect(screen.getByText('openai')).toBeTruthy() expect(screen.queryByText('Active')).toBeNull() expect(screen.queryByText('Inactive')).toBeNull() - expect(screen.getByText(`+ ${en.add}`)).toBeTruthy() + expect(screen.getByText(en.add)).toBeTruthy() }) it('turns the setup card into a row once the credential reports configured', async () => { @@ -332,7 +332,7 @@ describe('ModelsSection', () => { it('adds a dormant provider with a derived reference and stores its key', async () => { const { mutate, set } = await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) const pick = await screen.findByLabelText(en.provider) expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain']) expect(pick.value).toBe('anthropic') @@ -356,7 +356,7 @@ describe('ModelsSection', () => { it('switches the add card target and degrades unknown or broken targets loudly', async () => { await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) const pick = await screen.findByLabelText(en.provider) fireEvent.change(pick, { target: { value: 'broken' } }) await screen.findByText(/unresolvable settings path/) @@ -374,7 +374,7 @@ describe('ModelsSection', () => { const { set } = await mountSection({ mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))), }) - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) const keys = screen.getAllByLabelText(en.keyInput) fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } }) @@ -554,7 +554,7 @@ describe('ModelsSection', () => { />) expect(screen.getByText(en.readOnly)).toBeTruthy() expect(screen.getAllByText(en.remove).every(button => button.disabled)).toBe(true) - expect(screen.getByText(`+ ${en.add}`).disabled).toBe(true) + expect(screen.getByText(en.add).disabled).toBe(true) }) it('toggles the row editor closed on a second edit click and on cancel', async () => { @@ -573,10 +573,10 @@ describe('ModelsSection', () => { it('cancels the add card back to the add button', async () => { await mountSection() - fireEvent.click(screen.getByText(`+ ${en.add}`)) + fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement) - await screen.findByText(`+ ${en.add}`) + await screen.findByText(en.add) expect(screen.queryByLabelText(en.provider)).toBeNull() }) diff --git a/packages/client/ui-primitives/src/Tooltip.module.css b/packages/client/ui-primitives/src/Tooltip.module.css index 4da0eebc2d..56b93f5be1 100644 --- a/packages/client/ui-primitives/src/Tooltip.module.css +++ b/packages/client/ui-primitives/src/Tooltip.module.css @@ -1,6 +1,6 @@ /* Visual spec mirrors deepsuite @deepseek/ui Tooltip.css (size m, no arrow), - except padding tightened 6/12 -> 4/8 and radius 10 -> 8 by product ruling: - tooltip-bg plate, + except padding tightened 6/12 -> 3/7, type 14/22 -> 13/20, and radius + 10 -> 8 by product ruling: tooltip-bg plate, one text color across both themes (the plate stays dark in light and dark mode). Behavior (fixed positioning off the anchor rect) is local — the upstream Floating stack is intentionally not vendored. */ @@ -8,13 +8,20 @@ .bubble { position: fixed; z-index: 100; - padding: 4px 8px; + /* Fixed-position shrink-to-fit measures only the space from `left` to the + viewport edge, so anchors near the right edge would wrap early; + max-content sizes by the label alone, capped at half the viewport. */ + width: max-content; + max-width: 50vw; + padding: 3px 7px; border-radius: 8px; background: var(--dsw-alias-tooltip-bg); color: var(--dsw-static-neutral-bluish-00); - font-size: 14px; - line-height: 22px; + font-size: 13px; + line-height: 20px; white-space: pre-line; + /* Unbreakable tokens (URLs, paths) must not push past max-width. */ + overflow-wrap: break-word; pointer-events: none; animation: tooltip-in 150ms var(--ds-ease-in-out); } @@ -27,6 +34,10 @@ transform: translateX(-50%); } +.bubble[data-side='top'] { + transform: translate(-50%, -100%); +} + @keyframes tooltip-in { from { opacity: 0; } } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index e85583a50c..9204f1efdc 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -1,18 +1,19 @@ // Hover/focus label bubble (figma tooltip pill: dark plate, white text). -// TODO: interaction is a placeholder (no flip on viewport collision or -// arrow) — visuals and behavior get a proper pass later. +// TODO: interaction is a placeholder (horizontal overflow clamps, but there +// is no vertical flip on viewport collision and no arrow) — visuals and +// behavior get a proper pass later. // The anchor is the child element itself (cloneElement, no wrapper node), so // attaching a tooltip never changes the anchor's layout context. The bubble is // position:fixed and coordinates come from the anchor's rect at show time, so // it escapes ancestor overflow clipping (the sidebar rail clips its column) // without a portal. -import { cloneElement, useCallback, useEffect, useRef, useState } from 'react' +import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' import css from './Tooltip.module.css' /** Bubble placement relative to the anchor. */ -export type TooltipSide = 'right' | 'bottom' +export type TooltipSide = 'right' | 'bottom' | 'top' /** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */ interface AnchorProps { @@ -44,6 +45,21 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, else if (childRef != null) (childRef as MutableRefObject).current = el }, [childRef]) const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + const bubble = useRef(null) + // Horizontal viewport clamp: fixed positioning knows nothing about edges, so + // a centered bubble near the right edge would clip. Measured after paint and + // written straight to the style (no state), so it cannot re-trigger itself. + // EDGE_MARGIN keeps the bubble 12px off the viewport edges instead of flush. + useLayoutEffect(() => { + const el = bubble.current + if (el === null || pos === null) return + const EDGE_MARGIN = 12 + const r = el.getBoundingClientRect() + let dx = 0 + if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right + if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left + if (dx !== 0) el.style.left = `${pos.x + dx}px` + }, [pos]) const showTimer = useRef | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). @@ -73,7 +89,9 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, const r = el.getBoundingClientRect() setPos(side === 'right' ? { x: r.right + 10, y: r.top + r.height / 2 } - : { x: r.left + r.width / 2, y: r.bottom + 8 }) + : side === 'top' + ? { x: r.left + r.width / 2, y: r.top - 8 } + : { x: r.left + r.width / 2, y: r.bottom + 8 }) } const showAfterHoverDelay = () => { cancelShow() @@ -101,7 +119,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {pos !== null && ( - + {label} )} diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 74e3e757b2..eee124d402 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -675,6 +675,26 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** ic_send_outline_14 (figma extract): thin-stroke upward send arrow. */ +export const IconSendOutline14 = ({ size = 14, className }: IconProps) => ( + + + +) + +/** ic_queue_outline_14 (figma extract): open chat bubble with two queued lines. */ +export const IconQueueOutline14 = ({ size = 14, className }: IconProps) => ( + + + +) + /** ic_checklist_outline_14 (figma extract): two rings + two list bars. */ export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => ( @@ -703,7 +723,30 @@ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => ( ) -/** sparkle_16 (Others tool-row / goal strip leading glyph; hand-authored three-star +/** ic_ds_goal_outline_16 (goal strip leading glyph: dartboard with a landed arrow) */ +export const IconGoalOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + + + + + +) + +/** sparkle_16 (Others tool-row leading glyph; hand-authored three-star * approximation — the figma 43:31850 glyph is an SF Symbols "sparkles" text glyph, * not extractable as vector data) */ export const IconSparkle16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index cc5175cba4..c12f09821a 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -14,8 +14,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (45 deepsuite + 15 figma extracts + the hand-authored sparkle)', () => { - expect(iconNames.length).toBe(61) + it('exports the full P-I set (46 deepsuite + 17 figma extracts + the hand-authored sparkle)', () => { + expect(iconNames.length).toBe(64) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 5dc9a1a378..01088db4bd 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -43,8 +43,9 @@ describe('Tooltip', () => { const bubble = screen.getByRole('tooltip') expect(bubble.textContent).toBe('Open sidebar') expect(bubble.getAttribute('data-side')).toBe('right') - // jsdom rects are all-zero: right placement lands at the +10 gutter. - expect(bubble.style.left).toBe('10px') + // jsdom rects are all-zero: right placement lands at the +10 gutter, then + // the zero-width measured rect clamps to the 12px edge margin (10 + 12). + expect(bubble.style.left).toBe('22px') expect(bubble.style.top).toBe('0px') fireEvent.mouseLeave(anchor) expect(screen.queryByRole('tooltip')).toBeNull() @@ -60,12 +61,69 @@ describe('Tooltip', () => { fireEvent.focus(anchor) const bubble = screen.getByRole('tooltip') expect(bubble.getAttribute('data-side')).toBe('bottom') - expect(bubble.style.left).toBe('0px') + // Zero-width jsdom rect at x=0 clamps to the 12px edge margin. + expect(bubble.style.left).toBe('12px') expect(bubble.style.top).toBe('8px') fireEvent.blur(anchor) expect(screen.queryByRole('tooltip')).toBeNull() }) + // jsdom's default rects are all-zero, so the clamp tests stub the measured + // rect (anchor and bubble share the prototype stub) and derive expectations + // from it: pos.x = anchor center, then shifted by the measured overflow. + const rect = (left: number, right: number): DOMRect => + ({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) }) + + it('clamps a bubble overflowing the right viewport edge back inside', () => { + const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100)) + try { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + // pos.x = 1000 (anchor center); measured right edge 1100 overflows the + // 1024 viewport's 12px safe margin (limit 1012) by 88, so the clamp + // shifts left to 912. + expect(screen.getByRole('tooltip').style.left).toBe('912px') + } finally { + spy.mockRestore() + } + }) + + it('clamps a bubble past the left viewport edge back inside', () => { + const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(-20, 80)) + try { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + // pos.x = 30 (anchor center); measured left edge -20 underflows the + // 12px safe margin by 32, so the clamp shifts right to 62. + expect(screen.getByRole('tooltip').style.left).toBe('62px') + } finally { + spy.mockRestore() + } + }) + + it('supports top placement for anchors at the viewport bottom', () => { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + const bubble = screen.getByRole('tooltip') + expect(bubble.getAttribute('data-side')).toBe('top') + // jsdom rects are all-zero: top placement lands at the -8 gutter and the + // zero-width measured rect clamps left to the 12px edge margin. + expect(bubble.style.left).toBe('12px') + expect(bubble.style.top).toBe('-8px') + }) + it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => { const onMouseEnter = vi.fn() const onMouseLeave = vi.fn() diff --git a/packages/client/ui-question/src/client/PlanReviewPanel.module.css b/packages/client/ui-question/src/client/PlanReviewPanel.module.css index 428effab2b..8da6d10c00 100644 --- a/packages/client/ui-question/src/client/PlanReviewPanel.module.css +++ b/packages/client/ui-question/src/client/PlanReviewPanel.module.css @@ -9,7 +9,9 @@ .frame { display: flex; justify-content: center; - padding: 6px 24px 10px; + /* Sides = clearance + 16px: the card lands on the shared content width + (input card - 32) at every viewport. */ + padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px; } .card { @@ -17,7 +19,7 @@ overflow: hidden; flex-direction: column; width: 100%; - max-width: 776px; + max-width: var(--dsh-chat-content-width); /* Composer seat sits in a fixed-height conversation column (overflow hidden): cap the card against the viewport and scroll the plan, so the strip and the decision row stay reachable on a long plan. */ @@ -93,6 +95,18 @@ gap: 8px; } +/* The discuss verb stays a quiet text button beside the two decision + capsules: 14px glyph against the 14px label with a slightly wider gap, so + the icon reads as a prefix rather than a peer-sized control. */ +.discuss { + gap: 6px; + color: var(--dsw-alias-label-secondary); +} + +.discuss:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); +} + @media (max-width: 720px) { .frame { padding: 6px 10px 10px; diff --git a/packages/client/ui-question/src/client/PlanReviewPanel.tsx b/packages/client/ui-question/src/client/PlanReviewPanel.tsx index 020df9c82c..9f7bf6e18a 100644 --- a/packages/client/ui-question/src/client/PlanReviewPanel.tsx +++ b/packages/client/ui-question/src/client/PlanReviewPanel.tsx @@ -73,21 +73,21 @@ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
{error}
{decline !== undefined && ( )}
- + {/* Expanded, the button carries its own label — tooltip only on the rail. */} + + + + )} /> ) @@ -550,7 +552,7 @@ export function WorkspaceBrowser({ picking affordance has nothing to offer here: the region hides the button rather than leaving a dead one in the header. */} {directoryFlowAvailable && ( - +