@sparkelf/dsh-plus 0.2.1-rc.2.4 → 0.2.2-alpha.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +7 -5
  2. package/README.zh.md +7 -5
  3. package/lib/apply-08huHfLG.js +665 -0
  4. package/lib/apply-DmICvIGh.js +653 -0
  5. package/lib/apply.js +1 -1
  6. package/lib/bin.js +133 -37
  7. package/lib/types/apply.d.ts +1 -1
  8. package/lib/types/apply.js +56 -117
  9. package/lib/types/patch-set.d.ts +59 -0
  10. package/lib/types/patch-set.js +132 -0
  11. package/lib/types/standalone-cli.js +56 -2
  12. package/lib/types/standalone-profile.d.ts +8 -1
  13. package/lib/types/standalone-profile.js +84 -38
  14. package/package.json +625 -175
  15. package/patches/approval-allow-policy.patch +108 -0
  16. package/patches/atomic-write-zombie-lock-holder.patch +53 -0
  17. package/patches/bootstrap-exempt-names.patch +32 -0
  18. package/patches/browser-auth-mode.patch +127 -0
  19. package/patches/composer-popover-boundaries.patch +337 -0
  20. package/patches/diagram-preview.patch +562 -0
  21. package/patches/image-offload-hoist-replay.patch +533 -0
  22. package/patches/legacy-code-preset.patch +148 -0
  23. package/patches/llm-pi-ai-transport-truncation.patch +33 -0
  24. package/patches/mobile-journal-generation.patch +76 -0
  25. package/patches/office-preview-stable-load.patch +34 -0
  26. package/patches/officecli-deliverables.patch +42 -0
  27. package/patches/ptc-mcp-schema-types.patch +156 -0
  28. package/patches/responses-reasoning-status.patch +289 -0
  29. package/patches/session-envelope-ignorable.patch +63 -0
  30. package/patches/session-export-chinese.patch +85 -0
  31. package/patches/session-format-legacy-restart.patch +35 -0
  32. package/patches/session-log-trajectory-toolbar.patch +352 -0
  33. package/patches/state-dot-brand-blue.patch +185 -0
  34. package/patches/ui-settings-compact-phone.patch +76 -0
  35. package/patches/web-runtime-compat.patch +83 -0
  36. package/patches/workspace-storage-restore.patch +186 -0
  37. package/patches/wsl-native-open.patch +265 -0
