From c5187d38a5e509820d92c998f27021dd1a27abad Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 13 Aug 2026 19:09:16 +0800 Subject: [PATCH 1/2] fix(web): align the sidebar Cordis panel with menu and footer conventions The Cordis popup now uses the standard menu surface (layer-3 fill, inverted border, lv3 shadow) so it stays visible in dark mode, closes on outside pointerdown, and anchors to its trigger through a measured fixed position that escapes the sidebar overflow clip. The Cordis and Settings footer triggers share the same 42px row, icon size, padding, and hover fill, and the panel's header, heading, and card spacing is rebalanced. Refs #2526 --- .../src/client/SettingsRoot.module.css | 12 +++--- .../src/client/CordisPanel.module.css | 34 ++++++++--------- .../ui-cordis/src/client/CordisPanel.tsx | 38 ++++++++++++++++--- 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 060e8d115b..03e0f6372c 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -1,19 +1,19 @@ /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar foot trigger row + centered 1080x700 modal panel. The trigger uses the - sidebar's 34px compact row / 36px rail circle rhythm; the + sidebar foot's 42px row / 36px rail circle rhythm; the panel is a two-column layout — 188px nav rail + content column with a 54px header and the 24px-padded options area. */ -/* Trigger row: match the other wide sidebar controls' compact vertical rhythm. */ +/* Trigger row: the 42px foot-row box shared with the Cordis footer action. */ .trigger { flex: none; display: flex; align-items: center; gap: 8px; - width: calc(100% + 8px); - height: 34px; - margin: 4px -4px 4px; - padding: 6px 2px 6px 10px; + width: calc(100% + 4px); + height: 42px; + margin: 4px -2px; + padding: 0 10px 0 8px; box-sizing: border-box; border: none; border-radius: 12px; diff --git a/packages/extensions/ui-cordis/src/client/CordisPanel.module.css b/packages/extensions/ui-cordis/src/client/CordisPanel.module.css index bf72bfecec..1a10f07ee3 100644 --- a/packages/extensions/ui-cordis/src/client/CordisPanel.module.css +++ b/packages/extensions/ui-cordis/src/client/CordisPanel.module.css @@ -6,7 +6,7 @@ display: flex; align-items: center; width: 100%; - height: 49px; + height: 42px; margin: 8px 0 0; } @@ -20,9 +20,10 @@ display: inline-flex; align-items: center; gap: 8px; - width: 100%; - height: 49px; - padding: 0 8px 0 6px; + width: calc(100% + 4px); + height: 42px; + margin: 0 -2px; + padding: 0 10px 0 8px; border: none; border-radius: 12px; background: transparent; @@ -34,7 +35,7 @@ } .badge:hover { - background: var(--dsw-alias-interactive-bg-hover-solid); + background: var(--dsw-alias-interactive-bg-hover); } .badge[data-active] { @@ -77,10 +78,10 @@ gap: 2px; } +/* Fixed so the sidebar's overflow clip cannot cut the 420px surface; the + left/bottom offsets are measured from the trigger before paint. */ .panel { position: fixed; - left: 12px; - bottom: 128px; z-index: 30; display: flex; flex-direction: column; @@ -88,10 +89,10 @@ max-width: calc(100vw - 24px); max-height: 60vh; overflow: hidden; - border: 1px solid var(--dsw-alias-border-l1); + border: 1px solid var(--dsw-alias-border-inverted); border-radius: 12px; - background: var(--dsw-alias-bg-base); - box-shadow: var(--dsw-shadow-lv2); + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); /* Only `.body` scrolls; the header remains fixed above it. */ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); @@ -105,19 +106,17 @@ min-height: 44px; padding: 10px 12px; box-sizing: border-box; - border-bottom: 1px solid var(--dsw-alias-border-l2); - background: var(--dsw-alias-bg-base); } .body { flex: 1; min-height: 0; overflow-y: auto; - padding: 4px 12px 12px; + padding: 0 12px 12px; } .title { - font-size: 13px; + font-size: 14px; font-weight: 500; line-height: 20px; color: var(--dsw-alias-label-primary); @@ -137,7 +136,7 @@ /* Session headings replace per-row ownership markers. */ .group { - margin: 8px 0 4px; + margin: 8px 0 8px; font-size: 11px; font-weight: 500; line-height: 16px; @@ -159,10 +158,9 @@ display: flex; flex-direction: column; gap: 8px; - padding: 10px 12px; + padding: 14px 12px 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; - background: var(--dsw-alias-bg-base); } .row[data-cordis-awaiting] { @@ -252,7 +250,7 @@ padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 7px; - background: var(--dsw-alias-bg-base); + background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; } diff --git a/packages/extensions/ui-cordis/src/client/CordisPanel.tsx b/packages/extensions/ui-cordis/src/client/CordisPanel.tsx index 946e9d9dfa..42f1586dae 100644 --- a/packages/extensions/ui-cordis/src/client/CordisPanel.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisPanel.tsx @@ -1,6 +1,6 @@ /** Frame-wide dynamic Plugin inventory, approvals, versions, and lifecycle actions. */ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ButtonHTMLAttributes, ReactNode } from 'react' import { IconCheckOutline16, IconCloseOutline16, IconCordisPluginOutline14, IconPlayOutline16, @@ -119,6 +119,34 @@ export function CordisPanel({ const [pending, setPending] = useState>(new Set()) const [actionErrors, setActionErrors] = useState>(new Map()) const visibleRequests = useRef>(new Set()) + const rootRef = useRef(null) + const [anchor, setAnchor] = useState<{ left: number; bottom: number }>() + + // The panel is position: fixed (the sidebar clips overflow), so it hugs the + // trigger through a measured offset instead of document flow. + useLayoutEffect(() => { + if (!open) return + const place = (): void => { + const rect = rootRef.current?.getBoundingClientRect() + if (rect !== undefined) { + setAnchor({ left: rect.left, bottom: window.innerHeight - rect.top + 8 }) + } + } + place() + window.addEventListener('resize', place) + return () => { window.removeEventListener('resize', place) } + }, [open]) + + useEffect(() => { + if (!open) return + const closeOutside = (event: PointerEvent): void => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) { + setOpen(false) + } + } + document.addEventListener('pointerdown', closeOutside) + return () => { document.removeEventListener('pointerdown', closeOutside) } + }, [open]) useEffect(() => { const now = new Set() @@ -420,9 +448,9 @@ export function CordisPanel({ } return ( -
- {open && ( -
+
+ {open && anchor !== undefined && ( +
{t('panel.title')}
@@ -458,7 +486,7 @@ export function CordisPanel({ aria-expanded={open} onClick={() => { setOpen(value => !value) }} > - + {wide && ( <> {t('panel.trigger')} From b5deda1f05044edd4dfec53c862bbe16e154508a Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 13 Aug 2026 19:39:10 +0800 Subject: [PATCH 2/2] refactor(client): share the outside-pointer dismissal hook The jobs list and the Cordis panel carried identical outside-pointerdown close effects, which the duplication gate rejects; both now use useDismissOnOutsidePointer from ui-primitives. Refs #2526 --- .../ui-jobs/src/client/JobListAction.tsx | 13 ++------- packages/client/ui-primitives/src/index.ts | 1 + .../src/useDismissOnOutsidePointer.ts | 29 +++++++++++++++++++ .../ui-cordis/src/client/CordisPanel.tsx | 13 ++------- 4 files changed, 34 insertions(+), 22 deletions(-) create mode 100644 packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index b4cd08c56d..8834abf82b 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import type { JobView } from '@deepseek-ai/dsh-client-runtime/client' -import { IconChevronDownOutline14, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutline14, StateDot, useDismissOnOutsidePointer, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -101,16 +101,7 @@ export function JobListAction({ sessionId, useSessions, t }: JobListActionProps) const rows = useMemo(() => ordered(jobs), [jobs]) const liveCount = useMemo(() => jobs.filter(isLive).length, [jobs]) - useEffect(() => { - if (!open) return - const closeOutside = (event: PointerEvent): void => { - if (event.target instanceof Node && !rootRef.current?.contains(event.target)) { - setOpen(false) - } - } - document.addEventListener('pointerdown', closeOutside) - return () => { document.removeEventListener('pointerdown', closeOutside) } - }, [open]) + useDismissOnOutsidePointer(rootRef, open, setOpen) // The clock only runs while an open list is showing something that moves. useEffect(() => { diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 05baf26f8d..1e8c3e576d 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -13,6 +13,7 @@ export { Input } from './Input.tsx' export { Menu } from './Menu.tsx' export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx' export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts' +export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' export { HoverCard } from './HoverCard.tsx' export { Modal } from './Modal.tsx' export { OnboardingSurface } from './OnboardingSurface.tsx' diff --git a/packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts b/packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts new file mode 100644 index 0000000000..3706d13bfc --- /dev/null +++ b/packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts @@ -0,0 +1,29 @@ +/** + * Outside-pointer dismissal for trigger-owned popovers (jobs list, Cordis + * panel): while the surface is open, a pointerdown outside the root closes it. + */ +import { useEffect } from 'react' +import type { RefObject } from 'react' + +/** + * Close an open popover when a pointerdown lands outside its root element. + * @param root - element containing both the trigger and the open surface. + * @param open - whether the surface is showing; false detaches the listener. + * @param setOpen - state setter invoked with false on an outside pointerdown. + */ +export function useDismissOnOutsidePointer( + root: RefObject, + open: boolean, + setOpen: (open: boolean) => void, +): void { + useEffect(() => { + if (!open) return + const closeOutside = (event: PointerEvent): void => { + if (event.target instanceof Node && !root.current?.contains(event.target)) { + setOpen(false) + } + } + document.addEventListener('pointerdown', closeOutside) + return () => { document.removeEventListener('pointerdown', closeOutside) } + }, [root, open, setOpen]) +} diff --git a/packages/extensions/ui-cordis/src/client/CordisPanel.tsx b/packages/extensions/ui-cordis/src/client/CordisPanel.tsx index 42f1586dae..4e96d15c94 100644 --- a/packages/extensions/ui-cordis/src/client/CordisPanel.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisPanel.tsx @@ -4,7 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ButtonHTMLAttributes, ReactNode } from 'react' import { IconCheckOutline16, IconCloseOutline16, IconCordisPluginOutline14, IconPlayOutline16, - IconStopFill16, IconTrashOutline16, Tooltip, + IconStopFill16, IconTrashOutline16, Tooltip, useDismissOnOutsidePointer, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -137,16 +137,7 @@ export function CordisPanel({ return () => { window.removeEventListener('resize', place) } }, [open]) - useEffect(() => { - if (!open) return - const closeOutside = (event: PointerEvent): void => { - if (event.target instanceof Node && !rootRef.current?.contains(event.target)) { - setOpen(false) - } - } - document.addEventListener('pointerdown', closeOutside) - return () => { document.removeEventListener('pointerdown', closeOutside) } - }, [open]) + useDismissOnOutsidePointer(rootRef, open, setOpen) useEffect(() => { const now = new Set()