From 7bcf5e3fb035613b369adac156beffb1a807aa09 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 16:53:35 +0800 Subject: [PATCH 01/18] feat(cli): enable Node environment proxy in launcher --- bin/dsh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/dsh b/bin/dsh index f85f28a5cd..c578d78e74 100755 --- a/bin/dsh +++ b/bin/dsh @@ -20,6 +20,7 @@ root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd) # ESM-only and the CJS resolver costs ~0.4s of startup). Absolute paths keep # both the hook and the tsconfig anchored to this checkout when the launcher # runs from any cwd, where bare `tsx/esm` would not resolve. -TSX_TSCONFIG_PATH="$root/tsconfig.json" \ +NODE_USE_ENV_PROXY=1 \ + TSX_TSCONFIG_PATH="$root/tsconfig.json" \ exec node --import "$root/node_modules/tsx/dist/esm/index.mjs" \ "$root/apps/cli/src/bin.ts" "$@" From 2cac565383f84ee1ead4902e82dd583a3615beac Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 20:25:02 +0800 Subject: [PATCH 02/18] fix(web): let the pointer reach hover cards and row menus The workspace browser's two hover-raised popups both died on the way to them. HoverCard closed on the first pointerleave and rendered its card pointer-events:none, but the card sits 8px off the anchor, so every path to it crossed ground belonging to neither. The row action menus put closeOnPointerLeave's handler on the portaled list, so aiming back at the ... trigger that opened it, or overshooting a list edge, closed it with no window to come back. usePointerGrace owns one cancelable delayed close (200ms) shared by both atoms: leaving arms it, returning cancels it. The hover card becomes hit-testable so resting on it holds it open, and Menu moves pointer-leave dismissal to the wrapper span, where React's enter/leave traversal makes trigger and portaled list one region. Both gestures are pinned in the real browser lane; each fails without the corresponding fix. --- ...-07-30-hover-popup-pointer-grace.i18n.yaml | 6 ++ .../2026-07-30-hover-popup-pointer-grace.md | 35 ++++++++ ...2026-07-30-hover-popup-pointer-grace.zh.md | 35 ++++++++ apps/web/tests/workspace-management.e2e.ts | 76 +++++++++++++---- .../ui-primitives/src/HoverCard.module.css | 5 +- .../client/ui-primitives/src/HoverCard.tsx | 24 ++++-- packages/client/ui-primitives/src/Menu.tsx | 28 ++++++- .../client/ui-primitives/src/pointer-grace.ts | 53 ++++++++++++ .../client/ui-primitives/tests/atoms.spec.tsx | 84 ++++++++++++++++--- .../ui-primitives/tests/hover-card.spec.tsx | 33 +++++++- 10 files changed, 341 insertions(+), 38 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md create mode 100644 packages/client/ui-primitives/src/pointer-grace.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml new file mode 100644 index 0000000000..b87f5de3b1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md +2026-07-30-hover-popup-pointer-grace.md: e999fdea482c14b3b7864df4ba4cba55a89cd7b2 +2026-07-30-hover-popup-pointer-grace.zh.md: 100dfc5b37ed547a8615b2f0f9c3c225a1b592b5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md new file mode 100644 index 0000000000..e999fdea48 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md @@ -0,0 +1,35 @@ +# Agent Note: Hover popup pointer grace + +Status: implemented + +English | [中文](2026-07-30-hover-popup-pointer-grace.zh.md) + +## Problem + +Both popups the workspace browser rows raise floated out of reach of the pointer. `HoverCard` closed on the first `pointerleave` from its anchor and rendered its card `pointer-events: none`, but the card sits 8px off the anchor's right edge, so every path to it crossed ground belonging to neither and killed the card before it arrived — the full workspace path and session title it exists to show could be read only in passing. The row action menus passed `closeOnPointerLeave`, whose handler sat on the portaled list: aiming back at the `...` trigger that opened the list closed it, and so did any overshoot past a list edge, with no window to come back. + +## Decision + +`usePointerGrace` ([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts)) owns one cancelable delayed close, shared by both atoms, with `POINTER_GRACE_MS` at 200. Leaving arms the close; coming back cancels it. Transit through an anchor-to-popup gap is therefore survivable, while a pointer that has genuinely moved on still dismisses the popup. + +`HoverCard` arms the grace on leave instead of closing, and its card no longer sets `pointer-events: none`, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press inside the anchor and an owner flipping `disabled` still dismiss immediately, ahead of the grace. + +`Menu` moves pointer-leave dismissal from the portaled list to the wrapper span. React's enter/leave traversal runs over the React tree, so the trigger and the portaled list are one region there: crossing the 4px gap between them, or aiming back at the trigger, no longer counts as leaving. Leaving is only armed while the list is open, and an owner-driven close (selection, Escape, outside click) disarms a pending grace close in an effect keyed on `open` alone — folding that into the outside-click effect would cancel the grace on every re-render, since owners pass a fresh `onClose` closure each time. + +## Alternatives considered + +**Close the popups only on outside click and Escape.** Rejected because both popups are hover-raised and unlabeled as dismissible; leaving them up after the pointer has moved to another row would strand a card over unrelated content. + +**Widen the anchor's hit area to abut the popup.** Rejected because the 8px and 4px offsets are the design's, and an invisible bridge element would have to track every reposition the fixed-positioned popups already do on scroll and resize. + +**Keep the hover card `pointer-events: none` and only add the grace.** Rejected because the pointer resting on the card would then hit whatever is behind it, so the grace would expire and close the card the user had just reached. + +**Give each atom its own timer.** Rejected because the two closes are the same behavior with the same tuning; a shared hook keeps them from drifting apart. + +## Consequences + +The hover card is now hit-testable and covers 244px of whatever it overlays while shown, which is the price of being reachable; it still lives only as long as the pointer is on the row or the card. Row menus survive the round trip between trigger and list, and a menu that closes for its own reason cannot be reopened into a stale pending close. Menus without `closeOnPointerLeave` are untouched — the wrapper handlers are only attached when it is set. + +## Testing + +`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md new file mode 100644 index 0000000000..100dfc5b37 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 悬浮弹层的指针宽限期 + +Status: implemented + +[English](2026-07-30-hover-popup-pointer-grace.md) | 中文 + +## 问题 + +工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭,其卡片还设置了 `pointer-events: none`;但卡片位于锚点右边缘外 8px 处,因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域,卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 `closeOnPointerLeave`,而其处理器挂在传送后的列表上:把指针移回打开该列表的 `...` 触发按钮会关闭列表,越过列表边缘的任何一次抖动同样如此,且没有任何折返窗口。 + +## 决策 + +`usePointerGrace`([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))持有唯一一个可取消的延迟关闭,由两个原子组件共享,`POINTER_GRACE_MS` 为 200。离开会启动关闭,折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙,而真正移开的指针仍会关闭弹层。 + +`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在锚点内按下指针以及所有者将 `disabled` 置真,仍会抢在宽限期之前立即关闭卡片。 + +`Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮,都不再算作离开。只有在列表打开时才会启动离开关闭;由所有者驱动的关闭(选择、Escape、外部点击)会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect,则每次重新渲染都会取消宽限期,因为所有者每次都传入新的 `onClose` 闭包。 + +## 考虑过的替代方案 + +**仅通过外部点击和 Escape 关闭这两种弹层。** 之所以否决:两者都由悬停唤起,且没有可见的关闭标识;在指针已移到其他行之后仍让它们停留,会把卡片遗留在无关内容之上。 + +**扩大锚点的命中区域,使其与弹层相接。** 之所以否决:8px 与 4px 的偏移来自设计稿,而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和缩放时执行的每一次重新定位。 + +**保留悬浮卡片的 `pointer-events: none`,只加入宽限期。** 之所以否决:那样指针停在卡片上时命中的是卡片背后的元素,宽限期仍会到期,并关闭用户刚刚够到的卡片。 + +**让两个原子组件各自持有计时器。** 之所以否决:这两处关闭是同一种行为、同一套调参;共享 hook 可以防止它们各自漂移。 + +## 后果 + +悬浮卡片现在可被命中,显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价;它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返,而因自身原因关闭的菜单也不会被残留的待执行关闭重新关掉。未设置 `closeOnPointerLeave` 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。 + +## 测试 + +`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index e6a7f31f18..da09aaa77a 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -1,7 +1,8 @@ // Web e2e scenarios: workspace management — the create-by-name dialog, the // rename round trip over the real wire (workspace.rename RPC + durable // registry), duplicate-name pre-check, the flat "In one list" view with its -// persisted group-by preference, and the session hover card. Zero model +// persisted group-by preference, and the pointer-reachability of the session +// hover card and the row action menu. Zero model // calls: workspace.create/rename are host RPCs with no model involvement, // and the one session row the flat/hover scenarios need comes from a seeded // fixture (the seeded-history seed reused verbatim — no new recording). @@ -26,7 +27,7 @@ const MODE = webSnapshotMode() const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md') const SEED_ID = 'workspace-management-web-e2e' -describe('web e2e: workspace management (create / rename / flat view / hover card)', () => { +describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -384,14 +385,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('shows the session hover card after a dwell on the row', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) - // Expand Ungrouped to reveal the seeded session row, then dwell on it - // (the card opens after a 500ms hover delay, portaled to body). + /** + * Expand Ungrouped and return its seeded session row. The only visible child + * is the non-blank persisted Session; the blank Session created while + * adopting the Workspace stays hidden. + * @returns the session row locator, already present. + */ + async function seededSessionRow() { const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') const ungroupedSection = ungroupedRow.locator('..') - // Initial-current auto-expansion can race this following test's gesture; - // converge on expanded rather than assuming which update wins first. + // Initial-current auto-expansion can race this gesture; converge on + // expanded rather than assuming which update wins first. await expect.poll(async () => { if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { await page.getByText('Ungrouped', { exact: true }).click() @@ -399,20 +403,62 @@ describe('web e2e: workspace management (create / rename / flat view / hover car } return await ungroupedRow.getAttribute('aria-expanded') }, { timeout: 5_000 }).toBe('true') - // The only visible child is the non-blank persisted Session; the blank - // Session created while adopting the Workspace remains hidden. - const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) + const row = ungroupedSection.locator('[role="treeitem"]').nth(1) + await row.waitFor({ timeout: 10_000 }) + return row + } + + it('shows the session hover card after a dwell on the row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) + // Dwell on the seeded row; the card opens after a 500ms hover delay, + // portaled to body. + const sessionRow = await seededSessionRow() await sessionRow.hover() - // Card content: the full title plus the Idle status line (display-only - // card; no aria role — text anchors are the stable selector). + // Card content: the full title plus the Idle status line (no aria role — + // text anchors are the stable selector). await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1) - // Leaving the anchor closes it with no delay. + // The card is REACHABLE: it sits 8px off the row, so getting to it means + // crossing ground that belongs to neither. Hovering it must not dismiss + // it — the regression this scenario guards. + const card = page.getByText('Idle', { exact: true }).locator('../../..') + await card.hover() + await page.waitForTimeout(600) + expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1) + // Leaving anchor and card together closes it after the grace. await page.getByRole('button', { name: 'Settings' }).hover() await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('keeps an open row menu up while the pointer moves between trigger and list', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu')) + const sessionRow = await seededSessionRow() + // The trigger is display:none until its row hovers. + await sessionRow.hover() + const trigger = sessionRow.locator('button[aria-label^="Session actions for "]') + await trigger.click() + const item = page.getByRole('menuitem', { name: 'Rename' }) + await item.waitFor({ timeout: 5_000 }) + // Into the list, then back up to the trigger across the 4px gap below it: + // that return trip used to fire the list's pointerleave and close the + // menu, so a hesitating pointer lost it. Order matters — clicking leaves + // the pointer ON the trigger, so entering the list has to come first for + // the return to be a real departure. + await item.hover() + await page.waitForTimeout(300) + await trigger.hover() + await page.waitForTimeout(600) + expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) + // ...and back down into the list, which must still be there to enter. + await item.hover() + await page.waitForTimeout(600) + expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) + // Pointer-leave dismissal still applies once the pointer genuinely leaves. + await page.getByRole('button', { name: 'Settings' }).hover() + await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.warnings).toEqual([]) // The directory-browser aria golden is this spec's one owned artifact; diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 8d8a52100e..ff1ac5509d 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -7,7 +7,9 @@ /* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the * menu card's elevation. Surface is #2C2C2E in both themes (figma value, - * light/dark identical), so a component-level variable, not a theme token. */ + * light/dark identical), so a component-level variable, not a theme token. + * Hit-testable on purpose: resting the pointer on the card holds it open + * (HoverCard's grace close), which a `pointer-events: none` card cannot do. */ .card { --dsw-hovercard-bg: #2C2C2E; position: fixed; @@ -18,5 +20,4 @@ border-radius: 12px; background: var(--dsw-hovercard-bg); box-shadow: var(--dsw-shadow-lv3); - pointer-events: none; } diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 1720a0b79c..3a1ce462b3 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,18 +1,24 @@ // HoverCard: delayed hover-preview card portaled to document.body. // Same portal mechanics as Menu: the wrapper span supplies the anchor rect, // the card is fixed-positioned at its right edge and repositions on -// scroll/resize while open. Display-only — the card ignores pointer events -// and closes the instant the pointer leaves the anchor (no close delay). +// scroll/resize while open. The card is reachable: it takes pointer events, +// and leaving the anchor only arms a grace-delayed close, so the pointer can +// cross the 8px gap and settle on the card to read a clipped path or title. +// The portaled card is a React child of the wrapper, so React's enter/leave +// traversal already treats it as inside — one pair of wrapper handlers covers +// anchor and card alike. import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' +import { usePointerGrace } from './pointer-grace.ts' import css from './HoverCard.module.css' /** * Render an anchor with a hover-triggered preview card. * @param props.anchor - the hover target (rendered in place inside a wrapper span). - * @param props.content - card content (display-only, no pointer interaction). + * @param props.content - card content; the pointer may rest on it, so it is + * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.disabled - suppress opening; turning true closes an open card. * @returns anchor wrapper with the conditional portaled card. @@ -29,6 +35,8 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false const [open, setOpen] = useState(false) const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { setOpen(false) }) + const clearTimer = () => { if (timerRef.current !== null) { clearTimeout(timerRef.current) @@ -40,8 +48,9 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false useEffect(() => { if (!disabled) return clearTimer() + cancelClose() setOpen(false) - }, [disabled]) + }, [disabled, cancelClose]) useEffect(() => clearTimer, []) @@ -91,17 +100,22 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false className={css.root} onPointerEnter={() => { if (disabled) return + // Coming back inside during the grace (the gap, or the card itself) + // keeps the current card rather than restarting the dwell. + cancelClose() + if (open) return clearTimer() timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs) }} onPointerLeave={() => { clearTimer() - setOpen(false) + armClose() }} // Any press inside the anchor (row click, menu trigger) dismisses the // card immediately, without waiting for the owner to flip `disabled`. onPointerDownCapture={() => { clearTimer() + cancelClose() setOpen(false) }} > diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 747750363d..ea7e51b478 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -13,6 +13,7 @@ import type { CSSProperties, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCheckOutline16 } from './icons/index.tsx' +import { usePointerGrace } from './pointer-grace.ts' import css from './Menu.module.css' /** Selectable row (optionally with a nested submenu). */ @@ -69,8 +70,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * from the anchor rect (repositions on scroll/resize while open). Use when an * ancestor's overflow clipping would crop the in-place list; default false * keeps the pure-CSS in-place behavior. - * @param props.closeOnPointerLeave - close the list when the pointer leaves - * it (default false keeps it open until outside click/Escape/selection). + * @param props.closeOnPointerLeave - close the list once the pointer has left + * both trigger and list for the pointer grace (default false keeps it open + * until outside click/Escape/selection). The grace makes the 4px trigger->list + * gap and a brief overshoot survivable; coming back cancels the close. * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the @@ -102,6 +105,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const listRef = useRef(null) const [openSubmenuId, setOpenSubmenuId] = useState(null) const [fixedPos, setFixedPos] = useState(null) + const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose) // Portal mode: fixed-position the list from the anchor rect before paint; // track the anchor while open (capture-phase scroll catches nested panes). @@ -179,6 +183,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } }, [open, onClose]) + // A close from selection/Escape/outside click outruns a pending grace close; + // left armed it would shut a list reopened inside the grace window. Its own + // effect, not the listener effect above: that one re-runs on every `onClose` + // identity change and would cancel the grace mid-transit. + useEffect(() => { + if (!open) cancelClose() + }, [open, cancelClose]) + // The submenu card is absolutely positioned outside the list box; the // scroll clip would crop it, so only submenu-free menus get the height cap. const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0) @@ -251,7 +263,6 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)} style={portal ? fixedPos ?? MEASURE_STYLE : undefined} role="menu" - onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined} // React portals bubble synthetic events through the REACT tree: without // this stop, an item click re-fires the anchor row's own onClick // (open/toggle) after onSelect. @@ -268,8 +279,17 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align ) + // Pointer-leave dismissal watches the WRAPPER, not the list: React's + // enter/leave traversal runs over the React tree, so trigger and portaled + // list are one region here. Aiming back at the trigger, or crossing the 4px + // gap between them, therefore never counts as leaving. return ( - + { if (open) armClose() } : undefined} + > {anchor} {portal ? (list !== false && createPortal(list, document.body)) : list} diff --git a/packages/client/ui-primitives/src/pointer-grace.ts b/packages/client/ui-primitives/src/pointer-grace.ts new file mode 100644 index 0000000000..1619cfe66e --- /dev/null +++ b/packages/client/ui-primitives/src/pointer-grace.ts @@ -0,0 +1,53 @@ +// Shared close timing for pointer-dismissed popups (HoverCard, hover-closing +// Menu). Both float free of their anchor, so the pointer has to cross ground +// that belongs to neither on its way in; closing on the first pointerleave +// makes the popup unreachable. The grace turns that transit into a cancelable +// pending close. + +import { useCallback, useEffect, useRef } from 'react' + +/** + * Grace before a pointer-dismissed popup closes. Covers the anchor->popup gap + * (8px for HoverCard, 4px for Menu) at a hand's travel speed without leaving a + * popup lingering once the pointer has genuinely moved on. + */ +export const POINTER_GRACE_MS = 200 + +/** Cancelable delayed close for a pointer-dismissed popup. */ +export interface PointerGrace { + /** Schedule the close {@link POINTER_GRACE_MS} from now, replacing any pending one. */ + arm: () => void + /** Abort a pending close (the pointer came back). */ + cancel: () => void +} + +/** + * Delay a pointer-dismissed popup's close so the pointer can cross the gap + * between anchor and popup. A pending close is dropped on unmount. + * @param close - runs when the grace elapses with no re-entry; read at fire + * time, so callers may pass a fresh closure each render. + * @returns the {@link PointerGrace} handle. + */ +export function usePointerGrace(close: () => void): PointerGrace { + const timerRef = useRef | null>(null) + const closeRef = useRef(close) + closeRef.current = close + + const cancel = useCallback(() => { + if (timerRef.current === null) return + clearTimeout(timerRef.current) + timerRef.current = null + }, []) + + const arm = useCallback(() => { + cancel() + timerRef.current = setTimeout(() => { + timerRef.current = null + closeRef.current() + }, POINTER_GRACE_MS) + }, [cancel]) + + useEffect(() => cancel, [cancel]) + + return { arm, cancel } +} diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 51b14faa17..8a9791a411 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' +import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) @@ -160,16 +161,77 @@ describe('Menu', () => { expect(onSelect).toHaveBeenCalledWith('del') }) - it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => { - const onClose = vi.fn() - const { rerender } = render( - trigger} items={items} onSelect={() => {}} onClose={onClose} />) - fireEvent.pointerLeave(screen.getByRole('menu')) - expect(onClose).toHaveBeenCalledTimes(1) - rerender( - trigger} items={items} onSelect={() => {}} onClose={onClose} />) - fireEvent.pointerLeave(screen.getByRole('menu')) - expect(onClose).toHaveBeenCalledTimes(1) + it('closeOnPointerLeave closes a grace after the pointer leaves trigger and list; default never does', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + const wrapper = screen.getByText('trigger').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + // Still open through the grace: the pointer may be crossing the gap. + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) }) + expect(onClose).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(onClose).toHaveBeenCalledTimes(1) + rerender( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('coming back inside the grace keeps the list open (trigger and list are one region)', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + const wrapper = screen.getByText('trigger').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('a close from selection disarms the pending grace close', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + const wrapper = screen.getByText('trigger').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + // The owner closes for its own reason (selection/Escape) mid-grace; the + // armed timer must not survive to shut a list reopened right after. + rerender( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('leaving a closed list arms nothing', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByText('trigger').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } }) it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => { diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index ce599c0258..3826fdf79a 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives' +import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) beforeEach(() => { vi.useFakeTimers() }) @@ -54,18 +55,47 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) - it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => { + it('pointerleave closes an open card a grace later; re-enter after that restarts the dwell', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('card body')).toBeTruthy() fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) }) + expect(screen.getByText('card body')).toBeTruthy() + act(() => { vi.advanceTimersByTime(1) }) expect(screen.queryByText('card body')).toBeNull() fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('card body')).toBeTruthy() }) + it('reaching the card inside the grace keeps it open without restarting the dwell', () => { + // The portaled card is a React child of the wrapper, so the pointer + // arriving on it re-enters the wrapper — the gesture the 8px anchor gap + // used to make impossible. + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('re-entering while open does not queue a second dwell', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.pointerEnter(wrapper) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + // A dwell restarted by the redundant enter would reopen the card here. + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByText('card body')).toBeNull() + }) + it('a press inside the anchor dismisses the card without waiting for disabled', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) @@ -135,6 +165,7 @@ describe('HoverCard', () => { expect(card.style.left).toBe('308px') expect(card.style.top).toBe('90px') fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) expect(screen.queryByText('card body')).toBeNull() }) From 3d438eb329bc31bb9836598720b5b260b006e40a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 14:28:52 +0800 Subject: [PATCH 03/18] feat(cli): add minimal Core Web profile --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 4 +- ...9-persistent-bash-str-replace-editor.zh.md | 4 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 8 +++ apps/cli/README.zh.md | 8 +++ apps/cli/config/core-web.cordis.yml | 66 +++++++++++++++++++ apps/cli/package.json | 4 ++ apps/web/tests/core-web-profile.snapshot.ts | 34 ++++++++++ apps/web/tests/scaffold.ts | 10 +++ apps/web/tsconfig.json | 1 + pnpm-lock.yaml | 12 ++++ tsconfig.host.json | 1 + 13 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 apps/cli/config/core-web.cordis.yml create mode 100644 apps/web/tests/core-web-profile.snapshot.ts diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index a16f07a63c..5fc6eacf97 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: a97af750bdd80ddf38dc2d126e70c245ed035f35 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 0c2ab26693d90c91d5c41a128ebb77a3c6cc2e7f +2026-07-29-persistent-bash-str-replace-editor.md: 22851078c1cc8fa9d5716afa41c8a2e2b7e7725c +2026-07-29-persistent-bash-str-replace-editor.zh.md: cf4d18f26d11380a637d573e8ea98cb3ccfcdb59 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index a97af750bd..22851078c1 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,6 +18,8 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. +The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay composes both plugins over the ordinary Web surface, disables its other model-facing consumers, and leaves the Web host, browser, Workspace, persistence, sandbox, and permission stack in place. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. + ## Alternatives considered **One combined compatibility plugin.** Rejected because neither tool requires the other and the combined name would tie reusable capabilities to one benchmark. @@ -30,4 +32,4 @@ Both plugins are included in the Python runtime closure. The persistent Bash clo ## Consequences -Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. The Core Web profile retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 0c2ab26693..cf4d18f26d 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,6 +18,8 @@ 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 +已交付的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) 覆盖层在常规 Web 界面之上组合这两个插件,禁用该界面的其他面向模型的消费方,并保留 Web 宿主、浏览器、Workspace、持久化、沙箱与权限栈。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。 + ## 考虑过的替代方案 **单一组合兼容插件。** 被拒绝,因为两个工具互不依赖,组合命名还会把可复用能力绑定到某个基准。 @@ -30,4 +32,4 @@ ## 后果 -Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。Core Web profile 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index b2fe93b91a..8eb90402b7 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: c36a75fc61fd7118f48c9b68be3144177df19534 -README.zh.md: e926fa99c4e483351f52ca4e76b668e26b34d02f +README.md: f9f370282fc4df1074388018a96c7f5277d94985 +README.zh.md: fea038e9590537190ddf80383cd988ed7e3fcc75 diff --git a/apps/cli/README.md b/apps/cli/README.md index c36a75fc61..f9f370282f 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -23,6 +23,14 @@ The shipped TUI and Web compositions register the native DeepSeek adapter plus p `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode). +[`core-web.cordis.yml`](config/core-web.cordis.yml) is an opt-in `dsh web --config` overlay that keeps the shipped Web host, browser, Workspace, persistence, and permission composition while reducing the default native model surface to owner-scoped persistent `bash` and `str_replace_editor`. The PTY backend and editor consume the existing Web sandbox and filesystem providers. An open persistent shell prevents changing that session's permission mode until the shell closes, so a shell created under wider access cannot survive a downgrade. `DSH_TOOLS_MODE` still controls native/Code Mode presentation for the resulting two-tool registry. + +From a source checkout, start this minimal Web profile with: + +```sh +pnpm run dsh web --config apps/cli/config/core-web.cordis.yml +``` + Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md). ## Install (developer machine) diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index e926fa99c4..fea038e959 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -23,6 +23,14 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。 +[`core-web.cordis.yml`](config/core-web.cordis.yml) 是一个可选启用的 `dsh web --config` 覆盖层:它保留已交付的 Web 宿主、浏览器、Workspace、持久化与权限组合,同时将默认的原生模型界面精简为以所有者为作用域的持久 `bash` 以及 `str_replace_editor`。PTY 后端和编辑器分别消费现有的 Web 沙箱与文件系统提供方。持久 shell 处于打开状态时,会阻止所属会话更改权限模式;因此,在较宽权限下创建的 shell 无法在降权后继续存活。`DSH_TOOLS_MODE` 仍控制由此得到的双工具注册表采用原生/Code Mode 呈现。 + +在源码 checkout 中,用以下命令启动这个精简 Web profile: + +```sh +pnpm run dsh web --config apps/cli/config/core-web.cordis.yml +``` + 每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。 ## 安装(开发机) diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml new file mode 100644 index 0000000000..4713a8b1a7 --- /dev/null +++ b/apps/cli/config/core-web.cordis.yml @@ -0,0 +1,66 @@ +# Opt-in two-tool profile over the shipped Web composition. The default native +# model surface is exactly persistent `bash` plus `str_replace_editor`; the +# Web host, browser shell, workspace, persistence, and permission stack remain. + +# Disable every model-facing consumer in the base/Web tree. plan-mode owns the +# always-registered exit_plan_mode tool even while the session is not planning. +- id: tool-bash + disabled: true + +- id: tool-tasks + disabled: true + +- id: tool-fs + disabled: true + +- id: tool-fs-search + disabled: true + +- id: tool-skill + disabled: true + +- id: plan-mode + disabled: true + +- id: tool-subagent + disabled: true + +- id: tool-subagent-fork + disabled: true + +- id: tool-workflow + disabled: true + +- id: tool-todo + disabled: true + +# The matching browser controls must not offer host tools that this profile +# omits. ui-question's host half owns the ask_user_question registration. +- id: ui-plan + disabled: true + +- id: ui-question + disabled: true + +- insert: + - id: pty + name: '@deepseek-ai/dsh-pty' + + # This backend consumes the existing Web sandbox and permission policy. + # An open persistent shell fences permission-mode changes until it closes. + - id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + + # The editor consumes the Web fs-sandbox provider and therefore retains + # the selected session permission mode. + - id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 diff --git a/apps/cli/package.json b/apps/cli/package.json index 787682ce04..eca871ed3e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -71,6 +71,8 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -103,12 +105,14 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts new file mode 100644 index 0000000000..355988eb34 --- /dev/null +++ b/apps/web/tests/core-web-profile.snapshot.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { launchWebScaffold, type WebScaffold } from './scaffold.ts' + +const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url)) + +describe('core Web profile', () => { + let scaffold: WebScaffold + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + extraOverlayPath: CORE_WEB_OVERLAY, + toolsMode: 'native', + }) + }) + + afterAll(async () => { + await scaffold?.close() + }) + + it('boots the shipped Web composition with only persistent Bash and the string-replace editor', () => { + expect(scaffold.ctx.tools.schemas().map(tool => tool.name)).toMatchInlineSnapshot(` + [ + "bash", + "str_replace_editor", + ] + `) + + const entries = [...scaffold.ctx.loader.entries()] + expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined() + expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined() + expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined() + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index ef60146d7d..f79f8b247f 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -100,6 +100,12 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { + /** + * Optional product overlay applied after the shipped Web surface and before + * the scaffold's hermetic test patches, matching AppCLIEntry's `--config` + * ordering. + */ + extraOverlayPath?: string /** * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row * in replay/refresh modes; ignored in record mode (the real adapter @@ -196,8 +202,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Fri, 31 Jul 2026 14:14:22 +0800 Subject: [PATCH 04/18] fix(web): only anchor presses dismiss the hover card The card is a React child of the wrapper, so capture-phase presses on it reached the wrapper's dismissal handler: the first pointerdown of a text selection closed the card, contradicting its JSDoc contract. Restrict the immediate close to presses outside the card, keeping it mounted under a held press (and the browser click with it), and align onPointerLeave's grace arming with Menu (only while open). Pin both new behaviors in hover-card.spec and update the bilingual Agent Note. --- ...-07-30-hover-popup-pointer-grace.i18n.yaml | 4 ++-- .../2026-07-30-hover-popup-pointer-grace.md | 2 +- ...2026-07-30-hover-popup-pointer-grace.zh.md | 2 +- .../client/ui-primitives/src/HoverCard.tsx | 12 ++++++++--- .../ui-primitives/tests/hover-card.spec.tsx | 20 +++++++++++++++++++ 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml index b87f5de3b1..ed2f7646bc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md -2026-07-30-hover-popup-pointer-grace.md: e999fdea482c14b3b7864df4ba4cba55a89cd7b2 -2026-07-30-hover-popup-pointer-grace.zh.md: 100dfc5b37ed547a8615b2f0f9c3c225a1b592b5 +2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1 +2026-07-30-hover-popup-pointer-grace.zh.md: db10e156103284383f911684b2c92977a0315275 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md index e999fdea48..3f60c98ec6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md @@ -12,7 +12,7 @@ Both popups the workspace browser rows raise floated out of reach of the pointer `usePointerGrace` ([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts)) owns one cancelable delayed close, shared by both atoms, with `POINTER_GRACE_MS` at 200. Leaving arms the close; coming back cancels it. Transit through an anchor-to-popup gap is therefore survivable, while a pointer that has genuinely moved on still dismisses the popup. -`HoverCard` arms the grace on leave instead of closing, and its card no longer sets `pointer-events: none`, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press inside the anchor and an owner flipping `disabled` still dismiss immediately, ahead of the grace. +`HoverCard` arms the grace on leave instead of closing, and its card no longer sets `pointer-events: none`, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press on the card starts a selection instead of dismissing it; only anchor-region presses and an owner flipping `disabled` dismiss immediately, ahead of the grace. `Menu` moves pointer-leave dismissal from the portaled list to the wrapper span. React's enter/leave traversal runs over the React tree, so the trigger and the portaled list are one region there: crossing the 4px gap between them, or aiming back at the trigger, no longer counts as leaving. Leaving is only armed while the list is open, and an owner-driven close (selection, Escape, outside click) disarms a pending grace close in an effect keyed on `open` alone — folding that into the outside-click effect would cancel the grace on every re-render, since owners pass a fresh `onClose` closure each time. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md index 100dfc5b37..db10e15610 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md @@ -12,7 +12,7 @@ Status: implemented `usePointerGrace`([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))持有唯一一个可取消的延迟关闭,由两个原子组件共享,`POINTER_GRACE_MS` 为 200。离开会启动关闭,折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙,而真正移开的指针仍会关闭弹层。 -`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在锚点内按下指针以及所有者将 `disabled` 置真,仍会抢在宽限期之前立即关闭卡片。 +`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择,不会关闭卡片;只有锚点区域内的按下和所有者将 `disabled` 置真,才会抢在宽限期之前立即关闭卡片。 `Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮,都不再算作离开。只有在列表打开时才会启动离开关闭;由所有者驱动的关闭(选择、Escape、外部点击)会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect,则每次重新渲染都会取消宽限期,因为所有者每次都传入新的 `onClose` 闭包。 diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 3a1ce462b3..a768606d86 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -109,11 +109,17 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }} onPointerLeave={() => { clearTimer() - armClose() + // Leaving a closed card schedules a no-op close; only arm while + // open, matching Menu's shape. + if (open) armClose() }} - // Any press inside the anchor (row click, menu trigger) dismisses the + // A press inside the anchor (row click, menu trigger) dismisses the // card immediately, without waiting for the owner to flip `disabled`. - onPointerDownCapture={() => { + // Capture presses reach this handler from the card too — it is a React + // child of the wrapper — but a press there starts a selection, so the + // card must stay mounted under it (and the browser's click with it). + onPointerDownCapture={(e) => { + if (cardRef.current?.contains(e.target as Node)) return clearTimer() cancelClose() setOpen(false) diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index 3826fdf79a..631bad78a0 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -108,6 +108,26 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) + it('a press on the card starts a selection instead of dismissing it', () => { + // The card is a React child of the wrapper, so capture-phase presses on + // it reach the wrapper's dismissal handler too; they must not close it, + // or the first pointerdown of a text-selection drag would kill the card. + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.pointerDown(screen.getByText('card body')) + // Still mounted after a grace's worth of time: no close was armed either. + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('a press while closed leaves the card closed', () => { + mount() + fireEvent.pointerDown(screen.getByText('row')) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + it('disabled suppresses opening entirely', () => { const { wrapper } = mount({ disabled: true }) fireEvent.pointerEnter(wrapper) From 8714952c85849342b9162d93c53ccde7459f92cc Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 15:38:22 +0800 Subject: [PATCH 05/18] feat(web): copy values from hover cards --- ...07-30-client-locale-full-rollout.i18n.yaml | 4 +- .../2026-07-30-client-locale-full-rollout.md | 2 +- ...026-07-30-client-locale-full-rollout.zh.md | 2 +- ...2026-07-31-hover-card-click-copy.i18n.yaml | 6 + .../2026-07-31-hover-card-click-copy.md | 29 ++++ .../2026-07-31-hover-card-click-copy.zh.md | 29 ++++ apps/web/tests/workspace-management.e2e.ts | 10 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../ui-primitives/src/HoverCard.module.css | 17 +++ .../client/ui-primitives/src/HoverCard.tsx | 58 +++++++- .../ui-primitives/tests/hover-card.spec.tsx | 139 +++++++++++++++++- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + .../client/ui-workspace/src/client/locales.ts | 2 + .../ui-workspace/src/client/rows/Rows.tsx | 6 + .../client/ui-workspace/tests/rows.spec.tsx | 21 ++- 19 files changed, 332 insertions(+), 17 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index a56a91c980..1f91308209 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: c080d9f240d4533ecd9694ceecfada8662c46425 -2026-07-30-client-locale-full-rollout.zh.md: 062d982e3d7ea62f3ca4c8fedb842e8336f0852c +2026-07-30-client-locale-full-rollout.md: ae67bc6e9c38180296c27f1f733b4f8dfd25ed08 +2026-07-30-client-locale-full-rollout.zh.md: ef05b6d992795708373aac6c0bfe514c5dac89bb diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index c080d9f240..ae67bc6e9c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -14,7 +14,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record` is the key source and `en satisfies Record` locks bilingual balance. -**Zero-cordis atoms (ui-primitives) take copy as props**: `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). +**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). **The non-translation boundary (deliberate decisions, not debt):** diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 062d982e3d..ef05b6d992 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -14,7 +14,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record` 为 key 源、`en satisfies Record` 锁双语平衡。 -**zero-cordis 原子组件(ui-primitives)文案 props 化**:`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 +**zero-cordis 原子组件(ui-primitives)文案 props 化**:`HoverCard` 的 `copyLabel`/`copiedLabel`、`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 **不翻译边界(刻意决定,不是欠账):** diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml new file mode 100644 index 0000000000..906a082a14 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md +2026-07-31-hover-card-click-copy.md: 6a59013cdabf4d7d2ecc35d4e6ec3c14ee206057 +2026-07-31-hover-card-click-copy.zh.md: f742b7e9ffdf09a7a23d5aab2e1e9189520d9193 diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md new file mode 100644 index 0000000000..6a59013cda --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md @@ -0,0 +1,29 @@ +# Agent Note: Hover cards copy their primary value on activation + +Status: implemented + +English | [中文](2026-07-31-hover-card-click-copy.zh.md) + +## Problem + +Workspace and Session rows clip the two values their hover cards expose in full: the Workspace directory path and Session title. The [reachable card](../bug-fix/2026-07-30-hover-popup-pointer-grace.md) permits text selection, but selecting and copying a single known value is a needlessly precise gesture, and the card gives no confirmation that the clipboard accepted it. + +## Decision + +`HoverCard` accepts an optional `copyText` plus `copyLabel` and `copiedLabel`. With `copyText`, the whole card has button semantics for pointer and keyboard activation; it writes that exact string through the shared clipboard helper and replaces its content with the success label for one second only after the host accepts the write. Without `copyText`, the atom retains its read/select-only behavior. + +The Workspace browser chooses the payload rather than making the primitive infer it from rendered text: a Workspace card passes the full directory path, and a Session card passes the full display title. The browser's locale seat supplies `Copy`/`复制` and the success state `Copied`/`已复制`. + +Press and activation remain separate contracts. A pointer press inside the card keeps it mounted so text selection can begin; the completed click activates copy. Anchor-region presses still dismiss immediately, and clipboard rejection leaves the original content visible without claiming success. + +## Alternatives considered + +**Copy the card's rendered `textContent`.** That would concatenate the primary value with creation time or running status, making the clipboard payload depend on presentation and localization. + +**Implement clipboard state in both Workspace card bodies.** The two consumers would duplicate host fallback, keyboard behavior, timer ownership, and success rendering even though the card owns the activation surface. + +**Change the common Chinese `copied` label from `复制成功` to `已复制`.** That would alter every existing copy control to satisfy one card interaction. The Workspace dictionary owns the card-specific wording instead. + +## Consequences + +Both hover-card variants gain the same click and keyboard affordance while retaining consumer-owned payload semantics and localized feedback. The generic atom adds one optional behavior path and a one-second timer; it clears the timer on unmount and never reports a rejected write as success. Focused component coverage pins pointer selection, activation, failure, feedback expiry, and cleanup, while the real-browser Workspace scenario verifies the English label and browser clipboard. diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md new file mode 100644 index 0000000000..f742b7e9ff --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md @@ -0,0 +1,29 @@ +# Agent Note(agent 决策记录):悬浮卡片激活时复制主要值 + +Status: implemented + +[English](2026-07-31-hover-card-click-copy.md) | 中文 + +## 问题 + +Workspace 与 Session 行会截断对应悬浮卡片完整展示的两个值:Workspace 目录路径和 Session 标题。这张[可抵达的卡片](../bug-fix/2026-07-30-hover-popup-pointer-grace.md)支持文本选择,但复制单个已知值仍需精确选择,操作没有必要地繁琐;卡片也不会确认剪贴板是否接受了写入。 + +## 决策 + +`HoverCard` 接收可选的 `copyText`,以及 `copyLabel` 和 `copiedLabel`。传入 `copyText` 后,整个卡片都会为指针与键盘激活提供按钮语义;卡片通过共享剪贴板辅助函数写入该字符串,并且只有宿主接受写入后,才会用成功标签替换内容一秒。未传入 `copyText` 时,该原子组件维持只读且可选择文本的行为。 + +Workspace 浏览器选择复制载荷,不让基础组件从渲染文本中推断:Workspace 卡片传入完整目录路径,Session 卡片传入完整显示标题。浏览器的 locale 席位提供 `Copy`/`复制`,成功状态则使用 `Copied`/`已复制`。 + +按下与激活仍是两份独立契约。卡片内发生指针按下时,卡片保持挂载,以便用户开始选择文本;完成点击才会激活复制。锚点区域内发生指针按下时,卡片仍会立即消失;剪贴板拒绝写入时,卡片继续显示原内容,不会声称复制成功。 + +## 备选方案 + +**复制卡片渲染后的 `textContent`。** 这会把主要值与创建时间或运行状态拼接起来,使剪贴板载荷依赖表现形式和本地化结果。 + +**在两个 Workspace 卡片主体中分别实现剪贴板状态。** 两个消费方会重复实现宿主回退、键盘行为、计时器所有权和成功状态渲染,尽管激活表层由卡片持有。 + +**将通用中文 `copied` 标签从 `复制成功` 改为 `已复制`。** 这样会为了满足一种卡片交互而改变所有现有复制控件。卡片专用文案应由 Workspace 字典持有。 + +## 后果 + +两种悬浮卡片都获得相同的点击与键盘操作能力,同时保留由消费方决定载荷的语义和本地化反馈。通用原子组件增加一条可选行为路径和一个一秒计时器;组件卸载时会清除该计时器,写入被拒绝时绝不会报告成功。聚焦组件测试会固定指针选择文本、激活、失败、反馈状态到期与清理行为,真实浏览器中的 Workspace 场景则验证英文标签和浏览器剪贴板。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 2047f900ed..b149a49e37 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -415,6 +415,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff // Dwell on the seeded row; the card opens after a 500ms hover delay, // portaled to body. const sessionRow = await seededSessionRow() + const rowTitle = await sessionRow.locator('[class*="title"]').innerText() await sessionRow.hover() // Card content: the full title plus the Idle status line (no aria role — // text anchors are the stable selector). @@ -422,10 +423,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff // The card is REACHABLE: it sits 8px off the row, so getting to it means // crossing ground that belongs to neither. Hovering it must not dismiss // it — the regression this scenario guards. - const card = page.getByText('Idle', { exact: true }).locator('../../..') + const card = page.getByRole('button', { name: 'Copy' }) await card.hover() await page.waitForTimeout(600) expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1) + // The full title is the card's primary value: activating anywhere on the + // card writes it through the browser clipboard and localizes the success + // feedback through the English locale seat. + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await card.click() + await page.getByRole('button', { name: 'Copied' }).waitFor({ timeout: 5_000 }) + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle) // Leaving anchor and card together closes it after the grace. await page.getByRole('button', { name: 'Settings' }).hover() await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index d55fe45007..f4728b0681 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 58be01d56a85c66a144df3f8054840961e987403 -README.zh.md: 2efbec77e64d664553e93b5a8f8dcd2ec7fce49e +README.md: 11ce5d2b71255cb7e078f39f7409bb303bc81a6e +README.zh.md: d1176a37df6d13103c76f907b9d7a80c37f855d5 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 58be01d56a..11ce5d2b71 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -4,6 +4,10 @@ English | [中文](README.zh.md) Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, and WebBlock. Contract: api-contracts v3 §8. +## Hover cards + +`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, writes that exact primary value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md). + ## Markdown rendering `MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). @@ -33,5 +37,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. -- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment. +- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2efbec77e6..d1176a37df 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -4,6 +4,10 @@ 纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock,以及 WebBlock。契约:api-contracts v3 §8。 +## 悬浮卡片 + +`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,通过包内剪贴板辅助函数原样写入该主要值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。 + ## Markdown 渲染 `MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 @@ -32,5 +36,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 -- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。 +- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index ff1ac5509d..b5edbfdb9c 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -21,3 +21,20 @@ background: var(--dsw-hovercard-bg); box-shadow: var(--dsw-shadow-lv3); } + +.copyable { + cursor: pointer; +} + +.copyable:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.copied { + display: block; + color: #FFFFFF; + font-size: 14px; + line-height: 20px; + text-align: center; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index a768606d86..a3bebdd460 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -11,6 +11,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' +import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import css from './HoverCard.module.css' @@ -21,19 +22,32 @@ import css from './HoverCard.module.css' * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.disabled - suppress opening; turning true closes an open card. + * @param props.copyText - optional primary value copied by activating the card. + * @param props.copyLabel - accessible activation label (default "复制"). + * @param props.copiedLabel - visible success label (default "复制成功"). * @returns anchor wrapper with the conditional portaled card. */ -export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: { +export function HoverCard({ + anchor, content, openDelayMs = 500, disabled = false, + copyText, copyLabel = '复制', copiedLabel = '复制成功', +}: { anchor: ReactNode content: ReactNode openDelayMs?: number disabled?: boolean + copyText?: string | undefined + copyLabel?: string | undefined + copiedLabel?: string | undefined }) { const rootRef = useRef(null) const cardRef = useRef(null) const timerRef = useRef | null>(null) + const copyTimerRef = useRef | null>(null) + const copyingRef = useRef(false) + const mountedRef = useRef(true) const [open, setOpen] = useState(false) const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + const [copied, setCopied] = useState(false) const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { setOpen(false) }) @@ -52,7 +66,14 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false setOpen(false) }, [disabled, cancelClose]) - useEffect(() => clearTimer, []) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + clearTimer() + if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current) + } + }, []) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -88,9 +109,38 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false } }, [open, pos]) + const copy = async (text: string): Promise => { + if (copied || copyingRef.current) return + copyingRef.current = true + const accepted = await writeClipboard(text) + copyingRef.current = false + if (!accepted || !mountedRef.current) return + setCopied(true) + copyTimerRef.current = setTimeout(() => { + copyTimerRef.current = null + setCopied(false) + }, 1000) + } + + const copyable = copyText !== undefined const card = open && pos !== null && ( -
- {content} +
{ void copy(copyText) } : undefined} + onKeyDown={copyable + ? (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault() + e.currentTarget.click() + } + : undefined} + > + {copied ? {copiedLabel} : content}
) diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index 631bad78a0..2931f89668 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -17,7 +17,13 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }) } -function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { +function mount(props: { + openDelayMs?: number + disabled?: boolean + copyText?: string + copyLabel?: string + copiedLabel?: string +} = {}) { const view = render( row} content={
card body
} {...props} />, ) @@ -26,6 +32,19 @@ function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { return { view, anchor, wrapper: anchor.parentElement as HTMLElement } } +/** Install the async browser clipboard and restore its prior host shape. */ +function installClipboard(writeText: (text: string) => Promise): () => void { + const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + return () => { + if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', prior) + } +} + describe('HoverCard', () => { it('opens after the dwell delay, positioned right of the anchor', () => { const { wrapper } = mount() @@ -128,6 +147,124 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) + it('copies its configured value and shows success only for the feedback window', async () => { + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ + copyText: '/full/path', + copyLabel: 'Copy path', + copiedLabel: 'Copied', + }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button', { name: 'Copy path' }) + await act(async () => { fireEvent.click(card) }) + expect(writeText).toHaveBeenCalledWith('/full/path') + expect(screen.getByRole('status').textContent).toBe('Copied') + expect(screen.getByRole('button', { name: 'Copied' })).toBe(card) + // Repeated activation while feedback is visible neither rewrites nor + // extends the one-second success window. + await act(async () => { fireEvent.click(card) }) + expect(writeText).toHaveBeenCalledOnce() + act(() => { vi.advanceTimersByTime(999) }) + expect(screen.getByText('Copied')).toBeTruthy() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('button', { name: 'Copy path' })).toBe(card) + expect(screen.getByText('card body')).toBeTruthy() + } finally { + restoreClipboard() + } + }) + + it('supports button keys and ignores unrelated keys', async () => { + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button') + fireEvent.keyDown(card, { key: 'Escape' }) + expect(writeText).not.toHaveBeenCalled() + await act(async () => { fireEvent.keyDown(card, { key: 'Enter' }) }) + expect(writeText).toHaveBeenCalledOnce() + act(() => { vi.advanceTimersByTime(1000) }) + await act(async () => { fireEvent.keyDown(card, { key: ' ' }) }) + expect(writeText).toHaveBeenCalledTimes(2) + } finally { + restoreClipboard() + } + }) + + it('keeps its content when the clipboard rejects the write', async () => { + const writeText = vi.fn(async () => { throw new Error('denied') }) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + await act(async () => { fireEvent.click(screen.getByRole('button')) }) + expect(screen.queryByText('Copied')).toBeNull() + expect(screen.getByText('card body')).toBeTruthy() + } finally { + restoreClipboard() + } + }) + + it('unmount clears copied feedback', async () => { + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const { view, wrapper } = mount({ copyText: 'value' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + await act(async () => { fireEvent.click(screen.getByRole('button')) }) + expect(vi.getTimerCount()).toBe(1) + view.unmount() + expect(vi.getTimerCount()).toBe(0) + } finally { + restoreClipboard() + } + }) + + it('does not create copied feedback after an in-flight write unmounts', async () => { + let acceptWrite: (() => void) | undefined + const writeText = vi.fn(() => new Promise((resolve) => { acceptWrite = resolve })) + const restoreClipboard = installClipboard(writeText) + try { + const { view, wrapper } = mount({ copyText: 'value' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.click(screen.getByRole('button')) + expect(writeText).toHaveBeenCalledOnce() + view.unmount() + await act(async () => { acceptWrite?.() }) + expect(vi.getTimerCount()).toBe(0) + } finally { + restoreClipboard() + } + }) + + it('coalesces activations while the clipboard write is in flight', async () => { + let acceptWrite: (() => void) | undefined + const writeText = vi.fn(() => new Promise((resolve) => { acceptWrite = resolve })) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button') + fireEvent.click(card) + fireEvent.click(card) + expect(writeText).toHaveBeenCalledOnce() + await act(async () => { acceptWrite?.() }) + expect(screen.getByRole('status').textContent).toBe('Copied') + } finally { + restoreClipboard() + } + }) + it('disabled suppresses opening entirely', () => { const { wrapper } = mount({ disabled: true }) fireEvent.pointerEnter(wrapper) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index a21fd21697..3beee61dc4 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: cc73214a281c6950acf8846f0bae3214c8726934 -README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534 +README.md: 864dea6b63c0c87fd6aabb8caf61373473f08f2d +README.zh.md: 0d8f3d79a0dbc996990bdd151a2247cbe66cb84c diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index cc73214a28..864dea6b63 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -8,6 +8,8 @@ The browser renders grouped or flat Session rows from the global runtime hooks a The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. +Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a Session card writes its full display title. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write. + The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index c0b6472c7d..0d8f3d79a0 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -8,6 +8,8 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 +Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活 Session 卡片则会写入其完整显示标题。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。 + Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 1ecc244329..af05dd55f1 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -54,6 +54,7 @@ export const zh = { 'status.running': '进行中', 'status.idle': '空闲', 'hover.created': '创建于 {time}', + 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', 'time.now': '刚刚', 'time.minutes': '{n}分钟', @@ -117,6 +118,7 @@ export const en = { 'status.running': 'Running', 'status.idle': 'Idle', 'hover.created': 'Created {time}', + 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', 'time.now': 'now', 'time.minutes': '{n}min', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 77583140c1..f967a81e0f 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -158,6 +158,9 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { anchor={ownRow} content={} disabled={menuOpen} + copyText={row.cwd} + copyLabel={t('copy')} + copiedLabel={t('hover.copied')} /> ) } @@ -347,6 +350,9 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork anchor={ownRow} content={} disabled={menuOpen || drag?.active === true} + copyText={title} + copyLabel={t('copy')} + copiedLabel={t('hover.copied')} /> ) } diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index c0b4959b17..e1dfe4efae 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -33,6 +33,19 @@ function dragProps(overrides: Partial = {}): RowDragProps { } } +/** Install the async browser clipboard and restore its prior host shape. */ +function installClipboard(writeText: (text: string) => Promise): () => void { + const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + return () => { + if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', prior) + } +} + const dataTransfer = { effectAllowed: '', dropEffect: '' } /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ @@ -129,8 +142,10 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('menu')).toBeNull() }) - it('workspace hover card shows title, directory path, and creation time after the dwell', () => { + it('workspace hover card shows its details and copies the full directory path', async () => { vi.useFakeTimers() + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) try { const group: GroupNode = { key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project', @@ -143,7 +158,11 @@ describe('workspace browser rows', () => { expect(screen.getAllByText('Project')).toHaveLength(2) expect(screen.getByText('/projects/project')).toBeTruthy() expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) }) + expect(writeText).toHaveBeenCalledWith('/projects/project') + expect(screen.getByText('已复制')).toBeTruthy() } finally { + restoreClipboard() vi.useRealTimers() } }) From 8997afb3e52308e4fddd579351f6d321737eff21 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 16:13:12 +0800 Subject: [PATCH 06/18] fix(web): preserve hover-card selection and feedback --- ...2026-07-31-hover-card-click-copy.i18n.yaml | 4 +- .../2026-07-31-hover-card-click-copy.md | 8 +- .../2026-07-31-hover-card-click-copy.zh.md | 8 +- apps/web/tests/workspace-management.e2e.ts | 21 +++-- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/HoverCard.module.css | 7 +- .../client/ui-primitives/src/HoverCard.tsx | 66 +++++++++++---- .../ui-primitives/tests/hover-card.spec.tsx | 82 ++++++++++++++++++- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../ui-workspace/src/client/rows/Rows.tsx | 2 +- .../client/ui-workspace/tests/rows.spec.tsx | 3 +- 15 files changed, 169 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml index 906a082a14..d4f7f72fe2 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md -2026-07-31-hover-card-click-copy.md: 6a59013cdabf4d7d2ecc35d4e6ec3c14ee206057 -2026-07-31-hover-card-click-copy.zh.md: f742b7e9ffdf09a7a23d5aab2e1e9189520d9193 +2026-07-31-hover-card-click-copy.md: c87734fe328fa2adb396d6685495faa82bc1fff2 +2026-07-31-hover-card-click-copy.zh.md: a57b5238b095de293605d4e309dcc2da3516e904 diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md index 6a59013cda..c87734fe32 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md @@ -10,11 +10,11 @@ Workspace and Session rows clip the two values their hover cards expose in full: ## Decision -`HoverCard` accepts an optional `copyText` plus `copyLabel` and `copiedLabel`. With `copyText`, the whole card has button semantics for pointer and keyboard activation; it writes that exact string through the shared clipboard helper and replaces its content with the success label for one second only after the host accepts the write. Without `copyText`, the atom retains its read/select-only behavior. +`HoverCard` accepts an optional `copyText` plus `copyLabel` and `copiedLabel`. With `copyText`, the whole card has button semantics for pointer and keyboard activation; its accessible name combines the localized action prefix with the exact value, it writes that value through the shared clipboard helper, and it replaces its content with the success label for up to one second only after the host accepts the write. The feedback retains the pre-copy card height and clears with the card. Without `copyText`, the atom retains its read/select-only behavior. -The Workspace browser chooses the payload rather than making the primitive infer it from rendered text: a Workspace card passes the full directory path, and a Session card passes the full display title. The browser's locale seat supplies `Copy`/`复制` and the success state `Copied`/`已复制`. +The Workspace browser chooses the payload rather than making the primitive infer it from rendered text: a Workspace card passes the full directory path, and a non-blank Session card passes the full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder, not session content. The browser's locale seat supplies `Copy`/`复制` and the success state `Copied`/`已复制`. -Press and activation remain separate contracts. A pointer press inside the card keeps it mounted so text selection can begin; the completed click activates copy. Anchor-region presses still dismiss immediately, and clipboard rejection leaves the original content visible without claiming success. +Press and activation remain separate contracts. A pointer press inside the card keeps it mounted so text selection can begin; a completed non-collapsed selection intersecting the card suppresses pointer-click activation, while a plain click or button key activates copy. Anchor-region presses still dismiss immediately, and clipboard rejection leaves the original content visible without claiming success. ## Alternatives considered @@ -26,4 +26,4 @@ Press and activation remain separate contracts. A pointer press inside the card ## Consequences -Both hover-card variants gain the same click and keyboard affordance while retaining consumer-owned payload semantics and localized feedback. The generic atom adds one optional behavior path and a one-second timer; it clears the timer on unmount and never reports a rejected write as success. Focused component coverage pins pointer selection, activation, failure, feedback expiry, and cleanup, while the real-browser Workspace scenario verifies the English label and browser clipboard. +Both non-placeholder hover-card variants gain the same click and keyboard affordance while retaining consumer-owned payload semantics and localized feedback. The generic atom adds one optional behavior path and a one-second timer; it clears copied state on close, ignores completion after close or unmount, and never reports a rejected write as success. Focused component coverage pins pointer selection precedence, activation, failure, feedback geometry and expiry, and cleanup, while the real-browser Workspace scenario verifies the English label, stable feedback height, and browser clipboard. diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md index f742b7e9ff..a57b5238b0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md @@ -10,11 +10,11 @@ Workspace 与 Session 行会截断对应悬浮卡片完整展示的两个值:W ## 决策 -`HoverCard` 接收可选的 `copyText`,以及 `copyLabel` 和 `copiedLabel`。传入 `copyText` 后,整个卡片都会为指针与键盘激活提供按钮语义;卡片通过共享剪贴板辅助函数写入该字符串,并且只有宿主接受写入后,才会用成功标签替换内容一秒。未传入 `copyText` 时,该原子组件维持只读且可选择文本的行为。 +`HoverCard` 接收可选的 `copyText`,以及 `copyLabel` 和 `copiedLabel`。传入 `copyText` 后,整个卡片都会为指针与键盘激活提供按钮语义;其无障碍名称由本地化操作前缀和原值组成,卡片通过共享剪贴板辅助函数写入该值,并且只有宿主接受写入后,才会用成功标签替换内容最长一秒。反馈保持复制前的卡片高度,并会随卡片关闭一同清除。未传入 `copyText` 时,该原子组件维持只读且可选择文本的行为。 -Workspace 浏览器选择复制载荷,不让基础组件从渲染文本中推断:Workspace 卡片传入完整目录路径,Session 卡片传入完整显示标题。浏览器的 locale 席位提供 `Copy`/`复制`,成功状态则使用 `Copied`/`已复制`。 +Workspace 浏览器选择复制载荷,不让基础组件从渲染文本中推断:Workspace 卡片传入完整目录路径,非空白 Session 卡片传入完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。浏览器的 locale 席位提供 `Copy`/`复制`,成功状态则使用 `Copied`/`已复制`。 -按下与激活仍是两份独立契约。卡片内发生指针按下时,卡片保持挂载,以便用户开始选择文本;完成点击才会激活复制。锚点区域内发生指针按下时,卡片仍会立即消失;剪贴板拒绝写入时,卡片继续显示原内容,不会声称复制成功。 +按下与激活仍是两份独立契约。卡片内发生指针按下时,卡片保持挂载,以便用户开始选择文本;文本选择完成后,若非折叠选区与卡片相交,就会阻止指针点击激活,而普通点击或按钮激活键会激活复制。锚点区域内发生指针按下时,卡片仍会立即消失;剪贴板拒绝写入时,卡片继续显示原内容,不会声称复制成功。 ## 备选方案 @@ -26,4 +26,4 @@ Workspace 浏览器选择复制载荷,不让基础组件从渲染文本中推 ## 后果 -两种悬浮卡片都获得相同的点击与键盘操作能力,同时保留由消费方决定载荷的语义和本地化反馈。通用原子组件增加一条可选行为路径和一个一秒计时器;组件卸载时会清除该计时器,写入被拒绝时绝不会报告成功。聚焦组件测试会固定指针选择文本、激活、失败、反馈状态到期与清理行为,真实浏览器中的 Workspace 场景则验证英文标签和浏览器剪贴板。 +两种非占位悬浮卡片都获得相同的点击与键盘操作能力,同时保留由消费方决定载荷的语义和本地化反馈。通用原子组件增加一条可选行为路径和一个一秒计时器;卡片关闭时会清除已复制状态,关闭或卸载后到达的完成结果会被忽略,写入被拒绝时绝不会报告成功。聚焦组件测试会固定指针选择文本的优先级、激活、失败、反馈尺寸与到期清除以及清理行为,真实浏览器中的 Workspace 场景则验证英文标签、反馈期间高度稳定和浏览器剪贴板。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index b149a49e37..b00d220585 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -28,6 +28,10 @@ const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', impo const MODE = webSnapshotMode() const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md') const SEED_ID = 'workspace-management-web-e2e' +// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them coupled +// to that contract if the shared grace tuning changes. +const POINTER_TRANSIT_MS = 300 +const POINTER_HOLD_MS = 600 describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => { let scaffold: WebScaffold @@ -423,16 +427,21 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff // The card is REACHABLE: it sits 8px off the row, so getting to it means // crossing ground that belongs to neither. Hovering it must not dismiss // it — the regression this scenario guards. - const card = page.getByRole('button', { name: 'Copy' }) + const card = page.getByRole('button', { name: `Copy: ${rowTitle}` }) await card.hover() - await page.waitForTimeout(600) + await page.waitForTimeout(POINTER_HOLD_MS) expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1) // The full title is the card's primary value: activating anywhere on the // card writes it through the browser clipboard and localizes the success // feedback through the English locale seat. await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + const cardHeight = (await card.boundingBox())?.height await card.click() - await page.getByRole('button', { name: 'Copied' }).waitFor({ timeout: 5_000 }) + const copied = page.getByRole('status').getByText('Copied', { exact: true }) + await copied.waitFor({ timeout: 5_000 }) + await page.waitForTimeout(POINTER_HOLD_MS) + expect((await card.boundingBox())?.height).toBe(cardHeight) + expect(await copied.isVisible()).toBe(true) expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle) // Leaving anchor and card together closes it after the grace. await page.getByRole('button', { name: 'Settings' }).hover() @@ -455,13 +464,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff // the pointer ON the trigger, so entering the list has to come first for // the return to be a real departure. await item.hover() - await page.waitForTimeout(300) + await page.waitForTimeout(POINTER_TRANSIT_MS) await trigger.hover() - await page.waitForTimeout(600) + await page.waitForTimeout(POINTER_HOLD_MS) expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) // ...and back down into the list, which must still be there to enter. await item.hover() - await page.waitForTimeout(600) + await page.waitForTimeout(POINTER_HOLD_MS) expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) // Pointer-leave dismissal still applies once the pointer genuinely leaves. await page.getByRole('button', { name: 'Settings' }).hover() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index f4728b0681..3212ae3554 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 11ce5d2b71255cb7e078f39f7409bb303bc81a6e -README.zh.md: d1176a37df6d13103c76f907b9d7a80c37f855d5 +README.md: 3a7c0fcb6557e2f8aa213290932cefeb62621f0e +README.zh.md: 2a0ce4e3b951cf77f6c8bd83dc15b874d1092027 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 11ce5d2b71..3a7c0fcb65 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Hover cards -`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, writes that exact primary value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md). +`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md). ## Markdown rendering diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index d1176a37df..2a0ce4e3b9 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -6,7 +6,7 @@ ## 悬浮卡片 -`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,通过包内剪贴板辅助函数原样写入该主要值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。 +`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。 ## Markdown 渲染 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index b5edbfdb9c..3ba3469fed 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -31,8 +31,13 @@ outline-offset: 2px; } +.feedback { + display: flex; + align-items: center; + justify-content: center; +} + .copied { - display: block; color: #FFFFFF; font-size: 14px; line-height: 20px; diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index a3bebdd460..2387eb5172 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -8,7 +8,7 @@ // traversal already treats it as inside — one pair of wrapper handlers covers // anchor and card alike. -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' @@ -22,8 +22,9 @@ import css from './HoverCard.module.css' * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.disabled - suppress opening; turning true closes an open card. - * @param props.copyText - optional primary value copied by activating the card. - * @param props.copyLabel - accessible activation label (default "复制"). + * @param props.copyText - optional primary value copied by activation and + * included in the card's accessible name. + * @param props.copyLabel - accessible activation-label prefix (default "复制"). * @param props.copiedLabel - visible success label (default "复制成功"). * @returns anchor wrapper with the conditional portaled card. */ @@ -43,13 +44,30 @@ export function HoverCard({ const cardRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) + const copyHeightRef = useRef(null) + const copyEpochRef = useRef(0) const copyingRef = useRef(false) const mountedRef = useRef(true) const [open, setOpen] = useState(false) const [pos, setPos] = useState<{ left: number; top: number } | null>(null) const [copied, setCopied] = useState(false) - const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { setOpen(false) }) + const clearCopied = useCallback(() => { + if (copyTimerRef.current !== null) { + clearTimeout(copyTimerRef.current) + copyTimerRef.current = null + } + copyHeightRef.current = null + setCopied(false) + }, []) + + const close = useCallback(() => { + copyEpochRef.current += 1 + clearCopied() + setOpen(false) + }, [clearCopied]) + + const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) const clearTimer = () => { if (timerRef.current !== null) { @@ -63,15 +81,19 @@ export function HoverCard({ if (!disabled) return clearTimer() cancelClose() - setOpen(false) - }, [disabled, cancelClose]) + close() + }, [disabled, cancelClose, close]) useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false + copyEpochRef.current += 1 clearTimer() - if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current) + if (copyTimerRef.current !== null) { + clearTimeout(copyTimerRef.current) + copyTimerRef.current = null + } } }, []) @@ -112,31 +134,39 @@ export function HoverCard({ const copy = async (text: string): Promise => { if (copied || copyingRef.current) return copyingRef.current = true + const copyEpoch = copyEpochRef.current const accepted = await writeClipboard(text) copyingRef.current = false - if (!accepted || !mountedRef.current) return + const card = cardRef.current + if (!accepted || !mountedRef.current || copyEpoch !== copyEpochRef.current || card === null) return + const height = card.offsetHeight + copyHeightRef.current = height > 0 ? height : null setCopied(true) - copyTimerRef.current = setTimeout(() => { - copyTimerRef.current = null - setCopied(false) - }, 1000) + copyTimerRef.current = setTimeout(clearCopied, 1000) } const copyable = copyText !== undefined const card = open && pos !== null && (
{ void copy(copyText) } : undefined} + aria-label={copyable ? `${copyLabel}: ${copyText}` : undefined} + onClick={copyable + ? (e) => { + const selection = window.getSelection() + if (selection !== null && !selection.isCollapsed && selection.rangeCount > 0 + && selection.getRangeAt(0).intersectsNode(e.currentTarget)) return + void copy(copyText) + } + : undefined} onKeyDown={copyable ? (e) => { if (e.key !== 'Enter' && e.key !== ' ') return e.preventDefault() - e.currentTarget.click() + void copy(copyText) } : undefined} > @@ -172,7 +202,7 @@ export function HoverCard({ if (cardRef.current?.contains(e.target as Node)) return clearTimer() cancelClose() - setOpen(false) + close() }} > {anchor} diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index 2931f89668..9e37a61d8e 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -140,6 +140,38 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) + it('keeps a completed card selection instead of treating its click as copy', async () => { + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + const selection = window.getSelection() + if (selection === null) throw new Error('jsdom selection API unavailable') + try { + const { wrapper } = mount({ copyText: 'card body', copyLabel: 'Copy' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button', { name: 'Copy: card body' }) + const selectedText = screen.getByText('card body') + const cardRange = document.createRange() + cardRange.selectNodeContents(selectedText) + selection.addRange(cardRange) + await act(async () => { fireEvent.click(card) }) + expect(writeText).not.toHaveBeenCalled() + expect(selection.toString()).toBe('card body') + expect(screen.getByText('card body')).toBeTruthy() + + // A non-collapsed selection elsewhere does not block this card. + selection.removeAllRanges() + const anchorRange = document.createRange() + anchorRange.selectNodeContents(screen.getByText('row')) + selection.addRange(anchorRange) + await act(async () => { fireEvent.click(card) }) + expect(writeText).toHaveBeenCalledWith('card body') + } finally { + selection.removeAllRanges() + restoreClipboard() + } + }) + it('a press while closed leaves the card closed', () => { mount() fireEvent.pointerDown(screen.getByText('row')) @@ -158,11 +190,13 @@ describe('HoverCard', () => { }) fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByRole('button', { name: 'Copy path' }) + const card = screen.getByRole('button', { name: 'Copy path: /full/path' }) + Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 }) await act(async () => { fireEvent.click(card) }) expect(writeText).toHaveBeenCalledWith('/full/path') expect(screen.getByRole('status').textContent).toBe('Copied') - expect(screen.getByRole('button', { name: 'Copied' })).toBe(card) + expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card) + expect(card.style.minHeight).toBe('96px') // Repeated activation while feedback is visible neither rewrites nor // extends the one-second success window. await act(async () => { fireEvent.click(card) }) @@ -170,7 +204,8 @@ describe('HoverCard', () => { act(() => { vi.advanceTimersByTime(999) }) expect(screen.getByText('Copied')).toBeTruthy() act(() => { vi.advanceTimersByTime(1) }) - expect(screen.getByRole('button', { name: 'Copy path' })).toBe(card) + expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card) + expect(card.style.minHeight).toBe('') expect(screen.getByText('card body')).toBeTruthy() } finally { restoreClipboard() @@ -228,6 +263,26 @@ describe('HoverCard', () => { } }) + it('clears copied feedback when the card closes', async () => { + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + await act(async () => { fireEvent.click(screen.getByRole('button')) }) + expect(screen.getByText('Copied')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('Copied')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + } finally { + restoreClipboard() + } + }) + it('does not create copied feedback after an in-flight write unmounts', async () => { let acceptWrite: (() => void) | undefined const writeText = vi.fn(() => new Promise((resolve) => { acceptWrite = resolve })) @@ -246,6 +301,27 @@ describe('HoverCard', () => { } }) + it('does not restore copied feedback after an in-flight card closes', async () => { + let acceptWrite: (() => void) | undefined + const writeText = vi.fn(() => new Promise((resolve) => { acceptWrite = resolve })) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.click(screen.getByRole('button')) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + await act(async () => { acceptWrite?.() }) + expect(vi.getTimerCount()).toBe(0) + expect(screen.getByText('card body')).toBeTruthy() + } finally { + restoreClipboard() + } + }) + it('coalesces activations while the clipboard write is in flight', async () => { let acceptWrite: (() => void) | undefined const writeText = vi.fn(() => new Promise((resolve) => { acceptWrite = resolve })) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 3beee61dc4..40da4bb214 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: 864dea6b63c0c87fd6aabb8caf61373473f08f2d -README.zh.md: 0d8f3d79a0dbc996990bdd151a2247cbe66cb84c +README.md: 6403d07d3d1d09232ebcc6fe707b39f1c68edb95 +README.zh.md: a5f7b8f278d38a83d547646cdf48bf6c33187475 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 864dea6b63..6403d07d3d 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -8,7 +8,7 @@ The browser renders grouped or flat Session rows from the global runtime hooks a The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. -Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a Session card writes its full display title. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write. +Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write. The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 0d8f3d79a0..a5f7b8f278 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -8,7 +8,7 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 -Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活 Session 卡片则会写入其完整显示标题。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。 +Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。 Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index f967a81e0f..27a105df30 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -350,7 +350,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork anchor={ownRow} content={} disabled={menuOpen || drag?.active === true} - copyText={title} + copyText={row.blank ? undefined : row.title} copyLabel={t('copy')} copiedLabel={t('hover.copied')} /> diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index e1dfe4efae..bb0be9538c 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -158,7 +158,7 @@ describe('workspace browser rows', () => { expect(screen.getAllByText('Project')).toHaveLength(2) expect(screen.getByText('/projects/project')).toBeTruthy() expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy() - await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) }) expect(writeText).toHaveBeenCalledWith('/projects/project') expect(screen.getByText('已复制')).toBeTruthy() } finally { @@ -194,6 +194,7 @@ describe('workspace browser rows', () => { expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2) expect(screen.getByText('空闲')).toBeTruthy() expect(screen.queryByText('刚刚')).toBeNull() + expect(screen.getByText('空闲').closest('[role="button"]')).toBeNull() } finally { vi.useRealTimers() } From 0936200e0e4dc170dd973672ebec7986f1123a71 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 16:29:39 +0800 Subject: [PATCH 07/18] fix(web): expose copied status outside button --- .../ui-primitives/src/HoverCard.module.css | 9 +++++++ .../client/ui-primitives/src/HoverCard.tsx | 10 +++++--- .../ui-primitives/tests/hover-card.spec.tsx | 25 ++++++++++++++++--- .../client/ui-workspace/tests/rows.spec.tsx | 2 +- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 3ba3469fed..8d425ca85c 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -43,3 +43,12 @@ line-height: 20px; text-align: center; } + +.status { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 2387eb5172..122043d904 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -157,8 +157,11 @@ export function HoverCard({ onClick={copyable ? (e) => { const selection = window.getSelection() - if (selection !== null && !selection.isCollapsed && selection.rangeCount > 0 - && selection.getRangeAt(0).intersectsNode(e.currentTarget)) return + if (selection !== null && !selection.isCollapsed) { + for (let i = 0; i < selection.rangeCount; i += 1) { + if (selection.getRangeAt(i).intersectsNode(e.currentTarget)) return + } + } void copy(copyText) } : undefined} @@ -170,7 +173,7 @@ export function HoverCard({ } : undefined} > - {copied ? {copiedLabel} : content} + {copied ? : content}
) @@ -206,6 +209,7 @@ export function HoverCard({ }} > {anchor} + {copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} ) diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index 9e37a61d8e..d5b580045a 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -159,8 +159,21 @@ describe('HoverCard', () => { expect(selection.toString()).toBe('card body') expect(screen.getByText('card body')).toBeTruthy() - // A non-collapsed selection elsewhere does not block this card. + // Firefox supports multiple selection ranges: any range intersecting + // this card wins, not only the first. selection.removeAllRanges() + const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue({ + isCollapsed: false, + rangeCount: 2, + getRangeAt: vi.fn((index: number) => ({ + intersectsNode: () => index === 1, + })), + } as unknown as Selection) + await act(async () => { fireEvent.click(card) }) + expect(writeText).not.toHaveBeenCalled() + getSelection.mockRestore() + + // A non-collapsed selection elsewhere does not block this card. const anchorRange = document.createRange() anchorRange.selectNodeContents(screen.getByText('row')) selection.addRange(anchorRange) @@ -191,10 +204,13 @@ describe('HoverCard', () => { fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) const card = screen.getByRole('button', { name: 'Copy path: /full/path' }) + const status = screen.getByRole('status') + expect(status.textContent).toBe('') + expect(card.contains(status)).toBe(false) Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 }) await act(async () => { fireEvent.click(card) }) expect(writeText).toHaveBeenCalledWith('/full/path') - expect(screen.getByRole('status').textContent).toBe('Copied') + expect(status.textContent).toBe('Copied') expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card) expect(card.style.minHeight).toBe('96px') // Repeated activation while feedback is visible neither rewrites nor @@ -202,10 +218,11 @@ describe('HoverCard', () => { await act(async () => { fireEvent.click(card) }) expect(writeText).toHaveBeenCalledOnce() act(() => { vi.advanceTimersByTime(999) }) - expect(screen.getByText('Copied')).toBeTruthy() + expect(status.textContent).toBe('Copied') act(() => { vi.advanceTimersByTime(1) }) expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card) expect(card.style.minHeight).toBe('') + expect(status.textContent).toBe('') expect(screen.getByText('card body')).toBeTruthy() } finally { restoreClipboard() @@ -271,7 +288,7 @@ describe('HoverCard', () => { fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) await act(async () => { fireEvent.click(screen.getByRole('button')) }) - expect(screen.getByText('Copied')).toBeTruthy() + expect(screen.getByRole('status').textContent).toBe('Copied') fireEvent.pointerLeave(wrapper) act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) expect(screen.queryByText('Copied')).toBeNull() diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index bb0be9538c..7bef820d4a 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -160,7 +160,7 @@ describe('workspace browser rows', () => { expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy() await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) }) expect(writeText).toHaveBeenCalledWith('/projects/project') - expect(screen.getByText('已复制')).toBeTruthy() + expect(screen.getByRole('status').textContent).toBe('已复制') } finally { restoreClipboard() vi.useRealTimers() From 5fc242e9721deba815b66214a0ccc1a9600d9f7b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 16:34:09 +0800 Subject: [PATCH 08/18] fix(web): scope hover-card live status --- packages/client/ui-primitives/src/HoverCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 122043d904..ea14afad1f 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -209,7 +209,7 @@ export function HoverCard({ }} > {anchor} - {copyable && {copied ? copiedLabel : ''}} + {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} ) From 0f3c821b387bba7de985ed0060a12a19b4fe74f9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 16:46:29 +0800 Subject: [PATCH 09/18] test(web): assert hover-card dismissal directly --- apps/web/tests/workspace-management.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index b00d220585..c1f0f56ac8 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -445,7 +445,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle) // Leaving anchor and card together closes it after the grace. await page.getByRole('button', { name: 'Settings' }).hover() - await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0) + await expect.poll(() => card.count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) From 7e929d3d50390ca191a2c5bdb7a2a5170800bf79 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:57:19 -0700 Subject: [PATCH 10/18] feat(examples): add generic memory MCP overlays --- ...-third-party-memory-mcp-examples.i18n.yaml | 6 + ...6-07-31-third-party-memory-mcp-examples.md | 78 +++++++++++ ...7-31-third-party-memory-mcp-examples.zh.md | 78 +++++++++++ apps/cli/package.json | 1 + .../tests/fixtures/memory-mcp-base.cordis.yml | 8 ++ apps/cli/tests/memory-mcp-configs.spec.ts | 132 ++++++++++++++++++ examples/README.i18n.yaml | 4 +- examples/README.md | 4 + examples/README.zh.md | 4 + examples/mcp-memory/README.i18n.yaml | 6 + examples/mcp-memory/README.md | 103 ++++++++++++++ examples/mcp-memory/README.zh.md | 103 ++++++++++++++ examples/mcp-memory/engram.cordis.yml | 15 ++ .../mcp-reference-memory.cordis.yml | 13 ++ examples/mcp-memory/memorix.cordis.yml | 14 ++ pnpm-lock.yaml | 3 + scripts/verify-cordis-config.ts | 5 +- 17 files changed, 574 insertions(+), 3 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md create mode 100644 apps/cli/tests/fixtures/memory-mcp-base.cordis.yml create mode 100644 apps/cli/tests/memory-mcp-configs.spec.ts create mode 100644 examples/mcp-memory/README.i18n.yaml create mode 100644 examples/mcp-memory/README.md create mode 100644 examples/mcp-memory/README.zh.md create mode 100644 examples/mcp-memory/engram.cordis.yml create mode 100644 examples/mcp-memory/mcp-reference-memory.cordis.yml create mode 100644 examples/mcp-memory/memorix.cordis.yml diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml new file mode 100644 index 0000000000..9115d89318 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md +2026-07-31-third-party-memory-mcp-examples.md: 512a222d1f8406d11ef5c58c5f2749c9b571d846 +2026-07-31-third-party-memory-mcp-examples.zh.md: 8bdc4ae371b7b3c4a8e78eeceed1f965e515a475 diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md new file mode 100644 index 0000000000..512a222d1f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md @@ -0,0 +1,78 @@ +# Agent Note: Third-party memory MCP examples + +Status: implemented + +English | [中文](2026-07-31-third-party-memory-mcp-examples.zh.md) + +## Problem + +A direct vendor integration made one provider's API, configuration, health behavior, and tool semantics part of DSH. That was too much product surface for a capability already expressible through MCP, and it would require repeating the same adaptation for every memory system. Users instead need a small, inspectable way to opt into one external memory server while preserving the generic MCP boundary. + +The acceptance bar is stronger than "the socket connects": each reference must support a real DSH write in session A, recall from the provider in a fresh DSH session B, and use of the recalled value. At the same time, provider downloads, accounts, models, embeddings, storage initialization, and separate HTTP processes must remain upstream responsibilities. + +## Decision + +Ship three default-off Cordis overlay examples under `examples/mcp-memory`: Memorix, MCP Reference Memory, and Engram. Every file inserts exactly one `@deepseek-ai/dsh-mcp-client` row. None is referenced by the shipped composition, and the CLI declares the generic bridge only so an explicitly selected overlay can resolve it. + +These third-party configurations are provided as interoperability examples only. Their inclusion does not imply endorsement, recommendation, partnership, or ongoing support by DeepSeek. There is no memory preset registry, vendor-specific DSH plugin, universal memory service, installation UI, migration layer, health checker, or reconnect controller. Another memory MCP server uses the same documented stdio or Streamable HTTP row. + +## Responsibility boundary + +| Concern | DSH | Upstream provider or user | +|---|---|---| +| Parse selected overlay | Yes | Select one file | +| Start stdio command and stop it on plugin disposal | Yes | Install the pinned executable | +| Connect to Streamable HTTP and discover tools | Yes | Run and supervise the HTTP service | +| Register tools as `mcp____` | Yes | Define tool schemas and behavior | +| Account, auth, model, embedding, storage initialization | No | Yes | +| Vendor data migration, retry, crash recovery | No | Yes | + +The generic stdio transport scrubs ambient credential-shaped and `DSH_*` variables. Baseline examples explicitly map only the variables they require; optional provider secrets must be added to `config.env` or configured in the provider's own files. + +## Pins and identity + +| Provider | Tested contract | +|---|---| +| Memorix | npm `1.3.0`, tag commit `500792cad3144142293bfbb20acb4841c9f7fcfa` | +| MCP Reference Memory | npm `2026.7.4`, package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` | +| Engram | tag `v1.20.0`, commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` | + +`DSH_MEMORY_USER_ID` is a stable user partition, not a DSH session id. Each example maps it to a separate provider data path under `$DSH_HOME`. + +Project identity remains provider-owned: Memorix and Engram use the DSH working directory's Git project, with Engram optionally accepting `ENGRAM_PROJECT`. + +## Model guidance + +The examples do not patch `@deepseek-ai/dsh-system-prompt`: a config patch replaces a row's complete config and could erase an existing persona. The README instead offers one optional additive instruction: + +> When the user asks you to remember something, call a memory write tool. When historical information may be relevant, search memory and use relevant results. + +Provider tool descriptions remain authoritative. + +## Validation contract + +Remote CI never contacts third-party services or consumes secrets. The keyless suite parses all three overlay files, checks their generic bridge and secret boundary, replaces the upstream endpoint with the package-owned MCP fixture server, boots the real Cordis Loader, and proves tool discovery. + +Before merge, manual evidence for every pinned provider must separately show: + +1. DSH session A calls a write tool and receives success for a unique value. +2. Fresh DSH session B, under the same provider/user scope, calls search or recall and returns that value without session A's transcript. +3. Session B uses the recalled value in a subsequent answer. + +"Fresh session" means a new DSH session in the same Host. No Host restart is required. The generic MCP client discovers asynchronously and has no automatic reconnect after a child or HTTP transport closes; validation waits for tools before the first turn and uses HMR or a Host restart only after a crash. + +## Alternatives considered + +**One DSH plugin per provider.** Rejected because it repeats auth, configuration, lifecycle, and tool wrappers that MCP already standardizes and expands ownership for every added provider. + +**A memory-provider preset registry.** Rejected because a registry would make third-party versions and recommendations look like a supported DSH product surface. Copyable overlays keep ownership and drift visible. + +**Run `npx` or `go run` inside the MCP row.** Rejected after probes showed first-run npm downloads can exceed the MCP initialization timeout and an interrupted `npx` cache can become unusable. DSH starts a server process; it is not the provider package manager. Pinned installation commands are explicit prerequisites. + +**Inject the common instruction from the generic MCP client.** Rejected because the bridge serves non-memory MCP servers too, and generic prompt mutation would reintroduce provider semantics into shared runtime code. + +## Consequences + +Selecting a file gives the model the provider's complete discovered MCP tool surface, with schema/token cost determined by that provider. Removing `--config` removes the memory server. Users accept each upstream license, data policy, cloud cost, and operational model directly. + +The earlier vendor-specific change is superseded by this generic path. Future provider drift is handled by updating and revalidating a small example pin rather than adding runtime branches to DSH. diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md new file mode 100644 index 0000000000..8bdc4ae371 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md @@ -0,0 +1,78 @@ +# Agent Note: 第三方记忆 MCP 示例 + +Status: implemented + +[English](2026-07-31-third-party-memory-mcp-examples.md) | 中文 + +## 问题 + +直接集成某个提供方会使该提供方的 API、配置、健康状态行为和工具语义成为 DSH 的一部分。对于已经可以通过 MCP 表达的功能,这会让产品接口过于庞大,而且每接入一个记忆系统都需要重复同样的适配工作。用户需要的是一种精简、可检查的方式,在保留通用 MCP 边界的同时,选择启用一个外部记忆服务器。 + +验收标准不止于「套接字可以连接」:每份参考配置都必须支持 DSH 在会话 A 中实际写入,在新的 DSH 会话 B 中从提供方召回,并使用召回的值。与此同时,提供方下载、账户、模型、embedding、存储初始化和独立 HTTP 进程仍由上游负责。 + +## 决策 + +在 `examples/mcp-memory` 下交付三份默认关闭的 Cordis overlay 示例:Memorix、MCP Reference Memory 和 Engram。每个文件只插入一个 `@deepseek-ai/dsh-mcp-client` 配置项。交付组合不会引用这些文件;CLI(命令行界面)仅声明通用桥接器,使用户显式选择 overlay 时可以解析它。 + +这些第三方配置仅作为互操作参考;收录不代表 DeepSeek 的认可、推荐、合作关系或持续支持承诺。系统没有记忆预设注册表、提供方专属 DSH 插件、通用记忆服务、安装 UI、迁移层、健康检查器或重连控制器。其他记忆 MCP 服务器可以使用同一份文档中的 stdio 或 Streamable HTTP 配置项。 + +## 职责边界 + +| 事项 | DSH | 上游提供方或用户 | +|---|---|---| +| 解析选中的 overlay | 是 | 选择一个文件 | +| 启动 stdio 命令,并在插件 dispose(资源释放)时将其停止 | 是 | 安装固定版本的可执行文件 | +| 连接 Streamable HTTP 并发现工具 | 是 | 运行并监管 HTTP 服务 | +| 以 `mcp____` 注册工具 | 是 | 定义工具 schema 和行为 | +| 账户、认证、模型、embedding、存储初始化 | 否 | 是 | +| 提供方数据迁移、重试、崩溃恢复 | 否 | 是 | + +通用 stdio 传输会清除环境中名称类似凭据的变量和 `DSH_*` 变量。基线示例仅显式映射自己需要的变量;可选的提供方密钥必须添加到 `config.env`,或配置在提供方自己的文件中。 + +## 版本固定与身份 + +| 提供方 | 已测试契约 | +|---|---| +| Memorix | npm `1.3.0`,tag commit `500792cad3144142293bfbb20acb4841c9f7fcfa` | +| MCP Reference Memory | npm `2026.7.4`,package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` | +| Engram | tag `v1.20.0`,commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` | + +`DSH_MEMORY_USER_ID` 是稳定的用户分区,不是 DSH 会话 id。每份示例都将其映射到 `$DSH_HOME` 下相互独立的提供方数据路径。 + +项目身份仍由提供方负责:Memorix 和 Engram 使用 DSH 工作目录中的 Git 项目,其中 Engram 还可以选择接受 `ENGRAM_PROJECT`。 + +## 模型指导 + +示例不会修改 `@deepseek-ai/dsh-system-prompt`:配置 patch 会替换某个配置项的完整配置,可能抹除已有 persona。README 改为提供一条可选的附加指令: + +> 用户要求记住时调用写入工具;涉及历史信息时,主动检索并使用相关记忆。 + +提供方的工具描述仍然是权威定义。 + +## 验证契约 + +远程 CI 不会访问第三方服务或消耗密钥。无密钥套件解析全部三份 overlay 文件,检查其通用桥接器和密钥边界,将上游端点替换为包自带的 MCP fixture(测试前置数据)服务器,通过真实 Cordis Loader 启动,并验证工具发现。 + +合并前,每个固定版本的提供方都必须分别提供以下人工证据: + +1. DSH 会话 A 调用写入工具,为一个唯一值写入记忆,并收到成功结果。 +2. 新的 DSH 会话 B 在相同提供方/用户范围下调用搜索或召回,不借助会话 A 的 transcript(文本记录)便可返回该值。 +3. 会话 B 在后续回答中使用该召回值。 + +「新会话」是指同一个 Host 中新建的 DSH 会话,不需要重启 Host。通用 MCP 客户端以异步方式发现工具,子进程或 HTTP 传输关闭后不会自动重连;验证会在第一轮之前等待工具出现,并且只在崩溃后使用 HMR 或重启 Host。 + +## 考虑过的替代方案 + +**每个提供方使用一个 DSH 插件。** 不予采纳,因为这会重复 MCP 已经标准化的认证、配置、生命周期和工具包装层,并随着每增加一个提供方而扩大维护范围。 + +**记忆提供方预设注册表。** 不予采纳,因为注册表会让第三方版本和推荐看起来像受支持的 DSH 产品接口。可复制的 overlay 让所有权和版本偏移保持可见。 + +**在 MCP 配置项内运行 `npx` 或 `go run`。** 不予采纳,因为探测表明首次 npm 下载可能超过 MCP 初始化超时,而中断的 `npx` 缓存可能变得不可用。DSH 负责启动服务器进程,不是提供方的包管理器。固定版本的安装命令属于显式前置条件。 + +**由通用 MCP 客户端注入共用指令。** 不予采纳,因为该桥接器也服务于非记忆类 MCP 服务器,而且通用提示词变更会把提供方语义重新带入共享运行时代码。 + +## 后果 + +选择一个文件后,模型可以使用提供方发现到的完整 MCP 工具接口;工具 schema 和 token 成本由提供方决定。移除 `--config` 就会移除记忆服务器。用户直接接受各上游的许可证、数据政策、云服务费用和运维模式。 + +通用方案取代了早期针对特定提供方的改动。未来出现提供方版本偏移时,只需更新并重新验证一份小型示例的固定版本,不必向 DSH 添加运行时分支。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 5556411e15..ab4353faf5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -68,6 +68,7 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", diff --git a/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml b/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml new file mode 100644 index 0000000000..00878d6c98 --- /dev/null +++ b/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml @@ -0,0 +1,8 @@ +# Minimal keyless composition for loading example MCP overlays against the +# package-owned fixture server in memory-mcp-configs.spec.ts. Source builtins +# keep this unit test independent of prebuilt workspace artifacts. +- id: system-prompt + name: cordis:memory-test-system-prompt + +- id: tools + name: cordis:memory-test-tools diff --git a/apps/cli/tests/memory-mcp-configs.spec.ts b/apps/cli/tests/memory-mcp-configs.spec.ts new file mode 100644 index 0000000000..a8940a13ae --- /dev/null +++ b/apps/cli/tests/memory-mcp-configs.spec.ts @@ -0,0 +1,132 @@ +/** + * The third-party memory examples stay config-only. This suite parses every + * checked-in overlay, verifies its pin/transport/secret boundary, then replaces + * only the upstream endpoint with the package-owned keyless MCP fixture and + * proves the real Cordis Loader discovers a tool through the generic bridge. + */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as McpClient from '@deepseek-ai/dsh-mcp-client/src/index.ts' + +interface ExampleContract { + file: string + id: string + serverName: string + transport: 'stdio' | 'streamable-http' + pin: string +} + +interface InsertedRow { + id?: string + name?: string + config?: Record +} + +const root = resolve(import.meta.dirname, '../../..') +const exampleDir = resolve(root, 'examples/mcp-memory') +const baseConfig = resolve(import.meta.dirname, 'fixtures/memory-mcp-base.cordis.yml') +const fixtureServer = resolve(root, 'packages/mcp/mcp-client/tests/fixture-server.ts') + +const examples: ExampleContract[] = [ + { + file: 'memorix.cordis.yml', + id: 'memory-memorix', + serverName: 'memorix', + transport: 'stdio', + pin: '1.3.0', + }, + { + file: 'mcp-reference-memory.cordis.yml', + id: 'memory-mcp-reference', + serverName: 'reference_memory', + transport: 'stdio', + pin: '2026.7.4', + }, + { + file: 'engram.cordis.yml', + id: 'memory-engram', + serverName: 'engram', + transport: 'stdio', + pin: '1.20.0', + }, +] + +const liveContexts = new Set() + +afterEach(async () => { + await Promise.all([...liveContexts].map(async ctx => ctx.fiber.dispose())) + liveContexts.clear() +}) + +function insertedRow(patches: PatchOptions[]): InsertedRow { + expect(patches).toHaveLength(1) + const insert = patches[0]?.insert + expect(insert).toHaveLength(1) + return insert?.[0] as InsertedRow +} + +async function waitForTool(ctx: Context, name: string): Promise { + const deadline = Date.now() + 10_000 + while (!ctx.tools.schemas().some(schema => schema.name === name)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${name}`) + await new Promise(resolveWait => setTimeout(resolveWait, 25)) + } +} + +describe('third-party memory MCP example overlays', () => { + it.each(examples)('parses $file with the documented generic boundary', (contract) => { + const file = resolve(exampleDir, contract.file) + const source = readFileSync(file, 'utf8') + const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file)) + + expect(row.id).toBe(contract.id) + expect(row.name).toBe('@deepseek-ai/dsh-mcp-client') + expect(row.config?.serverName).toBe(contract.serverName) + expect(row.config?.transport).toBe(contract.transport) + expect(source).toContain(contract.pin) + expect(source).not.toMatch(/\bsk-[A-Za-z0-9_-]{8,}\b/) + expect(source).not.toContain('DEEPSEEK_API_KEY') + }) + + it.each(examples)('loads $file and discovers a keyless fixture tool', async (contract) => { + const patches = loadOverlayPatches( + 'memory-mcp-config-test', + resolve(exampleDir, contract.file), + ) + // The static config gate verifies the checked-in bare package specifier. + // The unit test maps it to the source module so a clean checkout needs no + // prebuilt `lib/` artifacts before proving the Loader/MCP behavior. + insertedRow(patches).name = 'cordis:memory-test-mcp-client' + const fixturePatch: PatchOptions = { + id: contract.id, + config: { + serverName: contract.serverName, + transport: 'stdio', + command: process.execPath, + args: [fixtureServer], + env: {}, + cwd: root, + toolCallTimeoutMs: 5_000, + }, + } + const ctx = await boot( + 'memory-mcp-config-test', + baseConfig, + [...patches, fixturePatch], + (ctx) => { + ctx.loader.builtins['memory-test-system-prompt'] = SystemPrompt + ctx.loader.builtins['memory-test-tools'] = ToolRegistry + ctx.loader.builtins['memory-test-mcp-client'] = McpClient + }, + ) + liveContexts.add(ctx) + await waitForTool(ctx, `mcp__${contract.serverName}__greet`) + }, 15_000) +}) diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index 1956f0fd5e..ead468e816 100644 --- a/examples/README.i18n.yaml +++ b/examples/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 examples/README.md -README.md: a502f34128da497586d593f64d0ce1c05f68a067 -README.zh.md: e3c111bb6a8f67899b6f434345baa3b47640b7ee +README.md: 64e9804eb69367588e926791039c453b0a9aede9 +README.zh.md: dd26b9e3f35c2d3350da77ce04bd77b4660ced1b diff --git a/examples/README.md b/examples/README.md index a502f34128..64e9804eb6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,6 +4,10 @@ English | [中文](README.zh.md) Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: either a `cordis.yml` tree that picks swappable backends and loads one app package, or an **overlay** — a patch list `dsh --config` applies over the shipped composition ([`apps/cli/config/base.cordis.yml`](../apps/cli/config/base.cordis.yml) plus a surface overlay). Bundled compositions live in [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle; the `dsh` surfaces use flat config trees instead. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI, and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. +## mcp-memory + +Three default-off reference overlays connect a memory MCP server through the generic MCP client. Pick one file and pass it to `dsh --config`; DSH does not install or configure the upstream memory system. See [mcp-memory/README.md](mcp-memory/README.md) for pinned prerequisites, identity mapping, the shared optional prompt, and the write → fresh-session recall → use verification recipe. + ## headless-agent A non-interactive agent demo that accepts one positional task, runs one complete model/tool turn on the `@deepseek-ai/dsh-cli-demo` app, persists a fresh session, prints `text`, `json`, or `stream-json`, and exits. diff --git a/examples/README.zh.md b/examples/README.zh.md index e3c111bb6a..dd26b9e3f3 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -4,6 +4,10 @@ 展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:要么是一份选择可替换后端、加载一个应用包(package)的 `cordis.yml` 配置树,要么是一个 **overlay**——由 `dsh --config` 叠加到交付组合([`apps/cli/config/base.cordis.yml`](../apps/cli/config/base.cordis.yml) 加一份 surface overlay)之上的 patch 列表。成组的组合位于 [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中;`dsh` 的各 surface 则改用平铺 config tree。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动,无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 +## mcp-memory + +三份默认关闭的参考 overlay 通过通用 MCP 客户端连接一个记忆 MCP 服务器。选择其中一份文件传给 `dsh --config`;DSH 不负责安装或配置上游记忆系统。版本固定的前置条件、身份映射、可选的共用提示词,以及「写入 → 新会话召回 → 使用」验证流程详见 [mcp-memory/README.md](mcp-memory/README.md)。 + ## headless-agent 非交互式 agent(智能体)演示:接受一个位置任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml new file mode 100644 index 0000000000..1793918477 --- /dev/null +++ b/examples/mcp-memory/README.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 examples/mcp-memory/README.md +README.md: 7d1565f240738249b1e08eef135031bf81cc6146 +README.zh.md: 037670ed0110ad46d6f7e1b7160a1c4ae830ed4b diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md new file mode 100644 index 0000000000..7d1565f240 --- /dev/null +++ b/examples/mcp-memory/README.md @@ -0,0 +1,103 @@ +# Third-party memory MCP examples + +English | [中文](README.zh.md) + +These three **default-off reference configurations** connect one memory system to DSH through [`@deepseek-ai/dsh-mcp-client`](../../packages/mcp/mcp-client/README.md). Pick one, or copy the same generic MCP row for another server. + +These third-party configurations are provided as interoperability examples only. Their inclusion does not imply endorsement, recommendation, partnership, or ongoing support by DeepSeek. + +## What DSH does + +DSH parses the selected Cordis overlay, starts a configured stdio command or connects to a configured Streamable HTTP URL, discovers MCP tools, and exposes them as `mcp____`. DSH does **not** download the server, initialize its database, choose its model or embedding provider, create a cloud account, migrate vendor data, or supervise a separate HTTP service. For stdio, the generic client launches and stops the child with the DSH plugin lifecycle; for HTTP, the upstream service must already be running. + +The stdio bridge deliberately removes ambient credential-shaped and `DSH_*` variables before launching a child. Each example explicitly forwards only the variables needed for its baseline. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. + +## Choose one + +| System | Tested pin | Transport | Upstream prerequisite | +|---|---:|---|---| +| [Memorix](https://github.com/AVIDS2/memorix) | `memorix@1.3.0` (`500792cad3144142293bfbb20acb4841c9f7fcfa`) | stdio | Node 22.18+ and `npm install --global memorix@1.3.0` | +| [MCP Reference Memory](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) | `@modelcontextprotocol/server-memory@2026.7.4` (`6dd0a683e198783e30feabf7abaf42f925bd18b1`) | stdio | `npm install --global @modelcontextprotocol/server-memory@2026.7.4` | +| [Engram](https://github.com/Gentleman-Programming/engram) | `v1.20.0` (`ba9e46ced152c37a7cb9e576153c41995873e2fc`) | stdio | Go 1.25.10+ and `go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0`, or the matching release binary | + +## Enable one + +Use a stable user id across sessions, then pass one overlay to DSH: + +```sh +export DSH_MEMORY_USER_ID=alice +dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +``` + +Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--config` keeps all three disabled. + +To keep the selection in personal configuration, merge the chosen file's single `insert` patch into `$DSH_HOME/config.yaml` (normally `~/.dsh/config.yaml`). Do not copy over an existing file: it may already contain unrelated personal patches. + +## Provider setup + +### Memorix + +```sh +npm install --global memorix@1.3.0 +export DSH_MEMORY_USER_ID=alice +dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +``` + +Memorix works in local heuristic mode without an LLM or embedding service. Configure optional providers in Memorix's own `~/.memorix/config.toml` or project `memorix.toml`. The example keeps Memorix's Git-project identity from the DSH working directory and maps `DSH_MEMORY_USER_ID` to a private `MEMORIX_DATA_DIR`. + +### MCP Reference Memory + +```sh +npm install --global @modelcontextprotocol/server-memory@2026.7.4 +export DSH_MEMORY_USER_ID=alice +dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" +``` + +This reference server stores a local knowledge graph and exposes entity, relation, observation, read, search, and open tools. It needs no model or embedding service. The example maps `DSH_MEMORY_USER_ID` to an isolated `MEMORY_FILE_PATH`. + +### Engram + +```sh +go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0 +export DSH_MEMORY_USER_ID=alice +dsh --config "$PWD/examples/mcp-memory/engram.cordis.yml" +``` + +The example maps the user id to an isolated `ENGRAM_DATA_DIR`. Engram still owns project selection: it detects the Git project from the DSH working directory, or accepts `ENGRAM_PROJECT` as an explicit override. + +## Optional shared model instruction + +Add this short, vendor-neutral instruction to your existing model instructions if the server's tool descriptions do not trigger memory use reliably: + +> When the user asks you to remember something, call a memory write tool. When historical information may be relevant, search memory and use relevant results. + +This is additive guidance only. The examples do not replace DSH's system-prompt persona. + +## Verify write, fresh-session recall, and use + +Use one unique value, the same provider scope, and the same `DSH_MEMORY_USER_ID` throughout: + +1. In DSH session A, ask: `Remember that my validation drink is lapsang-.` Confirm the model called the provider's write tool and the tool returned success. +2. Create DSH session B in the same running Host. Do not copy session A's conversation. Ask: `What is my validation drink? Check memory.` Confirm the model called the provider's search or recall tool and returned the value. +3. Still in session B, ask: `Use that preference to suggest one drink for the meeting.` Confirm the answer uses the recalled value. + +A new DSH session is required; a Host restart is not. Restart or HMR is needed only after an MCP child crashes because the current generic client unregisters tools on disconnect and does not auto-reconnect. Initial discovery is asynchronous, so wait for the provider's `mcp__...` tools before sending the first validation prompt. + +## Bring another MCP server + +Copy the same generic shape and use a unique `id` and `serverName`: + +```yaml +- insert: + - id: memory-my-server + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: my-memory + transport: stdio + command: my-memory-mcp + args: [] + env: {} + cwd: !!js process.cwd() +``` + +For a remote server, use `transport: streamable-http`, `url`, and `headers` instead. Provider-specific installation, identity, authentication, models, embeddings, persistence, and licensing remain the provider's responsibility. diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md new file mode 100644 index 0000000000..037670ed01 --- /dev/null +++ b/examples/mcp-memory/README.zh.md @@ -0,0 +1,103 @@ +# 第三方记忆 MCP 示例 + +[English](README.md) | 中文 + +这三份**默认关闭的参考配置** 通过 [`@deepseek-ai/dsh-mcp-client`](../../packages/mcp/mcp-client/README.md) 将一个记忆系统连接到 DSH。请选择其中一份,或复制相同的通用 MCP 配置项来连接其他服务器。 + +这些第三方配置仅作为互操作参考;收录不代表 DeepSeek 的认可、推荐、合作关系或持续支持承诺。 + +## DSH 负责什么 + +DSH 解析选中的 Cordis overlay,启动已配置的 stdio 命令或连接已配置的 Streamable HTTP URL,发现 MCP 工具,并以 `mcp____` 的形式公开这些工具。DSH **不负责** 下载服务器、初始化其数据库、选择模型或 embedding 提供方、创建云端账户、迁移提供方数据,也不监管独立的 HTTP 服务。对于 stdio,通用客户端会随 DSH 插件生命周期启动和停止子进程;对于 HTTP,上游服务必须已经运行。 + +stdio 桥接器在启动子进程前会主动移除环境中名称类似凭据的变量和 `DSH_*` 变量。每份示例仅显式转发其基线运行所需的变量。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 + +## 选择一个 + +| 系统 | 已测试版本 | 传输方式 | 上游前置条件 | +|---|---:|---|---| +| [Memorix](https://github.com/AVIDS2/memorix) | `memorix@1.3.0`(`500792cad3144142293bfbb20acb4841c9f7fcfa`) | stdio | Node 22.18+,并执行 `npm install --global memorix@1.3.0` | +| [MCP Reference Memory](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) | `@modelcontextprotocol/server-memory@2026.7.4`(`6dd0a683e198783e30feabf7abaf42f925bd18b1`) | stdio | `npm install --global @modelcontextprotocol/server-memory@2026.7.4` | +| [Engram](https://github.com/Gentleman-Programming/engram) | `v1.20.0`(`ba9e46ced152c37a7cb9e576153c41995873e2fc`) | stdio | Go 1.25.10+,并执行 `go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0`,或安装匹配的发布版二进制文件 | + +## 启用一个 + +在多个会话间使用一个稳定的用户 id,然后将一份 overlay 传给 DSH: + +```sh +export DSH_MEMORY_USER_ID=alice +dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +``` + +请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--config` 就会让这三项全部保持关闭。 + +如果要把所选配置保存在个人配置中,请将对应文件中的单个 `insert` patch 合并到 `$DSH_HOME/config.yaml`(通常是 `~/.dsh/config.yaml`)。不要覆盖已有文件,其中可能已经包含无关的个人 patch。 + +## 提供方设置 + +### Memorix + +```sh +npm install --global memorix@1.3.0 +export DSH_MEMORY_USER_ID=alice +dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +``` + +Memorix 无需 LLM(大语言模型)或 embedding 服务,即可在本地启发式模式下运行。请在 Memorix 自己的 `~/.memorix/config.toml` 或项目 `memorix.toml` 中配置可选提供方。该示例沿用 DSH 工作目录中的 Git 项目标识,并将 `DSH_MEMORY_USER_ID` 映射到独立的 `MEMORIX_DATA_DIR`。 + +### MCP Reference Memory + +```sh +npm install --global @modelcontextprotocol/server-memory@2026.7.4 +export DSH_MEMORY_USER_ID=alice +dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" +``` + +该参考服务器存储本地知识图谱,并公开实体、关系、观察、读取、搜索和打开工具。它不需要模型或 embedding 服务。该示例将 `DSH_MEMORY_USER_ID` 映射到隔离的 `MEMORY_FILE_PATH`。 + +### Engram + +```sh +go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0 +export DSH_MEMORY_USER_ID=alice +dsh --config "$PWD/examples/mcp-memory/engram.cordis.yml" +``` + +该示例将用户 id 映射到隔离的 `ENGRAM_DATA_DIR`。Engram 仍负责选择项目:它从 DSH 工作目录检测 Git 项目,也接受 `ENGRAM_PROJECT` 作为显式覆盖。 + +## 可选的共用模型指令 + +如果服务器的工具描述无法可靠触发记忆使用,请将以下简短、与提供方无关的指令添加到你现有的模型指令中: + +> 用户要求记住时调用写入工具;涉及历史信息时,主动检索并使用相关记忆。 + +这只是附加指导。示例不会替换 DSH 系统提示词中的 persona。 + +## 验证写入、新会话召回和使用 + +请在整个过程中使用一个唯一值、相同的提供方范围和相同的 `DSH_MEMORY_USER_ID`: + +1. 在 DSH 会话 A 中提出:`Remember that my validation drink is lapsang-.`。确认模型调用了提供方的写入工具,并且工具返回成功。 +2. 在同一个仍在运行的 Host 中创建 DSH 会话 B。不要复制会话 A 的对话。提出:`What is my validation drink? Check memory.`。确认模型调用了提供方的搜索或召回工具,并返回该值。 +3. 继续在会话 B 中提出:`Use that preference to suggest one drink for the meeting.`。确认回答使用了召回的值。 + +必须新建 DSH 会话,但不需要重启 Host。只有 MCP 子进程崩溃后才需要重启或执行 HMR(热模块替换),因为当前的通用客户端会在连接断开时注销工具,且不会自动重连。初始发现过程是异步的,因此发送第一条验证提示词前,请等待提供方的 `mcp__...` 工具出现。 + +## 接入其他 MCP 服务器 + +复制相同的通用结构,并使用唯一的 `id` 和 `serverName`: + +```yaml +- insert: + - id: memory-my-server + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: my-memory + transport: stdio + command: my-memory-mcp + args: [] + env: {} + cwd: !!js process.cwd() +``` + +对于远程服务器,请改用 `transport: streamable-http`、`url` 和 `headers`。提供方专属的安装、身份、认证、模型、embedding、持久化和许可仍由提供方负责。 diff --git a/examples/mcp-memory/engram.cordis.yml b/examples/mcp-memory/engram.cordis.yml new file mode 100644 index 0000000000..100e12ddc7 --- /dev/null +++ b/examples/mcp-memory/engram.cordis.yml @@ -0,0 +1,15 @@ +# Opt-in reference for Engram 1.20.0. Install the pinned `engram` executable +# first; project selection remains Engram's cwd/ENGRAM_PROJECT contract. +- insert: + - id: memory-engram + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: engram + transport: stdio + command: engram + args: [mcp] + cwd: !!js process.cwd() + env: + ENGRAM_PROJECT: !!js process.env.ENGRAM_PROJECT ?? '' + ENGRAM_DATA_DIR: !!js >- + process.env.ENGRAM_DATA_DIR ?? (() => { const path = process.getBuiltinModule('node:path'); const os = process.getBuiltinModule('node:os'); const crypto = process.getBuiltinModule('node:crypto'); const configured = process.env.DSH_HOME; const root = configured !== undefined && configured.trim().length > 0 ? configured : path.join(os.homedir(), '.dsh'); const user = process.env.DSH_MEMORY_USER_ID?.trim() || 'default'; const scope = crypto.createHash('sha256').update(user).digest('hex').slice(0, 16); return path.join(path.resolve(root), 'mcp-memory', 'engram', scope) })() diff --git a/examples/mcp-memory/mcp-reference-memory.cordis.yml b/examples/mcp-memory/mcp-reference-memory.cordis.yml new file mode 100644 index 0000000000..315144e955 --- /dev/null +++ b/examples/mcp-memory/mcp-reference-memory.cordis.yml @@ -0,0 +1,13 @@ +# Opt-in reference for @modelcontextprotocol/server-memory 2026.7.4. Install +# the pinned executable first; DSH starts it but does not run a package manager. +- insert: + - id: memory-mcp-reference + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: reference_memory + transport: stdio + command: mcp-server-memory + cwd: !!js process.cwd() + env: + MEMORY_FILE_PATH: !!js >- + (() => { const path = process.getBuiltinModule('node:path'); const os = process.getBuiltinModule('node:os'); const crypto = process.getBuiltinModule('node:crypto'); const configured = process.env.DSH_HOME; const root = configured !== undefined && configured.trim().length > 0 ? configured : path.join(os.homedir(), '.dsh'); const user = process.env.DSH_MEMORY_USER_ID?.trim() || 'default'; const scope = crypto.createHash('sha256').update(user).digest('hex').slice(0, 16); return path.join(path.resolve(root), `mcp-reference-memory-${scope}.jsonl`) })() diff --git a/examples/mcp-memory/memorix.cordis.yml b/examples/mcp-memory/memorix.cordis.yml new file mode 100644 index 0000000000..4253262d12 --- /dev/null +++ b/examples/mcp-memory/memorix.cordis.yml @@ -0,0 +1,14 @@ +# Opt-in reference for Memorix 1.3.0. Install the pinned `memorix` executable +# first; DSH starts it but does not run a package manager. +- insert: + - id: memory-memorix + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: memorix + transport: stdio + command: memorix + args: [serve] + cwd: !!js process.cwd() + env: + MEMORIX_DATA_DIR: !!js >- + process.env.MEMORIX_DATA_DIR ?? (() => { const path = process.getBuiltinModule('node:path'); const os = process.getBuiltinModule('node:os'); const crypto = process.getBuiltinModule('node:crypto'); const configured = process.env.DSH_HOME; const root = configured !== undefined && configured.trim().length > 0 ? configured : path.join(os.homedir(), '.dsh'); const user = process.env.DSH_MEMORY_USER_ID?.trim() || 'default'; const scope = crypto.createHash('sha256').update(user).digest('hex').slice(0, 16); return path.join(path.resolve(root), 'mcp-memory', 'memorix', scope) })() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8b39ee3ff..a75377b86b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,6 +279,9 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 582da9dc65..bdb020a6a0 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -31,7 +31,10 @@ interface PluginReference { const root = resolve(import.meta.dirname, '..') // These example files are overlays consumed by the built dsh app, so their bare // specifiers resolve from apps/cli rather than the examples workspace. -const appOverlayFiles = new Set(['examples/web-cordis/cordis.yml']) +const appOverlayFiles = new Set([ + 'examples/web-cordis/cordis.yml', + ...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }), +]) const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const /** The adaptive directory-picker chooser package (mounts a backend row at boot). */ From e2fb026dee611efa8f7b0cbbb818da83d6bd55e0 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:08:35 -0700 Subject: [PATCH 11/18] docs(examples): clarify reference memory limits --- examples/mcp-memory/README.i18n.yaml | 4 ++-- examples/mcp-memory/README.md | 2 ++ examples/mcp-memory/README.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index 1793918477..f2b9153850 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/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 examples/mcp-memory/README.md -README.md: 7d1565f240738249b1e08eef135031bf81cc6146 -README.zh.md: 037670ed0110ad46d6f7e1b7160a1c4ae830ed4b +README.md: e0ca7ee4f18d0d8612f8e2ad5b3ba28ac15b2479 +README.zh.md: e5af09a98c8386697acde43b5f3db5438d90d69d diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 7d1565f240..e0ca7ee4f1 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -55,6 +55,8 @@ dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" This reference server stores a local knowledge graph and exposes entity, relation, observation, read, search, and open tools. It needs no model or embedding service. The example maps `DSH_MEMORY_USER_ID` to an isolated `MEMORY_FILE_PATH`. +Search is case-insensitive substring matching over entity names, types, and observations, not semantic retrieval. The server does not add embeddings, automatic summarization, conflict resolution, or a forgetting policy. + ### Engram ```sh diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 037670ed01..e5af09a98c 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -55,6 +55,8 @@ dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" 该参考服务器存储本地知识图谱,并公开实体、关系、观察、读取、搜索和打开工具。它不需要模型或 embedding 服务。该示例将 `DSH_MEMORY_USER_ID` 映射到隔离的 `MEMORY_FILE_PATH`。 +搜索只对实体名称、类型和 observation 进行不区分大小写的子字符串匹配,不是语义检索。该服务器不提供 embedding、自动摘要、冲突消解或遗忘策略。 + ### Engram ```sh From 07f51593a06cb753f835329f9c16a7729f339dcf Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:28:09 -0700 Subject: [PATCH 12/18] docs(examples): simplify memory onboarding --- examples/mcp-memory/README.i18n.yaml | 4 ++-- examples/mcp-memory/README.md | 22 ++++++++++++++++------ examples/mcp-memory/README.zh.md | 22 ++++++++++++++++------ examples/mcp-memory/memorix.cordis.yml | 3 --- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index f2b9153850..b69766153c 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/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 examples/mcp-memory/README.md -README.md: e0ca7ee4f18d0d8612f8e2ad5b3ba28ac15b2479 -README.zh.md: e5af09a98c8386697acde43b5f3db5438d90d69d +README.md: 97d7da66bad3b7b4a14d54a94788108b3831ccc4 +README.zh.md: 5d7c19ae76f10b795dc8e1b39c768851921b0703 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index e0ca7ee4f1..97d7da66ba 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -22,14 +22,25 @@ The stdio bridge deliberately removes ambient credential-shaped and `DSH_*` vari ## Enable one -Use a stable user id across sessions, then pass one overlay to DSH: +Pass one overlay to DSH: ```sh -export DSH_MEMORY_USER_ID=alice dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" ``` -Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--config` keeps all three disabled. +Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. Those two examples also accept `DSH_MEMORY_USER_ID` for stable per-user storage. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--config` keeps all three disabled. + +Without a repository checkout, download the selected overlay directly: + +```sh +mkdir -p "${DSH_HOME:-$HOME/.dsh}" +curl --fail --location \ + --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ + https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml +dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" +``` + +Replace `memorix.cordis.yml` in the URL with either of the other filenames to select it. Review a downloaded overlay before running it: Cordis configuration can contain executable `!!js` expressions. To keep the selection in personal configuration, merge the chosen file's single `insert` patch into `$DSH_HOME/config.yaml` (normally `~/.dsh/config.yaml`). Do not copy over an existing file: it may already contain unrelated personal patches. @@ -39,11 +50,10 @@ To keep the selection in personal configuration, merge the chosen file's single ```sh npm install --global memorix@1.3.0 -export DSH_MEMORY_USER_ID=alice dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" ``` -Memorix works in local heuristic mode without an LLM or embedding service. Configure optional providers in Memorix's own `~/.memorix/config.toml` or project `memorix.toml`. The example keeps Memorix's Git-project identity from the DSH working directory and maps `DSH_MEMORY_USER_ID` to a private `MEMORIX_DATA_DIR`. +Memorix works in local heuristic mode without an LLM or embedding service. Configure optional providers in Memorix's own `~/.memorix/config.toml` or project `memorix.toml`. The example keeps Memorix's Git-project identity from the DSH working directory and uses Memorix's own `~/.memorix/data` default. Set `MEMORIX_DATA_DIR` before starting DSH to override it. ### MCP Reference Memory @@ -77,7 +87,7 @@ This is additive guidance only. The examples do not replace DSH's system-prompt ## Verify write, fresh-session recall, and use -Use one unique value, the same provider scope, and the same `DSH_MEMORY_USER_ID` throughout: +Use one unique value and keep the provider's storage scope unchanged throughout: 1. In DSH session A, ask: `Remember that my validation drink is lapsang-.` Confirm the model called the provider's write tool and the tool returned success. 2. Create DSH session B in the same running Host. Do not copy session A's conversation. Ask: `What is my validation drink? Check memory.` Confirm the model called the provider's search or recall tool and returned the value. diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index e5af09a98c..5d7c19ae76 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -22,14 +22,25 @@ stdio 桥接器在启动子进程前会主动移除环境中名称类似凭据 ## 启用一个 -在多个会话间使用一个稳定的用户 id,然后将一份 overlay 传给 DSH: +将一份 overlay 传给 DSH: ```sh -export DSH_MEMORY_USER_ID=alice dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" ``` -请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--config` 就会让这三项全部保持关闭。 +请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。另外两份示例也接受 `DSH_MEMORY_USER_ID`,用于稳定的逐用户存储。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--config` 就会让这三项全部保持关闭。 + +如果本地没有仓库 checkout,可直接下载所选 overlay: + +```sh +mkdir -p "${DSH_HOME:-$HOME/.dsh}" +curl --fail --location \ + --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ + https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml +dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" +``` + +若要选择另外任一配置,请将 URL 中的 `memorix.cordis.yml` 替换为对应文件名。运行下载的 overlay 前,请先审阅其内容:Cordis 配置可以包含可执行的 `!!js` 表达式。 如果要把所选配置保存在个人配置中,请将对应文件中的单个 `insert` patch 合并到 `$DSH_HOME/config.yaml`(通常是 `~/.dsh/config.yaml`)。不要覆盖已有文件,其中可能已经包含无关的个人 patch。 @@ -39,11 +50,10 @@ dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" ```sh npm install --global memorix@1.3.0 -export DSH_MEMORY_USER_ID=alice dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" ``` -Memorix 无需 LLM(大语言模型)或 embedding 服务,即可在本地启发式模式下运行。请在 Memorix 自己的 `~/.memorix/config.toml` 或项目 `memorix.toml` 中配置可选提供方。该示例沿用 DSH 工作目录中的 Git 项目标识,并将 `DSH_MEMORY_USER_ID` 映射到独立的 `MEMORIX_DATA_DIR`。 +Memorix 无需 LLM(大语言模型)或 embedding 服务,即可在本地启发式模式下运行。请在 Memorix 自己的 `~/.memorix/config.toml` 或项目 `memorix.toml` 中配置可选提供方。该示例沿用 DSH 工作目录中的 Git 项目标识,并使用 Memorix 自身的默认目录 `~/.memorix/data`。若要覆盖该目录,请在启动 DSH 前设置 `MEMORIX_DATA_DIR`。 ### MCP Reference Memory @@ -77,7 +87,7 @@ dsh --config "$PWD/examples/mcp-memory/engram.cordis.yml" ## 验证写入、新会话召回和使用 -请在整个过程中使用一个唯一值、相同的提供方范围和相同的 `DSH_MEMORY_USER_ID`: +请在整个过程中使用一个唯一值,并保持提供方的存储范围不变: 1. 在 DSH 会话 A 中提出:`Remember that my validation drink is lapsang-.`。确认模型调用了提供方的写入工具,并且工具返回成功。 2. 在同一个仍在运行的 Host 中创建 DSH 会话 B。不要复制会话 A 的对话。提出:`What is my validation drink? Check memory.`。确认模型调用了提供方的搜索或召回工具,并返回该值。 diff --git a/examples/mcp-memory/memorix.cordis.yml b/examples/mcp-memory/memorix.cordis.yml index 4253262d12..c993581eae 100644 --- a/examples/mcp-memory/memorix.cordis.yml +++ b/examples/mcp-memory/memorix.cordis.yml @@ -9,6 +9,3 @@ command: memorix args: [serve] cwd: !!js process.cwd() - env: - MEMORIX_DATA_DIR: !!js >- - process.env.MEMORIX_DATA_DIR ?? (() => { const path = process.getBuiltinModule('node:path'); const os = process.getBuiltinModule('node:os'); const crypto = process.getBuiltinModule('node:crypto'); const configured = process.env.DSH_HOME; const root = configured !== undefined && configured.trim().length > 0 ? configured : path.join(os.homedir(), '.dsh'); const user = process.env.DSH_MEMORY_USER_ID?.trim() || 'default'; const scope = crypto.createHash('sha256').update(user).digest('hex').slice(0, 16); return path.join(path.resolve(root), 'mcp-memory', 'memorix', scope) })() From 0ee3d752fa1554467cbfe35fa08b2e682c4245f0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:31:41 +0800 Subject: [PATCH 13/18] test(web): smoke Core Web profile tools --- apps/web/tests/core-web-profile.snapshot.ts | 64 ++++++++++++++++++--- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts index 355988eb34..58f2a34858 100644 --- a/apps/web/tests/core-web-profile.snapshot.ts +++ b/apps/web/tests/core-web-profile.snapshot.ts @@ -1,29 +1,79 @@ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url)) describe('core Web profile', () => { let scaffold: WebScaffold + let agentHandle: AgentHandle beforeAll(async () => { scaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, toolsMode: 'native', }) + agentHandle = await scaffold.ctx.agents.create({ + sessionId: SessionId('core-web-profile-smoke'), + meta: { cwd: scaffold.workspaceCwd }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) }) afterAll(async () => { - await scaffold?.close() + const failures: unknown[] = [] + await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed') }) - it('boots the shipped Web composition with only persistent Bash and the string-replace editor', () => { - expect(scaffold.ctx.tools.schemas().map(tool => tool.name)).toMatchInlineSnapshot(` - [ - "bash", - "str_replace_editor", - ] + it('boots and executes both tools through the shipped Web composition', async () => { + const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt') + await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n') + const signal = new AbortController().signal + const bash = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('core-web-bash-smoke'), + name: 'bash', + arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" }, + agent: agentHandle.agent, + }) + const editor = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('core-web-editor-smoke'), + name: 'str_replace_editor', + arguments: { command: 'view', path: seedPath }, + agent: agentHandle.agent, + }) + + const text = (result: typeof bash): string => result.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .replaceAll(scaffold.workspaceCwd, '{{cwd}}') + .trimEnd() + + expect({ + tools: scaffold.ctx.tools.schemas().map(tool => tool.name), + bash: text(bash), + editor: text(editor), + }).toMatchInlineSnapshot(` + { + "bash": "CORE_WEB_BASH_OK", + "editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines): + 1 CORE_WEB_EDITOR_OK + 2", + "tools": [ + "bash", + "str_replace_editor", + ], + } `) const entries = [...scaffold.ctx.loader.entries()] From dc7f3253f689c01f9a661b16dd0662eb972bb5ff Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:34:52 -0700 Subject: [PATCH 14/18] fix(examples): keep memory configs provider-owned --- ...6-07-31-third-party-memory-mcp-examples.i18n.yaml | 4 ++-- .../2026-07-31-third-party-memory-mcp-examples.md | 8 ++++---- .../2026-07-31-third-party-memory-mcp-examples.zh.md | 8 ++++---- apps/cli/tests/memory-mcp-configs.spec.ts | 4 ++-- examples/mcp-memory/README.i18n.yaml | 4 ++-- examples/mcp-memory/README.md | 12 +++++------- examples/mcp-memory/README.zh.md | 12 +++++------- examples/mcp-memory/engram.cordis.yml | 6 +----- examples/mcp-memory/mcp-reference-memory.cordis.yml | 2 +- packages/mcp/mcp-client/README.i18n.yaml | 4 ++-- packages/mcp/mcp-client/README.md | 2 +- packages/mcp/mcp-client/README.zh.md | 2 +- 12 files changed, 30 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml index 9115d89318..0eddfa1740 100644 --- a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md -2026-07-31-third-party-memory-mcp-examples.md: 512a222d1f8406d11ef5c58c5f2749c9b571d846 -2026-07-31-third-party-memory-mcp-examples.zh.md: 8bdc4ae371b7b3c4a8e78eeceed1f965e515a475 +2026-07-31-third-party-memory-mcp-examples.md: e82d65a3a5a60cafcc47df5a6873cf5768cd8b8f +2026-07-31-third-party-memory-mcp-examples.zh.md: ee0e9f2e1378f9787e09e32cc05dd13a3648254c diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md index 512a222d1f..e82d65a3a5 100644 --- a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md @@ -27,9 +27,9 @@ These third-party configurations are provided as interoperability examples only. | Account, auth, model, embedding, storage initialization | No | Yes | | Vendor data migration, retry, crash recovery | No | Yes | -The generic stdio transport scrubs ambient credential-shaped and `DSH_*` variables. Baseline examples explicitly map only the variables they require; optional provider secrets must be added to `config.env` or configured in the provider's own files. +The generic stdio transport scrubs ambient credential-shaped and `DSH_*` variables while inheriting other ambient variables. Baseline examples add only required overrides; optional provider secrets must be added to `config.env` or configured in the provider's own files. -## Pins and identity +## Pins, storage, and identity | Provider | Tested contract | |---|---| @@ -37,7 +37,7 @@ The generic stdio transport scrubs ambient credential-shaped and `DSH_*` variabl | MCP Reference Memory | npm `2026.7.4`, package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` | | Engram | tag `v1.20.0`, commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` | -`DSH_MEMORY_USER_ID` is a stable user partition, not a DSH session id. Each example maps it to a separate provider data path under `$DSH_HOME`. +Storage remains provider-owned. Memorix uses `~/.memorix/data` and Engram uses `~/.engram` by default. The Reference Memory example sets a stable `$HOME/.dsh-mcp-reference-memory.jsonl` path instead of writing into the installed npm package directory. Each provider's own environment variable can override these locations before DSH starts. Project identity remains provider-owned: Memorix and Engram use the DSH working directory's Git project, with Engram optionally accepting `ENGRAM_PROJECT`. @@ -56,7 +56,7 @@ Remote CI never contacts third-party services or consumes secrets. The keyless s Before merge, manual evidence for every pinned provider must separately show: 1. DSH session A calls a write tool and receives success for a unique value. -2. Fresh DSH session B, under the same provider/user scope, calls search or recall and returns that value without session A's transcript. +2. Fresh DSH session B, under the same provider storage scope, calls search or recall and returns that value without session A's transcript. 3. Session B uses the recalled value in a subsequent answer. "Fresh session" means a new DSH session in the same Host. No Host restart is required. The generic MCP client discovers asynchronously and has no automatic reconnect after a child or HTTP transport closes; validation waits for tools before the first turn and uses HMR or a Host restart only after a crash. diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md index 8bdc4ae371..ee0e9f2e13 100644 --- a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md @@ -27,9 +27,9 @@ Status: implemented | 账户、认证、模型、embedding、存储初始化 | 否 | 是 | | 提供方数据迁移、重试、崩溃恢复 | 否 | 是 | -通用 stdio 传输会清除环境中名称类似凭据的变量和 `DSH_*` 变量。基线示例仅显式映射自己需要的变量;可选的提供方密钥必须添加到 `config.env`,或配置在提供方自己的文件中。 +通用 stdio 传输会清除环境中名称类似凭据的变量和 `DSH_*` 变量,同时继承其他环境变量。基线示例仅添加必需的覆盖项;可选的提供方密钥必须添加到 `config.env`,或配置在提供方自己的文件中。 -## 版本固定与身份 +## 版本固定、存储与身份 | 提供方 | 已测试契约 | |---|---| @@ -37,7 +37,7 @@ Status: implemented | MCP Reference Memory | npm `2026.7.4`,package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` | | Engram | tag `v1.20.0`,commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` | -`DSH_MEMORY_USER_ID` 是稳定的用户分区,不是 DSH 会话 id。每份示例都将其映射到 `$DSH_HOME` 下相互独立的提供方数据路径。 +存储仍由提供方负责。Memorix 默认使用 `~/.memorix/data`,Engram 默认使用 `~/.engram`。Reference Memory 示例设置稳定的 `$HOME/.dsh-mcp-reference-memory.jsonl` 路径,而不是写入已安装的 npm 包(package)目录。每个提供方自己的环境变量都可以在 DSH 启动前覆盖这些位置。 项目身份仍由提供方负责:Memorix 和 Engram 使用 DSH 工作目录中的 Git 项目,其中 Engram 还可以选择接受 `ENGRAM_PROJECT`。 @@ -56,7 +56,7 @@ Status: implemented 合并前,每个固定版本的提供方都必须分别提供以下人工证据: 1. DSH 会话 A 调用写入工具,为一个唯一值写入记忆,并收到成功结果。 -2. 新的 DSH 会话 B 在相同提供方/用户范围下调用搜索或召回,不借助会话 A 的 transcript(文本记录)便可返回该值。 +2. 新的 DSH 会话 B 在相同的提供方存储范围下调用搜索或召回,不借助会话 A 的 transcript(文本记录)便可返回该值。 3. 会话 B 在后续回答中使用该召回值。 「新会话」是指同一个 Host 中新建的 DSH 会话,不需要重启 Host。通用 MCP 客户端以异步方式发现工具,子进程或 HTTP 传输关闭后不会自动重连;验证会在第一轮之前等待工具出现,并且只在崩溃后使用 HMR 或重启 Host。 diff --git a/apps/cli/tests/memory-mcp-configs.spec.ts b/apps/cli/tests/memory-mcp-configs.spec.ts index a8940a13ae..7069b22738 100644 --- a/apps/cli/tests/memory-mcp-configs.spec.ts +++ b/apps/cli/tests/memory-mcp-configs.spec.ts @@ -90,7 +90,7 @@ describe('third-party memory MCP example overlays', () => { expect(row.name).toBe('@deepseek-ai/dsh-mcp-client') expect(row.config?.serverName).toBe(contract.serverName) expect(row.config?.transport).toBe(contract.transport) - expect(source).toContain(contract.pin) + expect(source.split('\n', 1)[0]).toContain(contract.pin) expect(source).not.toMatch(/\bsk-[A-Za-z0-9_-]{8,}\b/) expect(source).not.toContain('DEEPSEEK_API_KEY') }) @@ -121,12 +121,12 @@ describe('third-party memory MCP example overlays', () => { baseConfig, [...patches, fixturePatch], (ctx) => { + liveContexts.add(ctx) ctx.loader.builtins['memory-test-system-prompt'] = SystemPrompt ctx.loader.builtins['memory-test-tools'] = ToolRegistry ctx.loader.builtins['memory-test-mcp-client'] = McpClient }, ) - liveContexts.add(ctx) await waitForTool(ctx, `mcp__${contract.serverName}__greet`) }, 15_000) }) diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index b69766153c..def44e65e3 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/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 examples/mcp-memory/README.md -README.md: 97d7da66bad3b7b4a14d54a94788108b3831ccc4 -README.zh.md: 5d7c19ae76f10b795dc8e1b39c768851921b0703 +README.md: b5dd7ffc4ad248d38e108d9aa28c7c26e0c76913 +README.zh.md: 1249ae40bb344fc81836cb49d71dd5656457b1b3 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 97d7da66ba..b5dd7ffc4a 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -10,7 +10,7 @@ These third-party configurations are provided as interoperability examples only. DSH parses the selected Cordis overlay, starts a configured stdio command or connects to a configured Streamable HTTP URL, discovers MCP tools, and exposes them as `mcp____`. DSH does **not** download the server, initialize its database, choose its model or embedding provider, create a cloud account, migrate vendor data, or supervise a separate HTTP service. For stdio, the generic client launches and stops the child with the DSH plugin lifecycle; for HTTP, the upstream service must already be running. -The stdio bridge deliberately removes ambient credential-shaped and `DSH_*` variables before launching a child. Each example explicitly forwards only the variables needed for its baseline. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. +The stdio bridge deliberately removes ambient credential-shaped and `DSH_*` variables before launching a child; other ambient variables remain inherited. Each example adds only the baseline override it needs. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. ## Choose one @@ -28,7 +28,7 @@ Pass one overlay to DSH: dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" ``` -Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. Those two examples also accept `DSH_MEMORY_USER_ID` for stable per-user storage. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--config` keeps all three disabled. +Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--config` keeps all three disabled. Without a repository checkout, download the selected overlay directly: @@ -59,11 +59,10 @@ Memorix works in local heuristic mode without an LLM or embedding service. Confi ```sh npm install --global @modelcontextprotocol/server-memory@2026.7.4 -export DSH_MEMORY_USER_ID=alice dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" ``` -This reference server stores a local knowledge graph and exposes entity, relation, observation, read, search, and open tools. It needs no model or embedding service. The example maps `DSH_MEMORY_USER_ID` to an isolated `MEMORY_FILE_PATH`. +This reference server stores a local knowledge graph and exposes entity, relation, observation, read, search, and open tools. It needs no model or embedding service. The example stores its JSONL at `$HOME/.dsh-mcp-reference-memory.jsonl` instead of the installed npm package directory. Set `MEMORY_FILE_PATH` before starting DSH to override it. Search is case-insensitive substring matching over entity names, types, and observations, not semantic retrieval. The server does not add embeddings, automatic summarization, conflict resolution, or a forgetting policy. @@ -71,11 +70,10 @@ Search is case-insensitive substring matching over entity names, types, and obse ```sh go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0 -export DSH_MEMORY_USER_ID=alice dsh --config "$PWD/examples/mcp-memory/engram.cordis.yml" ``` -The example maps the user id to an isolated `ENGRAM_DATA_DIR`. Engram still owns project selection: it detects the Git project from the DSH working directory, or accepts `ENGRAM_PROJECT` as an explicit override. +Engram owns storage and project selection: it uses `~/.engram` by default, detects the Git project from the DSH working directory, and accepts `ENGRAM_DATA_DIR` or `ENGRAM_PROJECT` as ambient overrides. ## Optional shared model instruction @@ -93,7 +91,7 @@ Use one unique value and keep the provider's storage scope unchanged throughout: 2. Create DSH session B in the same running Host. Do not copy session A's conversation. Ask: `What is my validation drink? Check memory.` Confirm the model called the provider's search or recall tool and returned the value. 3. Still in session B, ask: `Use that preference to suggest one drink for the meeting.` Confirm the answer uses the recalled value. -A new DSH session is required; a Host restart is not. Restart or HMR is needed only after an MCP child crashes because the current generic client unregisters tools on disconnect and does not auto-reconnect. Initial discovery is asynchronous, so wait for the provider's `mcp__...` tools before sending the first validation prompt. +A new DSH session is required; a Host restart is not. Restart or HMR is needed only after an MCP child crashes because the current generic client does not auto-reconnect; its tool registrations remain until plugin disposal or a successful re-sync, and calls can fail against the closed transport. Initial discovery is asynchronous, so wait for the provider's `mcp__...` tools before sending the first validation prompt. ## Bring another MCP server diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 5d7c19ae76..1249ae40bb 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -10,7 +10,7 @@ DSH 解析选中的 Cordis overlay,启动已配置的 stdio 命令或连接已配置的 Streamable HTTP URL,发现 MCP 工具,并以 `mcp____` 的形式公开这些工具。DSH **不负责** 下载服务器、初始化其数据库、选择模型或 embedding 提供方、创建云端账户、迁移提供方数据,也不监管独立的 HTTP 服务。对于 stdio,通用客户端会随 DSH 插件生命周期启动和停止子进程;对于 HTTP,上游服务必须已经运行。 -stdio 桥接器在启动子进程前会主动移除环境中名称类似凭据的变量和 `DSH_*` 变量。每份示例仅显式转发其基线运行所需的变量。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 +stdio 桥接器在启动子进程前会主动移除环境中名称类似凭据的变量和 `DSH_*` 变量;其余环境变量仍会继承。每份示例仅添加其基线所需的覆盖项。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 ## 选择一个 @@ -28,7 +28,7 @@ stdio 桥接器在启动子进程前会主动移除环境中名称类似凭据 dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" ``` -请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。另外两份示例也接受 `DSH_MEMORY_USER_ID`,用于稳定的逐用户存储。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--config` 就会让这三项全部保持关闭。 +请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--config` 就会让这三项全部保持关闭。 如果本地没有仓库 checkout,可直接下载所选 overlay: @@ -59,11 +59,10 @@ Memorix 无需 LLM(大语言模型)或 embedding 服务,即可在本地启 ```sh npm install --global @modelcontextprotocol/server-memory@2026.7.4 -export DSH_MEMORY_USER_ID=alice dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" ``` -该参考服务器存储本地知识图谱,并公开实体、关系、观察、读取、搜索和打开工具。它不需要模型或 embedding 服务。该示例将 `DSH_MEMORY_USER_ID` 映射到隔离的 `MEMORY_FILE_PATH`。 +该参考服务器存储本地知识图谱,并公开实体、关系、观察、读取、搜索和打开工具。它不需要模型或 embedding 服务。该示例将 JSONL 存储在 `$HOME/.dsh-mcp-reference-memory.jsonl`,而不是已安装的 npm 包(package)目录中。若要覆盖该路径,请在启动 DSH 前设置 `MEMORY_FILE_PATH`。 搜索只对实体名称、类型和 observation 进行不区分大小写的子字符串匹配,不是语义检索。该服务器不提供 embedding、自动摘要、冲突消解或遗忘策略。 @@ -71,11 +70,10 @@ dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" ```sh go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0 -export DSH_MEMORY_USER_ID=alice dsh --config "$PWD/examples/mcp-memory/engram.cordis.yml" ``` -该示例将用户 id 映射到隔离的 `ENGRAM_DATA_DIR`。Engram 仍负责选择项目:它从 DSH 工作目录检测 Git 项目,也接受 `ENGRAM_PROJECT` 作为显式覆盖。 +Engram 负责存储和项目选择:它默认使用 `~/.engram`,从 DSH 工作目录检测 Git 项目,并接受 `ENGRAM_DATA_DIR` 或 `ENGRAM_PROJECT` 作为环境覆盖项。 ## 可选的共用模型指令 @@ -93,7 +91,7 @@ dsh --config "$PWD/examples/mcp-memory/engram.cordis.yml" 2. 在同一个仍在运行的 Host 中创建 DSH 会话 B。不要复制会话 A 的对话。提出:`What is my validation drink? Check memory.`。确认模型调用了提供方的搜索或召回工具,并返回该值。 3. 继续在会话 B 中提出:`Use that preference to suggest one drink for the meeting.`。确认回答使用了召回的值。 -必须新建 DSH 会话,但不需要重启 Host。只有 MCP 子进程崩溃后才需要重启或执行 HMR(热模块替换),因为当前的通用客户端会在连接断开时注销工具,且不会自动重连。初始发现过程是异步的,因此发送第一条验证提示词前,请等待提供方的 `mcp__...` 工具出现。 +必须新建 DSH 会话,但不需要重启 Host。只有 MCP 子进程崩溃后才需要重启或执行 HMR(热模块替换),因为当前的通用客户端不会自动重连;其工具注册会一直保留到插件完成资源释放或成功重新同步,针对已关闭传输的调用可能失败。初始发现过程是异步的,因此发送第一条验证提示词前,请等待提供方的 `mcp__...` 工具出现。 ## 接入其他 MCP 服务器 diff --git a/examples/mcp-memory/engram.cordis.yml b/examples/mcp-memory/engram.cordis.yml index 100e12ddc7..018df88b6f 100644 --- a/examples/mcp-memory/engram.cordis.yml +++ b/examples/mcp-memory/engram.cordis.yml @@ -1,5 +1,5 @@ # Opt-in reference for Engram 1.20.0. Install the pinned `engram` executable -# first; project selection remains Engram's cwd/ENGRAM_PROJECT contract. +# first; storage and project selection remain Engram-owned. - insert: - id: memory-engram name: '@deepseek-ai/dsh-mcp-client' @@ -9,7 +9,3 @@ command: engram args: [mcp] cwd: !!js process.cwd() - env: - ENGRAM_PROJECT: !!js process.env.ENGRAM_PROJECT ?? '' - ENGRAM_DATA_DIR: !!js >- - process.env.ENGRAM_DATA_DIR ?? (() => { const path = process.getBuiltinModule('node:path'); const os = process.getBuiltinModule('node:os'); const crypto = process.getBuiltinModule('node:crypto'); const configured = process.env.DSH_HOME; const root = configured !== undefined && configured.trim().length > 0 ? configured : path.join(os.homedir(), '.dsh'); const user = process.env.DSH_MEMORY_USER_ID?.trim() || 'default'; const scope = crypto.createHash('sha256').update(user).digest('hex').slice(0, 16); return path.join(path.resolve(root), 'mcp-memory', 'engram', scope) })() diff --git a/examples/mcp-memory/mcp-reference-memory.cordis.yml b/examples/mcp-memory/mcp-reference-memory.cordis.yml index 315144e955..d89fbe2673 100644 --- a/examples/mcp-memory/mcp-reference-memory.cordis.yml +++ b/examples/mcp-memory/mcp-reference-memory.cordis.yml @@ -10,4 +10,4 @@ cwd: !!js process.cwd() env: MEMORY_FILE_PATH: !!js >- - (() => { const path = process.getBuiltinModule('node:path'); const os = process.getBuiltinModule('node:os'); const crypto = process.getBuiltinModule('node:crypto'); const configured = process.env.DSH_HOME; const root = configured !== undefined && configured.trim().length > 0 ? configured : path.join(os.homedir(), '.dsh'); const user = process.env.DSH_MEMORY_USER_ID?.trim() || 'default'; const scope = crypto.createHash('sha256').update(user).digest('hex').slice(0, 16); return path.join(path.resolve(root), `mcp-reference-memory-${scope}.jsonl`) })() + process.env.MEMORY_FILE_PATH?.trim() || process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:os').homedir(), '.dsh-mcp-reference-memory.jsonl') diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 487742d907..c16e004674 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-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/mcp/mcp-client/README.md -README.md: 82d974635cb35878d6f0365b1aa7a9745436240e -README.zh.md: e687c620158955f954dcfebf682503225ee710de +README.md: fe6531054b00a72050b989013297bf14db85ac66 +README.zh.md: 61a1f555e2355a9761f76ac079e90dad675d6914 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 82d974635c..fe6531054b 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -61,7 +61,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` - Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server. - Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. - Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders. -- On disconnect/crash: all tools are unregistered; no auto-reconnect. +- On disconnect/crash: no auto-reconnect. Registered tools remain until plugin disposal or a successful re-sync, and calls can fail against the closed transport; reload with HMR or restart the Host to reconnect. ## Services consumed diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index e687c62015..61a1f555e2 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -61,7 +61,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc - 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。 - 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。 - Native/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。 -- 断开/崩溃时:注销所有工具;不自动重新连接。 +- 断开/崩溃时:不自动重新连接。已注册工具会一直保留到插件完成资源释放或成功重新同步,针对已关闭传输的调用可能失败;请通过 HMR 重新加载或重启 Host 来重新连接。 ## 消费的服务 From e7d31c64db29ebafe291665e8c0e7d69ef6d52a5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:54:17 +0800 Subject: [PATCH 15/18] fix(cli): keep Core Web profile minimal --- apps/cli/config/core-web.cordis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index 4713a8b1a7..78bbb84085 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -16,6 +16,9 @@ - id: tool-fs-search disabled: true +- id: tool-web + disabled: true + - id: tool-skill disabled: true From 827a5e2b32d5363d1f434253ab0a78a17f24d979 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:55:07 +0800 Subject: [PATCH 16/18] fix(cli): keep Core Web profile at two tools --- apps/cli/config/core-web.cordis.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index 4713a8b1a7..d2c0482b28 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -34,6 +34,9 @@ - id: tool-todo disabled: true +- id: tool-web + disabled: true + # The matching browser controls must not offer host tools that this profile # omits. ui-question's host half owns the ask_user_question registration. - id: ui-plan @@ -47,7 +50,10 @@ name: '@deepseek-ai/dsh-pty' # This backend consumes the existing Web sandbox and permission policy. - # An open persistent shell fences permission-mode changes until it closes. + # It loads only on Linux/macOS; Windows and other platforms fail at boot. + # Its 300s send wait matches the persistent Bash command timeout instead of + # pty-local's 30s default. An open persistent shell fences permission-mode + # changes until it closes. - id: pty-local name: '@deepseek-ai/dsh-pty-local' config: From 1cacef8bac190ae5536252c83c79d7003d6fffa4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:56:28 +0800 Subject: [PATCH 17/18] fix(cli): deduplicate Core Web overlay patch --- apps/cli/config/core-web.cordis.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index a90e4cbc6a..347a5bfbe6 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -37,9 +37,6 @@ - id: tool-todo disabled: true -- id: tool-web - disabled: true - # The matching browser controls must not offer host tools that this profile # omits. ui-question's host half owns the ask_user_question registration. - id: ui-plan From 9aeae0e422340851ce92893e67482590b2be6686 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:09:29 -0700 Subject: [PATCH 18/18] docs(mcp): correct crash recovery wording --- packages/mcp/mcp-client/README.i18n.yaml | 4 ++-- packages/mcp/mcp-client/README.md | 2 +- packages/mcp/mcp-client/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index c16e004674..9fa4504cca 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-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/mcp/mcp-client/README.md -README.md: fe6531054b00a72050b989013297bf14db85ac66 -README.zh.md: 61a1f555e2355a9761f76ac079e90dad675d6914 +README.md: d7966595c68ff1ec4a288caf5d9fe4b0bf580cc5 +README.zh.md: eb9e0dbdb48423cc4bc698fda355e973e42bc7a3 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index fe6531054b..d7966595c6 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -103,6 +103,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered. - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred. -- **Crash recovery is manual** — transport closure unregisters the server's tools, but reconnect requires an HMR reload or harness restart. +- **Crash recovery is manual** — transport closure does not auto-reconnect; registered tools can remain visible but fail against the closed transport until an HMR reload or Host restart. - **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred. - **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index 61a1f555e2..eb9e0dbdb4 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -103,6 +103,6 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc - **初始发现是异步的**:插件加载不会等待连接和 `listTools()`,因此在启动或 HMR 后立即开始的轮次可能在 MCP 工具注册前完成组装。 - **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。 -- **崩溃恢复需要手动触发**:传输关闭会注销服务器工具,但重新连接需要 HMR 重载或重启 harness。 +- **崩溃恢复需要手动触发**:传输关闭后不会自动重新连接;已注册工具可能仍然可见,但会因传输已关闭而调用失败,直到 HMR 重载或重启 Host。 - **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。 - **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。