@@ -0,0 +1,337 @@
1
+ diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts
2
+ index f97978d346..edab518be0 100644
3
+ --- a/apps/web/tests/access-confirmation.e2e.ts
4
+ +++ b/apps/web/tests/access-confirmation.e2e.ts
5
+ @@ -151,6 +151,12 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
6
+ expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:工作区内修改')
7
+
8
+ await access.click()
9
+ + const permissionMenu = await page.getByRole('menu').boundingBox()
10
+ + const permissionCenter = await page.locator('[data-dsh-center-col]').boundingBox()
11
+ + expect(permissionMenu).not.toBeNull()
12
+ + expect(permissionCenter).not.toBeNull()
13
+ + expect(permissionMenu!.x).toBeGreaterThanOrEqual(permissionCenter!.x + 11)
14
+ + expect(permissionMenu!.x + permissionMenu!.width).toBeLessThanOrEqual(permissionCenter!.x + permissionCenter!.width - 11)
15
+ await page.getByRole('menuitem', { name: '完全权限' }).click()
16
+ const dialog = page.getByRole('dialog', { name: '确认启用完全权限?' })
17
+ await dialog.waitFor({ timeout: 10_000 })
18
+ diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts
19
+ index 9625333287..c71f8c02de 100644
20
+ --- a/apps/web/tests/default-model.e2e.ts
21
+ +++ b/apps/web/tests/default-model.e2e.ts
22
+ @@ -480,6 +480,13 @@ describe('web e2e: the composer model switch is the default for later sessions',
23
+ await trigger.waitFor({ timeout: 15_000 })
24
+ expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400')
25
+ await trigger.click()
26
+ + // The composer popover must stay inside the centre column rather than touching its edges.
27
+ + const boundaryMenu = await page.getByRole('menu').boundingBox()
28
+ + const boundaryCenter = await page.locator('[data-dsh-center-col]').boundingBox()
29
+ + expect(boundaryMenu).not.toBeNull()
30
+ + expect(boundaryCenter).not.toBeNull()
31
+ + expect(boundaryMenu!.x).toBeGreaterThanOrEqual(boundaryCenter!.x + 11)
32
+ + expect(boundaryMenu!.x + boundaryMenu!.width).toBeLessThanOrEqual(boundaryCenter!.x + boundaryCenter!.width - 11)
33
+ const modelMenuBounds = await page.getByRole('menu', { name: '模型与推理等级', exact: true }).boundingBox()
34
+ const composerBounds = await page.locator('[data-composer-card]').first().boundingBox()
35
+ expect(modelMenuBounds!.width).toBeLessThan(composerBounds!.width)
36
+ diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md
37
+ index 43c61b89d1..8fa5117005 100644
38
+ --- a/packages/client/ui-layout/README.md
39
+ +++ b/packages/client/ui-layout/README.md
40
+ @@ -56,7 +56,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
41
+
42
+ `selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts.
43
+
44
+ -One registration declares the frame slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, font-list, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
45
+ +One registration declares the frame slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The center column marks its DOM owner so portaled composer menus can stay inside its horizontal bounds. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, font-list, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
46
+
47
+ </details>
48
+
49
+ diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx
50
+ index 5614947447..e3fc26d707 100644
51
+ --- a/packages/client/ui-layout/src/client/AppFrame.tsx
52
+ +++ b/packages/client/ui-layout/src/client/AppFrame.tsx
53
+ @@ -33,7 +33,7 @@ export type AppFrameProps =
54
+
55
+ /** Center column grid item (session-body building block). */
56
+ function CenterColumn(props: { children?: ReactNode }) {
57
+ - return <div className={css.centerCol}>{props.children}</div>
58
+ + return <div className={css.centerCol} data-dsh-center-col>{props.children}</div>
59
+ }
60
+
61
+ /** Subscribe to the main key without subscribing the column frame to each panel id. */
62
+ diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
63
+ index c36809acaa..21fb6c73b8 100644
64
+ --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css
65
+ +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
66
+ @@ -97,7 +97,9 @@
67
+ display: flex;
68
+ flex-direction: column;
69
+ /* Let the visible pane's rows set the card width, but keep pathological ids
70
+ - and narrow screens inside a bounded card where ellipsis remains. */
71
+ + and narrow screens inside a bounded card where ellipsis remains. The card's
72
+ + own pad is part of that bound, as it is on the Menu primitive's surface. */
73
+ + box-sizing: border-box;
74
+ width: max-content;
75
+ min-width: min(240px, calc(100vw - 32px));
76
+ max-width: min(420px, calc(100vw - 32px));
77
+ diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
78
+ index f68d355317..c8a0089c19 100644
79
+ --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx
80
+ +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
81
+ @@ -224,13 +224,25 @@ export function ModelSelect(
82
+ const rect = triggerRef.current?.getBoundingClientRect()
83
+ if (rect === undefined) return
84
+ const MARGIN = 12
85
+ + // The composer's center column narrows both clamp edges when this trigger
86
+ + // sits in one; without it the viewport alone decides, as before.
87
+ + const boundary = triggerRef.current?.closest<HTMLElement>('[data-dsh-center-col]')?.getBoundingClientRect()
88
+ + const minX = Math.max(MARGIN, (boundary?.left ?? 0) + MARGIN)
89
+ + const maxX = Math.min(window.innerWidth - MARGIN, (boundary?.right ?? window.innerWidth) - MARGIN)
90
+ + const availableWidth = Math.max(0, maxX - minX)
91
+ const lw = menuRef.current?.offsetWidth ?? 0
92
+ + const placedWidth = boundary === undefined ? lw : Math.min(lw, availableWidth)
93
+ const lh = menuRef.current?.offsetHeight ?? 0
94
+ - let x = rect.right - lw
95
+ + let x = rect.right - placedWidth
96
+ let y = rect.top - 8 - lh
97
+ - if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN)
98
+ + if (lw > 0) x = Math.min(Math.max(x, minX), maxX - placedWidth)
99
+ if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN)
100
+ - setMenuPos({ left: x, top: y })
101
+ + // A bounded card takes the owner's width: the same 12px inset on both
102
+ + // sides, never wider than the room between them.
103
+ + const bounds = boundary === undefined
104
+ + ? {}
105
+ + : { minWidth: Math.min(264, availableWidth), maxWidth: availableWidth }
106
+ + setMenuPos({ left: x, top: y, ...bounds })
107
+ }
108
+ place()
109
+ window.addEventListener('scroll', place, true)
110
+ diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
111
+ index dbb5b247db..584e1f0366 100644
112
+ --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
113
+ +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
114
+ @@ -454,14 +454,19 @@ describe('ModelSelect reasoning effort', () => {
115
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
116
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 })
117
+ try {
118
+ - const { container } = render(<ModelSelect renderSlot={renderLabel}
119
+ + const { container } = render(<div data-dsh-center-col><ModelSelect renderSlot={renderLabel}
120
+ locked={false}
121
+ available
122
+ directory={createSnapshotStore(state())}
123
+ load={vi.fn()} resolveText={resolveText}
124
+ select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
125
+ t={t}
126
+ - />)
127
+ + /></div>)
128
+ + // The composer card's own rect is a zero rect in jsdom; the column's is not.
129
+ + Object.defineProperty(container.firstElementChild, 'getBoundingClientRect', {
130
+ + configurable: true,
131
+ + value: () => ({ left: 280, right: 520, top: 0, bottom: 700, width: 240, height: 700, x: 280, y: 0, toJSON: () => ({}) }),
132
+ + })
133
+ const trigger = screen.getByRole('button', { name: /选择模型/ })
134
+ fireEvent.click(trigger)
135
+ const menu = screen.getByRole('menu')
136
+ @@ -469,8 +474,9 @@ describe('ModelSelect reasoning effort', () => {
137
+ expect(container.contains(menu)).toBe(false)
138
+ expect(menu.parentElement).toBe(document.body)
139
+ // jsdom anchor rects are all zero, so the measured 200x300 card clamps
140
+ - // to the 12px viewport margin on both axes.
141
+ - expect(menu.style.left).toBe('12px')
142
+ + // to the centre column's 12px inset horizontally and the viewport's on
143
+ + // the vertical axis.
144
+ + expect(menu.style.left).toBe('292px')
145
+ expect(menu.style.top).toBe('12px')
146
+ // Interactions inside the trigger subtree or the portaled card stay open.
147
+ expect(fireEvent.mouseDown(menu)).toBe(true)
148
+ diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
149
+ index 0281c41a24..f44f20a8fc 100644
150
+ --- a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
151
+ +++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx
152
+ @@ -1,4 +1,4 @@
153
+ -import { useEffect, useState } from 'react'
154
+ +import { useEffect, useRef, useState } from 'react'
155
+ import type { ReactNode } from 'react'
156
+ import clsx from 'clsx'
157
+ import {
158
+ @@ -81,6 +81,8 @@ export function PermissionSelect({
159
+ const catalog = usePermissionCatalog(state => state.value)
160
+ const [pick, setPick] = useState<string | null>(null)
161
+ const [open, setOpen] = useState(false)
162
+ + /** The center column this trigger sits in; the portaled menu clamps to it. */
163
+ + const boundaryRef = useRef<HTMLElement | null>(null)
164
+ const [confirmation, setConfirmation] = useState<string | null>(null)
165
+ const [acknowledged, setAcknowledged] = useState(false)
166
+
167
+ @@ -171,8 +173,12 @@ export function PermissionSelect({
168
+ onClose={() => { setOpen(false) }}
169
+ side="top"
170
+ portal
171
+ + boundaryRef={boundaryRef}
172
+ anchor={
173
+ <button
174
+ + ref={(node) => {
175
+ + boundaryRef.current = node?.closest<HTMLElement>('[data-dsh-center-col]') ?? null
176
+ + }}
177
+ type="button"
178
+ className={css.trigger}
179
+ aria-label={t('mode', { name: currentLabel })}
180
+ diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
181
+ index d95d802886..bb1d53df18 100644
182
+ --- a/packages/client/ui-primitives/README.md
183
+ +++ b/packages/client/ui-primitives/README.md
184
+ @@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot
185
+ | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. |
186
+ | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. |
187
+ | `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. |
188
+ -| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
189
+ +| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. A portaled list may intersect viewport clamping with an owner boundary. |
190
+ | `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. |
191
+ | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
192
+ | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. |
193
+ diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
194
+ index 115e1defe5..9972ffe785 100644
195
+ --- a/packages/client/ui-primitives/README.zh.md
196
+ +++ b/packages/client/ui-primitives/README.zh.md
197
+ @@ -25,7 +25,7 @@ kind: "package-library"
198
+ <a id="use-this-package"></a>
199
+ ## 使用本包
200
+
201
+ -`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
202
+ +`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 portal 模式的列表可把视口钳制与 owner boundary 取交集。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
203
+
204
+ `isMobileDevice()` 识别 iOS 和 Android 手机及平板,供自动聚焦和收起键盘使用。它识别 UA 中的 `Android`、`iPhone`、`iPad` 或 `iPod`,以及报告 `MacIntel` 且触摸点数大于一的 iPad。其他设备(包括 Windows 触屏电脑)返回 false,不受窗口宽度影响。函数缓存首次判断结果,供页面内的所有调用方共享;组件重新挂载会复用该结果,重新加载页面时会再次读取浏览器环境。识别出的移动设备需要用户主动聚焦文本输入框。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。
205
+
206
+ diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx
207
+ index 68b3a5766e..e25866591a 100644
208
+ --- a/packages/client/ui-primitives/src/Menu.tsx
209
+ +++ b/packages/client/ui-primitives/src/Menu.tsx
210
+ @@ -1,5 +1,5 @@
211
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
212
+ -import type { CSSProperties, ReactNode, SyntheticEvent } from 'react'
213
+ +import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react'
214
+ import { createPortal } from 'react-dom'
215
+ import clsx from 'clsx'
216
+ import { IconCheckOutlineRegular } from './icons/index.tsx'
217
+ @@ -154,6 +154,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
218
+ * wrapper there races the host's layout effects). Called on open, each animation
219
+ * frame, and scroll/resize; return null to skip placement for that frame.
220
+ * @param props.minWidthFromAnchor - in portal mode, keep the menu at least as wide as its anchor, bounded by viewport margins.
221
+ + * @param props.boundaryRef - portal mode only: intersect horizontal placement
222
+ + * with this owner element's rect as well as the viewport, so a menu owned by a
223
+ + * column stays inside that column when a sidebar or panel leaves a narrow
224
+ + * center.
225
+ * @param props.footer - rows pinned below the scrolling items area, separated
226
+ * by a hairline; they stay visible while the items above scroll.
227
+ * @param props.children - component rows rendered after `items` in the same
228
+ @@ -170,7 +174,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
229
+ * document.body outside the owner's DOM subtree.
230
+ * @returns anchor wrapper with the conditional list.
231
+ */
232
+ -export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName }: {
233
+ +export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, boundaryRef, footer, className, listClassName }: {
234
+ open: boolean
235
+ autoFocus?: boolean
236
+ anchor: ReactNode
237
+ @@ -190,6 +194,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
238
+ selection?: 'check' | 'fill'
239
+ getAnchorRect?: () => DOMRect | null
240
+ minWidthFromAnchor?: boolean
241
+ + boundaryRef?: RefObject<HTMLElement | null>
242
+ className?: string | undefined
243
+ listClassName?: string | undefined
244
+ }) {
245
+ @@ -262,12 +267,19 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
246
+ const GAP = 4
247
+ const vw = window.innerWidth
248
+ const vh = window.innerHeight
249
+ + // The owner boundary narrows both clamp edges; without one the viewport
250
+ + // alone decides, exactly as before.
251
+ + const boundary = boundaryRef?.current?.getBoundingClientRect()
252
+ + const minX = Math.max(MARGIN, (boundary?.left ?? 0) + MARGIN)
253
+ + const maxX = Math.min(vw - MARGIN, (boundary?.right ?? vw) - MARGIN)
254
+ + const availableWidth = Math.max(0, maxX - minX)
255
+ const listEl = listRef.current
256
+ if (listEl !== null) {
257
+ const minimum = minWidthFromAnchor ? `${Math.min(r.width, Math.max(0, vw - MARGIN * 2))}px` : ''
258
+ if (listEl.style.minWidth !== minimum) listEl.style.minWidth = minimum
259
+ }
260
+ const lw = listEl?.offsetWidth ?? 0
261
+ + const placedWidth = boundary === undefined ? lw : Math.min(lw, availableWidth)
262
+ const lh = listEl?.offsetHeight ?? 0
263
+
264
+ let x: number
265
+ @@ -279,14 +291,22 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
266
+ x = r.left
267
+ y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP
268
+ } else {
269
+ - x = r.right - lw
270
+ + x = r.right - placedWidth
271
+ y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP
272
+ }
273
+
274
+ - if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
275
+ + if (lw > 0) x = Math.min(Math.max(x, minX), maxX - placedWidth)
276
+ if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN)
277
+
278
+ - setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y })
279
+ + // A bounded card takes the owner's width: the same 12px inset on both
280
+ + // sides, never wider than the room between them.
281
+ + const bounds = boundary === undefined
282
+ + ? {}
283
+ + : { minWidth: Math.min(218, availableWidth), maxWidth: availableWidth }
284
+ + setFixedPos(current => current?.left === x && current.top === y
285
+ + && current.minWidth === bounds.minWidth && current.maxWidth === bounds.maxWidth
286
+ + ? current
287
+ + : { left: x, top: y, ...bounds })
288
+ }
289
+ // First run measures the hidden pre-render (same commit as `open`), so
290
+ // end/top alignment and clamping use real dimensions before anything
291
+ @@ -305,7 +325,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
292
+ window.removeEventListener('scroll', place, true)
293
+ window.removeEventListener('resize', place)
294
+ }
295
+ - }, [open, portal, align, side, getAnchorRect, minWidthFromAnchor])
296
+ + }, [open, portal, align, side, getAnchorRect, minWidthFromAnchor, boundaryRef])
297
+
298
+ // Opening remembers where the keyboard was, so closing can hand it back to
299
+ // that control — an anchor wrapping several (a split button) cannot be asked
300
+ diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
301
+ index 8b198e4365..592c56641d 100644
302
+ --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx
303
+ +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
304
+ @@ -596,6 +596,33 @@ describe('Menu', () => {
305
+ expect(menu.style.top).toBe('132px')
306
+ })
307
+
308
+ + it('portal mode intersects viewport clamping with an owner boundary', () => {
309
+ + const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')!
310
+ + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 300 })
311
+ + const anchor = { left: 480, right: 500, top: 100, bottom: 128, width: 20, height: 28, x: 480, y: 100, toJSON: () => ({}) } as DOMRect
312
+ + const boundary = { left: 280, right: 520, top: 0, bottom: 700, width: 240, height: 700, x: 280, y: 0, toJSON: () => ({}) } as DOMRect
313
+ + try {
314
+ + render(<Menu
315
+ + portal
316
+ + open
317
+ + align="end"
318
+ + getAnchorRect={() => anchor}
319
+ + boundaryRef={{ current: { getBoundingClientRect: () => boundary } as HTMLElement }}
320
+ + anchor={null}
321
+ + items={items}
322
+ + onSelect={() => {}}
323
+ + onClose={() => {}}
324
+ + />)
325
+ + const menu = screen.getByRole('menu')
326
+ + // The owner is 240px wide, so the 12px insets leave 216px for a 300px card.
327
+ + expect(menu.style.left).toBe('292px')
328
+ + expect(menu.style.minWidth).toBe('216px')
329
+ + expect(menu.style.maxWidth).toBe('216px')
330
+ + } finally {
331
+ + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
332
+ + }
333
+ + })
334
+ +
335
+ it('portal mode skips the frame when getAnchorRect returns null (no menu until a rect exists)', () => {
336
+ render(
337
+ <Menu