}
/**
@@ -47,8 +55,8 @@ function MainPanel({ usePanelInfo, renderSlot }: Pick, 'use
* occupant's panel is positioned against the column's right edge, which never
* moves, so it can hang over the centre when there is no track.
*/
-function RightbarColumn(props: { children?: ReactNode }) {
- return
)}
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
- {!sidebarCollapsed && }
+ {!drawer && !centeredSidebar && !sidebarCollapsed && }
{layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && (
)}
diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts
index b5ffd9f732..6e99e6f910 100644
--- a/packages/client/ui-layout/src/client/columns.ts
+++ b/packages/client/ui-layout/src/client/columns.ts
@@ -17,6 +17,8 @@ export const SIDEBAR_MAX = 420
export const SIDEBAR_DEFAULT = 280
/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */
export const SIDEBAR_COLLAPSED = 56
+/** Frame width below which the sidebar overlays the main content without a rail. */
+export const SIDEBAR_DRAWER_BREAKPOINT = 768
/** Viewport width below which the sidebar auto-collapses to the rail (deepsuite
* LG breakpoint); a manual toggle below it re-expands over the squeezed center
* (stores.ts narrowExpanded). */
@@ -46,11 +48,14 @@ export function clampWidth(px: number, min: number, max: number): number {
* @param rightbar - requested right panel width in px (0 = no track).
* @param collapsedWidth - track width of the closed sidebar; the default keeps
* the icon rail, 0 hides the column entirely (macOS desktop).
+ * @param centeredSidebar - place an expanded sidebar's edge at the frame midpoint.
* @returns actual widths after shrinking or removing the right track; only
* without that track may the center fall below its minimum, down to zero.
*/
-export function computeColumns(viewport: number, sidebar: number, rightbar: number, collapsedWidth = SIDEBAR_COLLAPSED): Columns {
- const s = sidebar === 0 ? collapsedWidth : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
+export function computeColumns(
+ viewport: number, sidebar: number, rightbar: number, collapsedWidth = SIDEBAR_COLLAPSED, centeredSidebar = false,
+): Columns {
+ const s = sidebar === 0 ? collapsedWidth : centeredSidebar ? viewport / 2 : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
const available = viewport - s - CENTER_MIN
const r = rightbar === 0 || available < RIGHTBAR_MIN
? 0
diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts
index ee9da22d2b..aec243039c 100644
--- a/packages/client/ui-layout/src/client/index.ts
+++ b/packages/client/ui-layout/src/client/index.ts
@@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { PanelInfo } from './service.ts'
import { AppFrame } from './AppFrame.tsx'
+import type { AppFrameInjected } from './AppFrame.tsx'
import { createLayoutStore } from './stores.ts'
import { LayoutController } from './service.ts'
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
@@ -62,8 +63,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* the seats it declares disappear with it. To add something to the
* sidebar, register into one of those inner seats instead.
*
- * The occupant receives the frame's live column state (collapsed, width)
- * and is expected to render the compact control rail while collapsed.
+ * The occupant receives the frame's collapsed state, width and drawer mode.
+ * Drawer content stays mounted while hidden; other browser columns retain
+ * a compact control rail while collapsed.
*/
'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
/**
@@ -105,9 +107,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'shell.overlay': { kind: 'list'; scope: 'root' }
/**
* Window-chrome seat at the frame's top-left, over every main panel.
- * Mounted only while the sidebar column is fully hidden (macOS desktop
- * collapse; other platforms keep the rail), so the occupant can assume the
- * frame edge is the window edge and the macOS traffic lights sit before it.
+ * Mounted for narrow drawers and collapsed macOS desktop sidebars. macOS
+ * traffic lights sit before it outside native fullscreen; browser controls
+ * start at the frame edge.
* OCCUPIED by ui-sidebar's reopen/New Session controls.
*
* While the seat is mounted the frame publishes
@@ -127,10 +129,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Sidebar owner share: live column state from the frame's concession solve. */
export interface SidebarOwnerProps {
- /** True when the sidebar is closed (the column renders the compact control rail). */
+ /** True when closed; a drawer is hidden, while other browser columns retain a rail. */
collapsed: boolean
- /** Rendered column width in px (SIDEBAR_COLLAPSED when collapsed). */
+ /** Rendered sidebar width in px; a hidden drawer retains its expanded width. */
width: number
+ /** Present the retained sidebar content as an overlay drawer. */
+ drawer: boolean
}
/** Right column owner share: resolved normal geometry and opening eligibility. */
@@ -160,6 +164,16 @@ export function apply(ctx: ClientContext): void {
const t = ctx.locale.bind('shortcuts.layout')
ctx.effect(() => {
+ // Headless profiles can instantiate client registrations without browser media APIs.
+ const query = typeof matchMedia === 'function'
+ ? matchMedia('(device-posture: folded) and (orientation: landscape)') : undefined
+ const foldedLandscape: HostObservable = {
+ getSnapshot: () => query?.matches ?? false,
+ subscribe: (listener) => {
+ query?.addEventListener('change', listener)
+ return () => { query?.removeEventListener('change', listener) }
+ },
+ }
const handle = createLayoutStore()
const instance = handle.create()
const store: typeof handle = { ...handle, create: () => instance }
@@ -187,6 +201,7 @@ export function apply(ctx: ClientContext): void {
'shell.leading': { kind: 'single', scope: 'root' },
},
store,
+ inject: (): AppFrameInjected => ({ hooks: { foldedLandscape } }),
}, AppFrame)
const disposeShortcut = ctx.shortcuts.register({
id: 'sidebar.left.toggle' as ShortcutCommandId, label: () => t('toggle'), aliases: ['sidebar', 'toggle left sidebar'],
@@ -197,8 +212,10 @@ export function apply(ctx: ClientContext): void {
'web:macos': { code: 'KeyB', modifiers: ['primary', 'alt'] },
'web:windows': { code: 'KeyB', modifiers: ['primary', 'alt'] },
},
- regions: ['page', 'editable'], modals: [],
- resolve: () => ({ status: 'handled', run: () => { layout.toggleSidebar() } }),
+ regions: ['page', 'editable'], modals: ['sidebar'],
+ resolve: ({ modal }) => modal !== null && modal !== 'sidebar'
+ ? { status: 'pass' }
+ : { status: 'handled', run: () => { layout.toggleSidebar() } },
})
const disposePanels = ctx.slots.subscribe('main', retainMainPanels)
retainMainPanels()
diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts
index 3a53caf4f1..264006bb45 100644
--- a/packages/client/ui-layout/src/client/stores.ts
+++ b/packages/client/ui-layout/src/client/stores.ts
@@ -6,7 +6,7 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-sto
import type { MainPanelId } from './service.ts'
import {
clampWidth, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN,
- SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
+ SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT, SIDEBAR_DRAWER_BREAKPOINT, SIDEBAR_MAX, SIDEBAR_MIN,
} from './columns.ts'
/**
@@ -93,6 +93,7 @@ export function createLayoutStore(): EngineStoreHandle {
d.panelInfo.activePanelId = panelId
+ if (d.layoutInfo.viewportWidth < SIDEBAR_DRAWER_BREAKPOINT) d.layoutInfo.narrowExpanded = false
},
retainMainPanels: (d, panelIds: readonly string[]) => {
if (d.panelInfo.activePanelId !== null && !panelIds.includes(d.panelInfo.activePanelId)) {
@@ -115,7 +116,8 @@ export function createLayoutStore(): EngineStoreHandle {
if (d.layoutInfo.viewportWidth === width) return
d.layoutInfo.rightbarInstant = false
- if ((d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) {
+ if ((d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)
+ || (d.layoutInfo.viewportWidth < SIDEBAR_DRAWER_BREAKPOINT) !== (width < SIDEBAR_DRAWER_BREAKPOINT)) {
d.layoutInfo.narrowExpanded = false
}
d.layoutInfo.viewportWidth = width
diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx
index 1dc7cc85b8..c35e914149 100644
--- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx
+++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx
@@ -2,8 +2,9 @@
/** Frame interactions with a real store and explicitly driven browser measurements. */
import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots'
import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { act, cleanup, render } from '@testing-library/react'
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { AppFrame } from '../src/client/AppFrame.tsx'
import type { AppFrameProps } from '../src/client/AppFrame.tsx'
import type { MainPanelId, RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts'
@@ -33,6 +34,7 @@ let frameWidth: number
let animationFrames: Map
let nextFrame: number
let originalTitle: string
+let foldedLandscape: SnapshotStore
const restoreProperties: (() => void)[] = []
function replaceProperty(target: T, key: K, value: T[K]): void {
@@ -60,13 +62,13 @@ function resize(width: number): void {
})
}
-function mountFrame(windowWidth = frameWidth) {
+function mountFrame(windowWidth = frameWidth, content?: AppFrameProps['renderSlot']) {
vi.stubGlobal('innerWidth', windowWidth)
const instance = createLayoutStore().create()
const slotCalls: { key: string; props: object; options: RenderOpts | undefined }[] = []
const renderSlot: AppFrameProps['renderSlot'] = (key, owner, options) => {
slotCalls.push({ key, props: owner, options })
- return
+ return content?.(key, owner, options) ??
}
const useSessions: AppFrameProps['useSessions'] = sel => sel({
ids: selectedSession === undefined ? [] : [selectedSession],
@@ -95,6 +97,7 @@ function mountFrame(windowWidth = frameWidth) {
renderSlot={renderSlot}
useSessions={useSessions}
usePanelInfo={usePanelInfo}
+ useFoldedLandscape={bindSnapshotSelector(foldedLandscape)}
useSessionStatus={useSessionStatus}
useSessionRetainInfo={() => undefined}
useResource={useResource}
@@ -152,6 +155,8 @@ beforeEach(() => {
nextFrame = 1
vi.stubEnv('DSH_CLIENT_TITLE', undefined)
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+ foldedLandscape = createSnapshotStore(false)
+ vi.stubGlobal('matchMedia', () => ({ matches: false }))
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextFrame++
animationFrames.set(id, callback)
@@ -205,7 +210,7 @@ describe('AppFrame', () => {
it('renders owner props for the default sidebar and prospective right panel', () => {
const { frame, rightOwner, sidebarOwner, slotCalls } = mountFrame()
expect(tracks(frame)).toEqual([280, 0])
- expect(sidebarOwner()).toEqual({ collapsed: false, width: 280 })
+ expect(sidebarOwner()).toEqual({ collapsed: false, width: 280, drawer: false })
expect(rightOwner()).toEqual({ width: 864, viewportWidth: 1920, canShow: true })
expect(slotCalls.find(c => c.key === 'main')).toEqual({ key: 'main', props: {}, options: { entryKey: 'conversation' } })
})
@@ -227,14 +232,16 @@ describe('AppFrame', () => {
expect(getByTestId('rightbar-content')).toBeTruthy()
})
- it('keeps Windows caption controls mounted with a zero-width collapsed column', () => {
+ it.each([1920, 390])('keeps Windows caption controls mounted at %ipx with a zero-width collapsed column', (width) => {
document.documentElement.setAttribute('data-windows-titlebar', '')
try {
const { frame, instance, sidebarOwner, getByTestId, queryByTestId } = mountFrame()
- act(() => { instance.actions.toggleSidebar() })
+ if (width < 1024) resize(width)
+ else act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)[0]).toBe(0)
- expect(sidebarOwner()).toMatchObject({ collapsed: true, width: 0 })
+ expect(sidebarOwner()).toMatchObject({ collapsed: true, width: 0, drawer: false })
expect(getByTestId('sidebar-content')).toBeTruthy()
+ expect(getByTestId('sidebar-content').closest('[inert]')).toBeNull()
// The caption row keeps the reopen controls; the darwin-only
// shell.leading seat must not mount a duplicate set.
expect(queryByTestId('shell.leading-content')).toBeNull()
@@ -247,7 +254,7 @@ describe('AppFrame', () => {
const { frame, instance, sidebarOwner, getByTestId, queryByTestId } = mountFrame()
act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)).toEqual([56, 0])
- expect(sidebarOwner()).toEqual({ collapsed: true, width: 56 })
+ expect(sidebarOwner()).toEqual({ collapsed: true, width: 56, drawer: false })
expect(getByTestId('sidebar-content')).toBeTruthy()
expect(frame.querySelector('[data-side="sidebar"]')).toBeNull()
// The rail keeps the window chrome housed: no shell.leading seat.
@@ -264,7 +271,7 @@ describe('AppFrame', () => {
expect(queryByTestId('shell.leading-content')).toBeNull()
act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)).toEqual([0, 0])
- expect(sidebarOwner()).toEqual({ collapsed: true, width: 0 })
+ expect(sidebarOwner()).toEqual({ collapsed: true, width: 0, drawer: false })
expect(frame.querySelector('[data-shell-leading]')).not.toBeNull()
expect(queryByTestId('shell.leading-content')).toBeTruthy()
act(() => { instance.actions.toggleSidebar() })
@@ -291,6 +298,37 @@ describe('AppFrame', () => {
})
describe('AppFrame normal width concessions', () => {
+ it('centers the sidebar while folded in landscape and restores its width without remounting', () => {
+ frameWidth = 951
+ const { frame, instance, sidebarOwner, getByTestId, unmount } = mountFrame()
+ act(() => { instance.actions.setSidebar(360); instance.actions.toggleSidebar() })
+ const sidebar = getByTestId('sidebar-content')
+ expect(tracks(frame)[0]).toBe(360)
+ fireEvent.transitionEnd(frame, { propertyName: 'grid-template-columns' })
+ act(() => { foldedLandscape.set(true) })
+ expect(tracks(frame)).toEqual([475.5, 0])
+ expect(frame.dataset.animating).toBe('true')
+ expect(sidebarOwner()).toEqual({ collapsed: false, width: 475.5, drawer: false })
+ expect(frame.querySelector('[data-side="sidebar"]')).toBeNull()
+ expect(getByTestId('sidebar-content')).toBe(sidebar)
+ act(() => { instance.actions.toggleSidebar() })
+ expect(tracks(frame)[0]).toBe(56)
+ resize(767)
+ expect(frame.dataset.animating).toBeUndefined()
+ act(() => { instance.actions.toggleSidebar() })
+ expect(sidebarOwner()).toEqual({ collapsed: false, width: 360, drawer: true })
+ resize(951)
+ act(() => { instance.actions.toggleSidebar() })
+ expect(tracks(frame)[0]).toBe(475.5)
+ fireEvent.transitionEnd(frame, { propertyName: 'grid-template-columns' })
+ act(() => { foldedLandscape.set(false) })
+ expect(tracks(frame)[0]).toBe(360)
+ expect(frame.dataset.animating).toBe('true')
+ expect(frame.querySelector('[data-side="sidebar"]')).not.toBeNull()
+ expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360)
+ unmount()
+ })
+
it('measures the frame, not the window, before choosing the first-open preference', () => {
frameWidth = 1000
const { instance, rightOwner } = mountFrame(1920)
@@ -319,7 +357,7 @@ describe('AppFrame normal width concessions', () => {
expect(instance.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbar: 864 })
act(() => { instance.actions.closeRightbar() })
resize(455)
- expect(tracks(frame)).toEqual([56, 0])
+ expect(tracks(frame)).toEqual([0, 0])
resize(1920)
expect(tracks(frame)).toEqual([420, 0])
})
@@ -336,7 +374,7 @@ describe('AppFrame normal width concessions', () => {
expect(rightOwner().canShow).toBe(true)
})
- it.each([[756, 300, true], [755, 0, false]] as const)('reports eligibility at %ipx', (width, rightbar, canShow) => {
+ it.each([[768, 312, true], [767, 345, true], [700, 300, true], [699, 0, false]] as const)('reports eligibility at %ipx', (width, rightbar, canShow) => {
frameWidth = width
const { instance, rightOwner } = mountFrame()
act(() => { instance.actions.toggleSidebar() })
@@ -450,6 +488,30 @@ describe('AppFrame right panel presentation', () => {
expect(frame.dataset.animating).toBe('true')
})
+ it('skips drawer entry motion from a collapsed rail while retaining explicit toggle motion', () => {
+ const { frame, instance } = mountFrame()
+ resize(768)
+ expect(frame.dataset.sidebarInstant).toBeUndefined()
+ resize(767)
+ expect(frame.dataset.sidebarInstant).toBe('true')
+ resize(766)
+ expect(frame.dataset.sidebarInstant).toBe('true')
+ act(() => { instance.actions.toggleSidebar() })
+ expect(frame.dataset.sidebarInstant).toBeUndefined()
+ act(() => { instance.actions.toggleSidebar() })
+ expect(frame.dataset.sidebarInstant).toBeUndefined()
+ resize(768)
+ resize(767)
+ expect(frame.dataset.sidebarInstant).toBe('true')
+ })
+
+ it('retains drawer entry motion when resizing an expanded sidebar', () => {
+ const { frame } = mountFrame()
+ resize(767)
+ expect(frame.dataset.sidebarCollapsed).toBe('true')
+ expect(frame.dataset.sidebarInstant).toBeUndefined()
+ })
+
it('animates the rightbar track flip and settles by timeout without a transition end', () => {
vi.useFakeTimers()
try {
@@ -506,11 +568,11 @@ describe('AppFrame right panel presentation', () => {
})
it('retains fullscreen without a track when normal columns cannot fit', () => {
- frameWidth = 700
+ frameWidth = 699
const { frame, instance, rightOwner } = mountFrame()
act(() => { instance.actions.openRightbar(false, true) })
- expect(tracks(frame)).toEqual([56, 0])
- expect(rightOwner()).toEqual({ width: 0, viewportWidth: 700, canShow: false })
+ expect(tracks(frame)).toEqual([0, 0])
+ expect(rightOwner()).toEqual({ width: 0, viewportWidth: 699, canShow: false })
expect(instance.getSnapshot().layoutInfo.rightbarShown).toBe(true)
expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
})
@@ -679,3 +741,58 @@ describe('AppFrame frame measurement lifecycle', () => {
expect(animationFrames.size).toBe(0)
})
})
+
+describe('sidebar drawer', () => {
+ it('cancels an unfinished drawer toggle animation at the rail breakpoint', () => {
+ frameWidth = 390
+ const { frame, instance } = mountFrame()
+ act(() => { instance.actions.toggleSidebar() })
+ expect(frame.hasAttribute('data-animating')).toBe(true)
+ resize(768)
+ expect(frame.hasAttribute('data-animating')).toBe(false)
+ })
+
+ it('keeps the main content at full width while opening and closing the retained drawer', () => {
+ frameWidth = 390
+ const { frame, instance, sidebarOwner, getByTestId } = mountFrame()
+ const sidebar = getByTestId('sidebar-content')
+ const main = getByTestId('main-content')
+ const leading = getByTestId('shell.leading-content')
+ expect(tracks(frame)).toEqual([0, 0])
+ expect(sidebarOwner()).toEqual({ collapsed: true, width: 280, drawer: true })
+ expect(sidebar.parentElement?.hasAttribute('inert')).toBe(true)
+ const mask = frame.querySelector('[data-sidebar-mask]')
+ expect(mask).not.toBeNull()
+ fireEvent.click(mask!)
+ expect(sidebarOwner().collapsed).toBe(true)
+ act(() => { instance.actions.toggleSidebar() })
+ expect(tracks(frame)).toEqual([0, 0])
+ expect(sidebarOwner().collapsed).toBe(false)
+ expect(sidebar.parentElement?.hasAttribute('inert')).toBe(false)
+ expect(main.closest('[inert]')).not.toBeNull()
+ expect(leading.closest('[inert]')).not.toBeNull()
+ expect(frame.querySelector('[data-side="sidebar"]')).toBeNull()
+ fireEvent.click(frame.querySelector('[aria-hidden="true"]')!)
+ expect(sidebarOwner().collapsed).toBe(true)
+ expect(main.closest('[inert]')).toBeNull()
+ expect(getByTestId('sidebar-content')).toBe(sidebar)
+ expect(getByTestId('main-content')).toBe(main)
+ expect(getByTestId('shell.leading-content')).toBe(leading)
+ expect(frame.querySelector('[data-sidebar-mask]')).toBe(mask)
+ })
+
+ it('closes the drawer on navigation and restores the rail at 768px', () => {
+ frameWidth = 767
+ const { frame, instance, sidebarOwner } = mountFrame()
+ act(() => { instance.actions.toggleSidebar() })
+ act(() => { instance.actions.selectPanel('plugins' as MainPanelId) })
+ expect(sidebarOwner().collapsed).toBe(true)
+ act(() => { instance.actions.toggleSidebar() })
+ resize(768)
+ expect(sidebarOwner()).toEqual({ collapsed: true, width: 56, drawer: false })
+ expect(tracks(frame)).toEqual([56, 0])
+ resize(767)
+ expect(sidebarOwner().collapsed).toBe(true)
+ expect(tracks(frame)).toEqual([0, 0])
+ })
+})
diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts
index 96aac84fd1..7ab8585886 100644
--- a/packages/client/ui-layout/tests/apply.client.spec.ts
+++ b/packages/client/ui-layout/tests/apply.client.spec.ts
@@ -6,13 +6,14 @@ import { Context, type Fiber } from '@deepseek-ai/cordis'
import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
-import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
+import type { HostObservable, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
import type { MainPanelId } from '../src/client/service.ts'
import type { createLayoutStore } from '../src/client/stores.ts'
+import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts'
const owners = new Set()
let originalRootStyle: string | null
@@ -93,12 +94,35 @@ describe('ui-layout client apply', () => {
expect(slots.spec('shell.leading')).toEqual({ kind: 'single', scope: 'root' })
})
+ it('keeps Desktop sidebar shortcuts behind a foreground modal', async () => {
+ const { ctx, slots } = await bench()
+ const shortcuts = ctx.get('shortcuts')
+ if (shortcuts === undefined) throw new Error('shortcuts service is unavailable')
+ const register = vi.spyOn(shortcuts, 'register')
+ await ctx.plugin({ inject: [...inject], apply }).await()
+ const command = register.mock.calls.find(([entry]) => entry.id === 'sidebar.left.toggle')?.[0]
+ if (command === undefined) throw new Error('sidebar shortcut was not registered')
+ const registry = new ShortcutRegistry('desktop', 'macos')
+ registry.register(command)
+ const handle = slots.entries('root')[0]!.store as ReturnType
+ const width = () => handle.create().getSnapshot().layoutInfo.sidebar
+ const gesture = { code: 'KeyB', meta: true, control: false, alt: false, shift: false,
+ repeat: false, composing: false, defaultPrevented: false }
+ const context = { region: 'editable' as const, target: null, modal: 'settings' }
+ expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('pass')
+ expect(width()).toBe(280)
+ expect(registry.dispatch(gesture, { ...context, modal: null }, vi.fn()).status).toBe('handled')
+ expect(width()).toBe(0)
+ expect(registry.dispatch(gesture, { ...context, modal: 'sidebar' }, vi.fn()).status).toBe('handled')
+ expect(width()).toBe(280)
+ })
+
it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => {
const { ctx, slots, rendererHost } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = slots.entries('root')[0]!
- expect(entry.inject).toBeUndefined()
+ expect(entry.inject).toBeTypeOf('function')
const handle = entry.store as ReturnType
const instance = handle.create()
expect(handle.create()).toBe(instance)
@@ -149,6 +173,47 @@ describe('ui-layout client apply', () => {
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
})
+ it('injects one live folded-landscape source and releases each subscription', async () => {
+ const query = Object.assign(new EventTarget(), { matches: true })
+ vi.stubGlobal('matchMedia', (media: string) => media === '(device-posture: folded) and (orientation: landscape)'
+ ? query : Object.assign(new EventTarget(), { matches: false }))
+ const { ctx, slots } = await bench()
+ const fiber = ctx.plugin({ inject: [...inject], apply })
+ await fiber.await()
+ const entry = slots.entries('root')[0]
+ if (entry?.inject === undefined) throw new Error('the root entry must inject its media source')
+ const first = entry.inject() as { hooks: { foldedLandscape: HostObservable } }
+ const second = entry.inject() as typeof first
+ const source = first.hooks.foldedLandscape
+ expect(second.hooks.foldedLandscape).toBe(source)
+ expect(source.getSnapshot()).toBe(true)
+ const listener = vi.fn()
+ const unsubscribe = source.subscribe(listener)
+ try {
+ query.matches = false
+ query.dispatchEvent(new Event('change'))
+ expect(source.getSnapshot()).toBe(false)
+ expect(listener).toHaveBeenCalledOnce()
+ } finally { unsubscribe() }
+ query.matches = true
+ query.dispatchEvent(new Event('change'))
+ expect(listener).toHaveBeenCalledOnce()
+ })
+
+ it('provides ordinary layout when the client tree starts without browser media queries', async () => {
+ vi.stubGlobal('matchMedia', undefined)
+ const { ctx, slots } = await bench()
+ const fiber = ctx.plugin({ inject: [...inject], apply })
+ await fiber.await()
+ const entry = slots.entries('root')[0]
+ if (entry?.inject === undefined) throw new Error('the root entry must inject its media source')
+ const source = (entry.inject() as { hooks: { foldedLandscape: HostObservable } }).hooks.foldedLandscape
+ expect(source.getSnapshot()).toBe(false)
+ const listener = vi.fn()
+ source.subscribe(listener)()
+ expect(listener).not.toHaveBeenCalled()
+ })
+
it('teardown unwinds the service, the root registration, and the child declarations', async () => {
const { ctx, slots, rendererHost } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
diff --git a/packages/client/ui-layout/tests/columns.client.spec.ts b/packages/client/ui-layout/tests/columns.client.spec.ts
index 4278516f0e..98aed13230 100644
--- a/packages/client/ui-layout/tests/columns.client.spec.ts
+++ b/packages/client/ui-layout/tests/columns.client.spec.ts
@@ -18,6 +18,11 @@ describe('computeColumns', () => {
expect(computeColumns(1920, 0, 0)).toEqual({ sidebar: 56, center: 1864, rightbar: 0 })
})
+ it('aligns an expanded sidebar to the fold midpoint without widening a collapsed rail', () => {
+ expect(computeColumns(951, 280, 864, 56, true)).toEqual({ sidebar: 475.5, center: 475.5, rightbar: 0 })
+ expect(computeColumns(951, 0, 0, 56, true)).toEqual({ sidebar: 56, center: 895, rightbar: 0 })
+ })
+
it('clamps sidebar preferences and limits the right panel to 70% of the frame', () => {
expect(computeColumns(3000, 9999, 9999)).toEqual({ sidebar: 420, center: 480, rightbar: 2100 })
expect(computeColumns(1920, 1, 1)).toEqual({ sidebar: 264, center: 1356, rightbar: 300 })
diff --git a/packages/client/ui-layout/tests/layout-store.client.spec.ts b/packages/client/ui-layout/tests/layout-store.client.spec.ts
index edf2a5a3c9..a2e5226568 100644
--- a/packages/client/ui-layout/tests/layout-store.client.spec.ts
+++ b/packages/client/ui-layout/tests/layout-store.client.spec.ts
@@ -290,3 +290,21 @@ describe('right panel instant geometry', () => {
expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false })
})
})
+
+describe('drawer navigation', () => {
+ it.each(['plugins' as MainPanelId, null])('closes the narrow drawer on navigation to %s', (panel) => {
+ const { store, actions } = createLayoutStore().create()
+ actions.setViewportWidth(390)
+ actions.toggleSidebar()
+ actions.selectPanel(panel)
+ expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false)
+ })
+
+ it('keeps a tablet sidebar open when navigating', () => {
+ const { store, actions } = createLayoutStore().create()
+ actions.setViewportWidth(768)
+ actions.toggleSidebar()
+ actions.selectPanel(null)
+ expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true)
+ })
+})
diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json
index b2daaaf054..ae8dfb07f6 100644
--- a/packages/client/ui-layout/tsconfig.json
+++ b/packages/client/ui-layout/tsconfig.json
@@ -23,9 +23,6 @@
{
"path": "../ui-theme/tsconfig.client.json"
},
- {
- "path": "../ui-primitives"
- },
{
"path": "../store"
},
diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json
index bcd578c59f..b785d7c3da 100644
--- a/packages/client/ui-message-feedback/package.json
+++ b/packages/client/ui-message-feedback/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-message-feedback",
"description": "The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind both ratings and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes",
- "version": "0.2.1-rc.3",
+ "version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml
index 177bc09fb2..e66c1a88ac 100644
--- a/packages/client/ui-model-selection/README.i18n.yaml
+++ b/packages/client/ui-model-selection/README.i18n.yaml
@@ -21,8 +21,8 @@
en: 2e6d9965e01dc0ce
zh: 332cc82233eb02f3
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-label-extensions:
- en: bc47871cf8b61451
- zh: 17d21da46604f071
+ en: 23610fb9daf9918a
+ zh: 9f63a97901be100c
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions:
en: 7f20428c747ff052
zh: 975e6de728e02fa0
@@ -30,8 +30,8 @@
en: 261bbf42296243af
zh: 72779fb7b8fbc473
/deepseek-ai-dsh-client-ui-model-selection/understand-the-implementation:
- en: f3d38594439eab3d
- zh: f255974e61696dca
+ en: 56003671ca5acf24
+ zh: 251e02201ea82142
/deepseek-ai-dsh-client-ui-model-selection/further-exploration:
en: 14acd4692e923480
zh: ff89aed852aa0da8
@@ -42,8 +42,8 @@
en: 68e54c00412a40d1
zh: 0ac96f57fd194d96
/deepseek-ai-dsh-client-ui-model-selection/known-limitations-and-deferred-work:
- en: 75b37954ceacd92a
- zh: a49d0361486e35c4
+ en: 7003aff3aa57e40c
+ zh: 450d88a5e9332f05
/deepseek-ai-dsh-client-ui-model-selection/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md
index b2c809123f..a3e3dae81f 100644
--- a/packages/client/ui-model-selection/README.md
+++ b/packages/client/ui-model-selection/README.md
@@ -49,7 +49,7 @@ The composer replaces the model and effort text with the Models icon when the ex
`conversation.input.model.label` is a session-scoped, inline `single` slot for the composer trigger (`placement: 'trigger'`), the root menu's current value (`'menu'`), and each model option (`'option'`). Its owner props provide `provider: { id, name }`, the exact catalog `model`, `placement`, and `selected`. Provider names are localized display names; match channels by `provider.id`, and identify a model by the provider/model ID pair. Connection URLs and credentials are not passed to the slot.
-Contributions must render non-interactive inline content, such as a colored name or badge. The selector owns clicks, search, focus, disabled states, check marks, reasoning effort, and trigger accessibility text. An absent contribution, including after plugin disposal, displays the catalog name. The trigger and root-menu value use the slot only when the selected model is present in the displayed catalog; otherwise they keep the built-in placeholder. During refresh, retained catalog entries continue to use the slot. Open menus track label size changes to stay inside the viewport. `/model` command rows use the command popup's separate rendering API.
+Contributions must render non-interactive inline content, such as a colored name or badge. The selector owns clicks, search, focus, disabled states, check marks, reasoning effort, and trigger accessibility text. An absent contribution, including after plugin disposal, displays the catalog name with its optional `model.badge` before the name in the trigger and after it in menus: a `Tag` in the badge's tone whose text is resolved for the active locale. Long catalog names ellipsize within the available width while the badge remains visible. A contribution replaces both and receives the badge in its owner props. The trigger and root-menu value use the slot only when the selected model is present in the displayed catalog; otherwise they keep the built-in placeholder. During refresh, retained catalog entries continue to use the slot. Open menus track label size changes to stay inside the viewport. `/model` command rows use the command popup's separate rendering API.
Import this package's `/client` declarations type-only, then register through `ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({ name: 'conversation.input.model.label' }, ModelLabel))`. Derive `ModelLabel` props from `PropsRuntime<'conversation.input.model.label'>`; use provider IDs to choose CSS Module classes and retain `model.name` as the text. Registration follows the declaration and contributing plugin lifetimes. Without a component replacement, styles can target the enclosing label's `data-provider-id` and `data-model-id` attributes in all three placements.
@@ -73,7 +73,7 @@ Menus use the shared `MenuSurface` material, including the macOS backing for bac
The composer `ModelSelect` and `/model` option builder share [provider ordering](src/client/provider-order.ts), while both searches use `rankByName` within each provider. The command supplies optional groups and `searchMode: 'fuzzy-label'` through the [popupSelect API](../ui-commands/README.md#use-this-package); both entries use `MenuGroup` and rebuild its sticky observer when the rendered groups change. The command popup fills the composer overlay, while the button retains its compact menu.
-Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's available directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one. The directory publishes the latest submitted selection as `pending` until it settles or a connection reset invalidates it; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated`, `settings/document-updated`, and credential update events.
+Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's available directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one. The directory publishes the latest submitted selection as `pending` until it settles or a connection reset invalidates it; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated`, `llm/models-updated`, `settings/document-updated`, and credential update events.
@@ -109,6 +109,7 @@ These limits define the current model surface. They are current package constrai
- **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-selection contract.
- **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload.
- **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent.
+- **Badges label the composer seat only** — `/model` popup rows, the trigger's accessible name, and its tooltip keep the plain catalog name.
### Dev Note
diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md
index e99a1e05ce..63c2cd9924 100644
--- a/packages/client/ui-model-selection/README.zh.md
+++ b/packages/client/ui-model-selection/README.zh.md
@@ -49,7 +49,7 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重
`conversation.input.model.label` 是会话作用域的内联 `single` slot,覆盖 composer 触发按钮(`placement: 'trigger'`)、根菜单中的当前值(`'menu'`)和各模型选项(`'option'`)。owner props 提供 `provider: { id, name }`、准确的目录 `model`、`placement` 和 `selected`。提供方名称是本地化显示名称;按 `provider.id` 匹配渠道,按提供方与模型 ID 的组合识别模型。slot 不接收连接 URL 或凭据。
-扩展必须渲染非交互的内联内容,例如带颜色的名称或徽标。选择器负责点击、搜索、焦点、禁用状态、勾选标记、推理强度和触发按钮的无障碍文字。没有扩展(包括插件卸载后)时显示目录名称。触发按钮和根菜单当前值仅在所选模型存在于显示目录中时调用 slot,否则保留内置占位文字。刷新期间保留的目录条目仍调用 slot。菜单打开时会跟随名称内容的尺寸变化调整位置,保持在视口内。`/model` 命令行使用命令弹窗独立的渲染 API。
+扩展必须渲染非交互的内联内容,例如带颜色的名称或徽标。选择器负责点击、搜索、焦点、禁用状态、勾选标记、推理强度和触发按钮的无障碍文字。没有扩展(包括插件卸载后)时显示目录名称;模型带有目录角标 `model.badge` 时,触发按钮在名称前显示一个按角标语调着色的 `Tag`,菜单仍放在名称后,文字按当前语言解析。较长的目录名称会在可用宽度内省略,角标保持可见。扩展会同时替换两者,并从 owner props 收到角标。触发按钮和根菜单当前值仅在所选模型存在于显示目录中时调用 slot,否则保留内置占位文字。刷新期间保留的目录条目仍调用 slot。菜单打开时会跟随名称内容的尺寸变化调整位置,保持在视口内。`/model` 命令行使用命令弹窗独立的渲染 API。
以 type-only 方式导入本包的 `/client` 声明,再通过 `ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({ name: 'conversation.input.model.label' }, ModelLabel))` 注册。`ModelLabel` 的 props 从 `PropsRuntime<'conversation.input.model.label'>` 派生;用提供方 ID 选择 CSS Module 类,并保留 `model.name` 作为文字。注册随声明和贡献插件的生命周期清理。不替换组件时,样式也可以在三个显示位置通过名称外层的 `data-provider-id` 和 `data-model-id` 属性定位。
@@ -73,7 +73,7 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重
composer 的 `ModelSelect` 与 `/model` 选项构建器共用[提供方排序](src/client/provider-order.ts),两个搜索框均在每个提供方内使用 `rankByName`。命令通过 [popupSelect API](../ui-commands/README.zh.md#use-this-package) 提供可选分组与 `searchMode: 'fuzzy-label'`;两个入口均使用 `MenuGroup`,并在渲染分组变化时重建其吸顶观察器。命令弹窗撑满 composer 浮层,按钮则保留紧凑菜单。
-两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的可用目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果。目录把最近一次提交的选择发布为 `pending`,直到它完成或被连接重置作废;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated`、`settings/document-updated` 与凭据更新事件上重拉。
+两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的可用目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果。目录把最近一次提交的选择发布为 `pending`,直到它完成或被连接重置作废;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated`、`llm/models-updated`、`settings/document-updated` 与凭据更新事件上重拉。
@@ -109,6 +109,7 @@ composer 的 `ModelSelect` 与 `/model` 选项构建器共用[提供方排序](s
- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent(智能体);没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立的模型选择约定。
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
+- **角标只出现在 composer 模型位**——`/model` 弹窗各行、触发按钮的无障碍名称及其提示仍只显示目录名称。
### 开发备注
diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json
index 594bef2c3a..fda2972fd2 100644
--- a/packages/client/ui-model-selection/package.json
+++ b/packages/client/ui-model-selection/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-model-selection",
"description": "Model selection over the shared model catalog, Session projection, and session.selectModel",
- "version": "0.2.1-rc.3",
+ "version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
index a4fbb31611..c36809acaa 100644
--- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css
@@ -250,6 +250,10 @@
background: var(--dsw-alias-interactive-bg-hover);
}
+.modelOption {
+ max-width: 100%;
+}
+
@media (hover: hover) {
.option:not(.modelOption):hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
@@ -284,6 +288,25 @@
white-space: nowrap;
}
+.badgedName {
+ display: inline-flex;
+ align-items: center;
+ max-width: 100%;
+ gap: 4px;
+ vertical-align: baseline;
+ white-space: nowrap;
+}
+
+.badgedNameText {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.badge {
+ flex: none;
+}
+
.check {
display: grid;
place-items: center;
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
index 59dbf85427..f68d355317 100644
--- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
@@ -37,7 +37,7 @@ import clsx from 'clsx'
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import {
IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseFillRegular,
- IconDataOutlineRegular, IconWarningOutlineRegular, Input, isMobileDevice, rankByName, StateDot, Toast,
+ IconDataOutlineRegular, IconWarningOutlineRegular, Input, isMobileDevice, rankByName, StateDot, Tag, Toast,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelLabelOwner, ModelSelectInjected } from './slots.ts'
@@ -64,7 +64,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* @returns the trigger and, while open, the two-level menu.
*/
export function ModelSelect(
- { locked, available, directory, load, select, t, renderSlot }:
+ { locked, available, directory, load, select, resolveText, t, renderSlot }:
ModelSelectInjected & Pick, 'locked'> & PropsLocale<'model'>
& Omit, 'SessionProvider'>,
) {
@@ -441,7 +441,15 @@ export function ModelSelect(
{renderSlot('conversation.input.model.label', {
provider, model, placement, selected,
- }, { fallback: model.name })}
+ }, {
+ fallback: model.badge === undefined ? model.name : (
+
+ {placement === 'trigger' ? <>{resolveText(model.badge.text)}{' '}> : null}
+ {model.name}
+ {placement === 'trigger' ? null : <>{' '}{resolveText(model.badge.text)}>}
+
+ ),
+ })}
)
let itemIndex = 0
diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts
index f1f15a1154..b91ee20b48 100644
--- a/packages/client/ui-model-selection/src/client/index.ts
+++ b/packages/client/ui-model-selection/src/client/index.ts
@@ -184,6 +184,7 @@ export function apply(ctx: ClientContext): void {
load: () => {
if (available) directory.load().catch(() => { /* surfaced on the store */ })
},
+ resolveText: text => ctx.locale.resolveText(text),
select: (selection: ModelSelection) => available
? directory.select(selection)
: Promise.resolve(undefined),
diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts
index 18fa88cc86..5052388e16 100644
--- a/packages/client/ui-model-selection/src/client/service.ts
+++ b/packages/client/ui-model-selection/src/client/service.ts
@@ -52,6 +52,7 @@ export class ModelDirectoryResolver extends Service {
for (const directory of this.live.directories.values) directory.resetConnected()
})
ctx.remote.$on('llm/adapters-updated', () => { this.catalog.refresh() })
+ ctx.remote.$on('llm/models-updated', () => { this.catalog.refresh() })
ctx.remote.$on('settings/document-updated', () => { this.catalog.refresh() })
ctx.remote.$on('credentials/record-updated', () => { this.catalog.refresh() })
ctx.remote.$on('credentials/reference-updated', () => { this.catalog.refresh() })
diff --git a/packages/client/ui-model-selection/src/client/slots.ts b/packages/client/ui-model-selection/src/client/slots.ts
index 47e1be2b0c..4ffddee580 100644
--- a/packages/client/ui-model-selection/src/client/slots.ts
+++ b/packages/client/ui-model-selection/src/client/slots.ts
@@ -21,7 +21,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* Non-interactive inline model label; selection controls remain owner-managed.
- * Without a contribution, the composer displays the catalog name.
+ * Without a contribution, the badge precedes the catalog name in the trigger
+ * and follows it in menus.
* @example
* return {
* inject: ['slots'],
@@ -50,6 +51,12 @@ export interface ModelSelectInjected {
directory: SnapshotStore
/** Ensure the shared advisory catalog is loaded (errors land on the store). */
load: () => void
+ /**
+ * Choose a catalog badge's provider-authored text for the active locale.
+ * @param text - the badge's literal text or translations.
+ * @returns the text to display.
+ */
+ resolveText: (text: NonNullable['text']) => string
/**
* Select a complete provider/model/reasoning selection.
* @param selection - model selection and optional adapter-owned effort.
diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
index 778a1df137..4bf00c63bd 100644
--- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
@@ -227,6 +227,14 @@ describe('ui-model-selection dual entry', () => {
expect(b.seat().locale).toBe('model')
})
+ it.each([['zh', '新'], ['en', 'New']] as const)('resolves seat badge text for the active %s locale', async (locale, expected) => {
+ const b = await bench(locale)
+ b.mint('s1')
+ const face = b.seat().inject!(sid('s1'))
+ expect(face.resolveText({ en: 'New', zh: '新' })).toBe(expected)
+ expect(face.resolveText('1017')).toBe('1017')
+ })
+
it.each(['zh', 'en'] as const)('shows names and providers without catalog descriptions (%s)', async (locale) => {
const b = await bench(locale)
b.mint('s1')
@@ -384,6 +392,27 @@ describe('ui-model-selection dual entry', () => {
})
})
+ it('replaces and removes badges in an open directory on model catalog notifications', async () => {
+ const b = await bench()
+ try {
+ b.mint('s1')
+ const face = b.seat().inject!(sid('s1'))
+ for (const badge of [{ text: 'New', tone: 'info' as const }, { text: 'Updated', tone: 'success' as const }, undefined]) {
+ b.setGroups(GROUPS.map(group => ({
+ ...group, models: group.models.map(model => ({ ...model, ...(badge === undefined ? {} : { badge }) })),
+ })))
+ b.remote.emit('llm/models-updated', ['deepseek-official'])
+ await vi.waitFor(() => {
+ expect(face.directory.getSnapshot().status).toBe('ready')
+ expect(face.directory.getSnapshot().groups[0]?.models[0]?.badge).toEqual(badge)
+ })
+ }
+ expect(face.directory.getSnapshot().current?.model).toBe('deepseek-v4-flash')
+ } finally {
+ await b.ctx.fiber.dispose()
+ }
+ })
+
it('retains the selected model while refreshing its catalog', async () => {
const b = await bench()
b.mint('s1')
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
index bb4832a0ce..dbb5b247db 100644
--- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
+++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx
@@ -25,6 +25,8 @@ const t: ComponentProps['t'] = (key, params) => {
const renderLabel: ComponentProps['renderSlot'] = (_name, _owner, options) => options?.fallback ?? null
+const resolveText: ComponentProps['resolveText'] = text => typeof text === 'string' ? text : text.en
+
const reasoning = {
efforts: [
{ id: 'off', name: 'Off' },
@@ -97,7 +99,8 @@ describe('ModelSelect label slot', () => {
await runtime.root.declare({
'conversation.input.model.label': { kind: 'single', scope: 'session', inline: true },
}, ({ renderSlot, SessionProvider }) =>
-
+ )
runtime.renderRoot()
const trigger = screen.getByRole('button', { name: '选择模型,当前 Same model' })
@@ -140,7 +143,8 @@ describe('ModelSelect label slot', () => {
it('does not decorate loading or missing selections with a stale provider', () => {
const directory = createSnapshotStore(state({ groups: [], status: 'loading' }))
const renderSlot = vi.fn(() => null)
- render()
+ render()
expect(renderSlot).not.toHaveBeenCalled()
expect(document.querySelector('[data-provider-id]')).toBeNull()
act(() => { directory.set(state({ groups: [] })) })
@@ -149,6 +153,57 @@ describe('ModelSelect label slot', () => {
})
})
+describe('ModelSelect catalog badges', () => {
+ const BADGE = { text: { en: 'New', zh: '新' }, tone: 'info' } as const
+ const badged = () => state({
+ current: { provider: 'deepseek-official', model: 'deepseek-flash' },
+ groups: [{
+ id: 'deepseek-official',
+ name: 'DeepSeek',
+ models: [
+ { id: 'deepseek-flash', name: 'DeepSeek-V4.1-Flash', badge: BADGE },
+ { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
+ ],
+ }],
+ })
+ const chinese: ComponentProps['resolveText'] = text => typeof text === 'string' ? text : text.zh ?? text.en
+
+ it('places the badge before the trigger name and after names in the menus', () => {
+ render()
+ const trigger = screen.getByRole('button', { name: '选择模型,当前 DeepSeek-V4.1-Flash' })
+ expect(trigger.textContent).toBe('新 DeepSeek-V4.1-Flash')
+ expect(screen.getByText('新').getAttribute('data-tone')).toBe('info')
+ fireEvent.click(trigger)
+ const current = screen.getByRole('menuitem', { name: /^模型/ })
+ expect(current.textContent).toContain('DeepSeek-V4.1-Flash 新')
+ fireEvent.click(current)
+ expect(screen.getAllByRole('menuitemradio').map(row => row.textContent)).toEqual(['DeepSeek-V4.1-Flash 新', 'DeepSeek-V4-Pro'])
+ })
+
+ it('hands the badge to a label contribution, which replaces the default rendering', async () => {
+ const runtime = await SlotTestRuntime.create()
+ onTestFinished(() => runtime.dispose())
+ const sessionId = SessionId('model-badge')
+ await runtime.sessions.add({ id: sessionId })
+ const session = runtime.sessions.retainFor(runtime.ctx, sessionId)
+ await runtime.root.declare({
+ 'conversation.input.model.label': { kind: 'single', scope: 'session', inline: true },
+ }, ({ renderSlot, SessionProvider }) =>
+
+ )
+ runtime.renderRoot()
+ await runtime.mount({ inject: ['slots'], apply(ctx) {
+ ctx.slots.inject('conversation.input.model.label', () => ctx.slots.register({
+ name: 'conversation.input.model.label',
+ }, ({ model }) => {model.name} [{model.badge?.tone ?? 'none'}]))
+ } })
+ expect(screen.getByRole('button', { name: '选择模型,当前 DeepSeek-V4.1-Flash' }).textContent).toBe('DeepSeek-V4.1-Flash [info]')
+ expect(screen.queryByText('新')).toBeNull()
+ })
+})
+
describe('ModelSelect reasoning effort', () => {
it('renders effort names without descriptions and submits the effort as part of the session selection', async () => {
const directory = createSnapshotStore(state())
@@ -160,7 +215,7 @@ describe('ModelSelect reasoning effort', () => {
locked={false}
available
directory={directory}
- load={vi.fn()}
+ load={vi.fn()} resolveText={resolveText}
select={select}
t={t}
/>)
@@ -203,7 +258,7 @@ describe('ModelSelect reasoning effort', () => {
locked={false}
available
directory={directory}
- load={vi.fn()}
+ load={vi.fn()} resolveText={resolveText}
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
t={t}
/>)
@@ -225,7 +280,7 @@ describe('ModelSelect reasoning effort', () => {
locked={false}
available
directory={directory}
- load={vi.fn()}
+ load={vi.fn()} resolveText={resolveText}
select={select}
t={t}
/>)
@@ -246,7 +301,8 @@ describe('ModelSelect reasoning effort', () => {
it.each(['model', 'provider'])('hides the saved id and effort when the selected %s disappears', (removed) => {
const directory = createSnapshotStore(state({ retainedEffort: 'High' }))
- render()
+ render()
expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toContain('DeepSeek-V4-Flash')
act(() => { directory.update((snapshot) => {
snapshot.groups = removed === 'provider' ? [] : snapshot.groups.map(group => ({ ...group, models: [] }))
@@ -272,7 +328,7 @@ describe('ModelSelect reasoning effort', () => {
locked={false}
available
directory={directory}
- load={vi.fn()}
+ load={vi.fn()} resolveText={resolveText}
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
t={t}
/>)
@@ -308,7 +364,7 @@ describe('ModelSelect reasoning effort', () => {
locked={false}
available
directory={directory}
- load={vi.fn()}
+ load={vi.fn()} resolveText={resolveText}
select={select}
t={t}
/>)
@@ -351,7 +407,8 @@ describe('ModelSelect reasoning effort', () => {
}
})
})
- render()
+ render()
const spinners = () => document.querySelectorAll('[data-state="ongoing"]')
const trigger = screen.getByRole('button', { name: /选择模型|当前/ })
@@ -380,7 +437,8 @@ describe('ModelSelect reasoning effort', () => {
directory.set(state({ status: 'selecting', pending: selection }))
return new Promise(() => {})
})
- render()
+ render()
fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
@@ -400,7 +458,7 @@ describe('ModelSelect reasoning effort', () => {
locked={false}
available
directory={createSnapshotStore(state())}
- load={vi.fn()}
+ load={vi.fn()} resolveText={resolveText}
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
t={t}
/>)
@@ -433,7 +491,7 @@ describe('ModelSelect reasoning effort', () => {
locked={false}
available={false}
directory={createSnapshotStore(state())}
- load={load}
+ load={load} resolveText={resolveText}
select={vi.fn().mockResolvedValue(undefined)}
t={t}
/>)
@@ -450,7 +508,7 @@ describe('ModelSelect keyboard walk', () => {
locked={false}
available
directory={createSnapshotStore(state())}
- load={vi.fn()}
+ load={vi.fn()} resolveText={resolveText}
select={select}
t={t}
/>)
@@ -479,7 +537,7 @@ describe('ModelSelect keyboard walk', () => {
const select = vi.fn()
render()
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
@@ -588,7 +646,7 @@ describe('ModelSelect keyboard walk', () => {
locked={false}
available
directory={createSnapshotStore(state())}
- load={vi.fn()}
+ load={vi.fn()} resolveText={resolveText}
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
t={t}
/>)
@@ -634,7 +692,7 @@ describe('ModelSelect keyboard walk', () => {
locked={false}
available
directory={directory}
- load={load}
+ load={load} resolveText={resolveText}
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
t={t}
/>)
@@ -693,7 +751,7 @@ describe('ModelSelect keyboard walk', () => {
locked={false}
available
directory={createSnapshotStore(state({ current: { provider: 'gone', model: 'gone' } }))}
- load={vi.fn()}
+ load={vi.fn()} resolveText={resolveText}
select={vi.fn().mockResolvedValue({ ok: true, value: undefined })}
t={t}
/>)
@@ -710,7 +768,7 @@ describe('ModelSelect catalog size', () => {
const groups = modelGroups(count)
const current = { provider: 'deepseek-official', model: count > 1 ? 'model-2' : 'deepseek-v4-flash' }
render()
+ load={vi.fn()} resolveText={resolveText} select={vi.fn()} t={t} />)
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
if (count > 0) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
@@ -733,7 +791,7 @@ describe('ModelSelect catalog size', () => {
it.each([0, 1, 4, 5])('opens an unselected %i-model catalog on search or its first row', (count) => {
render()
+ load={vi.fn()} resolveText={resolveText} select={vi.fn()} t={t} />)
const trigger = screen.getByRole('button', { name: '请选择模型' })
fireEvent.click(trigger)
const rows = screen.queryAllByRole('menuitemradio')
@@ -746,7 +804,8 @@ describe('ModelSelect catalog size', () => {
it.each([true, false])('clears search and restores focus across 5 → 4 → 5 models (checked: %s)', (checked) => {
const current = { provider: 'deepseek-official', model: checked ? 'model-3' : 'removed' }
const directory = createSnapshotStore(state({ groups: modelGroups(5), current }))
- render()
+ render()
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
if (checked) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
const search = screen.getByRole('searchbox')
@@ -775,7 +834,7 @@ describe('ModelSelect catalog size', () => {
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
render()
+ load={vi.fn()} resolveText={resolveText} select={select} t={t} />)
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
@@ -806,7 +865,7 @@ describe('ModelSelect catalog size', () => {
it('keeps Tab selection available after hovering a small-catalog row', async () => {
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
render()
+ load={vi.fn()} resolveText={resolveText} select={select} t={t} />)
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
const row = screen.getByRole('menuitemradio', { name: 'Model 3' })
@@ -821,7 +880,7 @@ describe('ModelSelect catalog size', () => {
it.each(['Escape', 'Tab'])('leaves a small model pane with %s and returns to its root cell', (key) => {
const select = vi.fn()
render()
+ load={vi.fn()} resolveText={resolveText} select={select} t={t} />)
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
const row = screen.getAllByRole('menuitemradio')[0]!
@@ -838,7 +897,7 @@ describe('ModelSelect search', () => {
it('clears search when reopening an unselected large catalog', () => {
render()
+ load={vi.fn()} resolveText={resolveText} select={vi.fn()} t={t} />)
const trigger = screen.getByRole('button', { name: '请选择模型' })
fireEvent.click(trigger)
const search = screen.getByRole('searchbox')
@@ -856,7 +915,8 @@ describe('ModelSelect search', () => {
const directory = createSnapshotStore(state({ groups: [
...state().groups, { id: 'empty', name: 'Empty Provider', models: [] },
] }))
- render()
+ render()
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
expect(screen.queryByRole('group', { name: 'Empty Provider' })).toBeNull()
@@ -870,7 +930,7 @@ describe('ModelSelect search', () => {
render(<>
+ load={vi.fn()} resolveText={resolveText} select={select} t={t} />
>)
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
@@ -896,7 +956,8 @@ describe('ModelSelect search', () => {
] },
],
}))
- render()
+ render()
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
const search = screen.getByRole('searchbox')
@@ -948,7 +1009,8 @@ describe('ModelSelect search', () => {
{ id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] },
] }))
const select = vi.fn().mockResolvedValue({ ok: true, value: undefined })
- render()
+ render()
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
@@ -994,7 +1056,8 @@ describe('ModelSelect search', () => {
it('hides the inherited effort until a listed model is selected', async () => {
const directory = createSnapshotStore(state({ current: null, routable: false, retainedEffort: 'High' }))
- render()
+ render()
const trigger = screen.getByRole('button', { name: '请选择模型' })
expect(trigger.hasAttribute('disabled')).toBe(false)
await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt')
@@ -1018,7 +1081,8 @@ it('places account and official models before third-party models', async () => {
id, name: id, models: [1, 2].map(index => ({ id: `${id}-${index}`, name: `${id}-${index}` })),
}))
const directory = createSnapshotStore(state({ current: null, groups }))
- render()
+ render()
fireEvent.click(screen.getByRole('button', { name: '请选择模型' }))
const names = screen.getAllByRole('menuitemradio').map(row => row.textContent)
expect(names).toEqual([
@@ -1036,7 +1100,7 @@ it.each([en, zh])('localizes the account group while preserving external names',
}))
render( key in copy ? copy[key as keyof typeof copy] : key} />)
+ load={vi.fn()} resolveText={resolveText} select={vi.fn()} t={key => key in copy ? copy[key as keyof typeof copy] : key} />)
fireEvent.click(screen.getByRole('button', { name: copy['trigger.selectAria'] }))
expect(screen.getByRole('group', { name: copy['provider.account'] })).toBeTruthy()
expect(screen.getByRole('group', { name: 'My Gateway' })).toBeTruthy()
@@ -1048,7 +1112,8 @@ it('restores the account model name after login without changing the saved route
] }]
const selected = { provider: 'deepseek-account', model: 'deepseek-flash', reasoningEffort: 'high' }
const directory = createSnapshotStore(state({ current: selected, groups, retainedEffort: 'High' }))
- render()
+ render()
expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh')
act(() => { directory.update((snapshot) => { snapshot.groups = []; snapshot.routable = false }) })
expect(screen.getByRole('button', { name: '请选择模型' }).textContent).toBe('请选择模型')
diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json
index 6088f56dd7..f50cb25154 100644
--- a/packages/client/ui-open-in-app/package.json
+++ b/packages/client/ui-open-in-app/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-open-in-app",
"description": "Web \"Open In...\" controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file",
- "version": "0.2.1-rc.3",
+ "version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json
index 8e5a256421..3adaa84393 100644
--- a/packages/client/ui-permission-presets/package.json
+++ b/packages/client/ui-permission-presets/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-permission-presets",
"description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection",
- "version": "0.2.1-rc.3",
+ "version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json
index 7956c6b884..9a8e654009 100644
--- a/packages/client/ui-plan/package.json
+++ b/packages/client/ui-plan/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-plan",
"description": "Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews",
- "version": "0.2.1-rc.3",
+ "version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml
index c1aab681db..433443206e 100644
--- a/packages/client/ui-plugin-manager/README.i18n.yaml
+++ b/packages/client/ui-plugin-manager/README.i18n.yaml
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
- en: 535d6322bcfb329b
- zh: fbf05155ccbb3e0e
+ en: 07da57fb0a0cb10e
+ zh: c743fd96317bec22
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
en: fcb2e69c8b028c0a
zh: d505a075679974eb
diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md
index 397dd2495f..80a24d6fee 100644
--- a/packages/client/ui-plugin-manager/README.md
+++ b/packages/client/ui-plugin-manager/README.md
@@ -27,7 +27,9 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
## Use this package
-[Ralph loops](../../experimental/ralph-bundle/README.md) and [Session search](../../experimental/session-search/README.md) remain bundled but are hidden from both plugin lists unless the profile enables or separately installs them. Existing selections remain manageable; switching off an installation-carried copy hides it again. Enter either exact package name in **Add plugin**, then choose **Enable now** to use the shipped copy without downloading it again. Sidebar session search is independent.
+Below 768px, the page uses 20px horizontal insets to align with the conversation header.
+
+[Ralph loops](../../experimental/ralph-bundle/README.md) and [Session search](../../experimental/session-search/README.md) remain bundled but are hidden from both plugin lists unless the profile enables or separately installs them. Existing selections remain manageable; switching off an installation-carried copy hides it again. Enter either exact package name in **Add plugin**, then choose **Enable now** to use the shipped copy without downloading it again. Sidebar session search is independent. LiveShare declares the same default-hidden intent in its bundle manifest (`dsh.bundle.listedByDefault: false`), so it stays out of both lists, including **More**, until a declared row runs or it is enabled. The page's manifest rule is generic: a bundle whose manifest does not declare `listedByDefault: false` is listed; one that does is listed only when the profile enables it, the installation carries it selected, or one of its declared rows runs; a hidden bundle is not opened by its ID either.
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists the official bundles eligible for display, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone unless its manifest hides unselected bundles; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
@@ -37,7 +39,7 @@ Installed bundles and their plugin rows display their own title and description
A bundle's page shows the bundle's detail document first among its sections, without a heading of its own: the bundle's long description in the current UI language, as Markdown with images. It is the Markdown file that `meta.detail` in the bundle's `locale/.json` names, otherwise the bundle's README, read by [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.md#bundle-detail-documents) in the [document order of `dsh-plugin-detail`](../../util/plugin-detail/README.md#document-order); the repository a bundle declares is not read. An installed bundle's document comes from its own files without network access, and an on-demand Official bundle's from the npm file CDNs at its offered version. While the document loads the section shows three skeleton lines; a bundle without a document shows no section; a failed read names its reason with **Retry**. Raw HTML is omitted, relative images load from the Host beside the document, relative links stay text, and a document taller than the section's height limit scrolls inside it. A bundle whose files are not installed, such as an on-demand Official bundle, is read from the npm file CDNs: its read first asks the Host's registry comparison and, when npmmirror answered fastest, reads it and its images from npmmirror first. An installed bundle's read does not wait for the comparison. Any profile change and a connection reset read shown documents again.
-On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable.
+On-demand Official cards follow package discovery visibility and are available offline before installation. In Add plugin, a bare Official package name uses the Host’s matching-version or source-checkout target, retains the selected registry, and offers Enable now after installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable.
The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states.
diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md
index dccd4cacd3..c66d0384db 100644
--- a/packages/client/ui-plugin-manager/README.zh.md
+++ b/packages/client/ui-plugin-manager/README.zh.md
@@ -27,7 +27,9 @@ kind: "package-reference"
## 使用本包
-[Ralph 循环](../../experimental/ralph-bundle/README.zh.md)和[会话搜索](../../experimental/session-search/README.zh.md)仍随包提供,但默认不在两个插件列表中展示,只有 profile 启用或另行安装后才显示。已有的启用状态仍可管理;关闭随安装提供的版本后,它会再次隐藏。在**添加插件**中输入准确包名,再选择**立即启用**,即可使用随包提供的版本,无需再次下载。侧栏会话搜索独立于此功能。
+宽度小于 768px 时,页面使用 20px 的左右内边距,与会话页头对齐。
+
+[Ralph 循环](../../experimental/ralph-bundle/README.zh.md)和[会话搜索](../../experimental/session-search/README.zh.md)仍随包提供,但默认不在两个插件列表中展示,只有 profile 启用或另行安装后才显示。已有的启用状态仍可管理;关闭随安装提供的版本后,它会再次隐藏。在**添加插件**中输入准确包名,再选择**立即启用**,即可使用随包提供的版本,无需再次下载。侧栏会话搜索独立于此功能。LiveShare 在其 bundle manifest 中声明相同的默认隐藏意图(`dsh.bundle.listedByDefault: false`),因此在自身声明的行开始运行或被启用前,它不会出现在两个列表(含**更多**页)中。页面的 manifest 规则是通用的:未声明 `listedByDefault: false` 的组合包会列出;声明了的,只有在 profile 启用、安装随附版本被选中,或它声明的某一行开始运行时才列出;隐藏的组合包也不能通过 ID 打开详情。
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出符合展示条件的官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页,但 manifest 声明默认隐藏的组合包会从该页消失;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
@@ -37,7 +39,7 @@ kind: "package-reference"
组合包页面在其各部分中首先显示该组合包的详细介绍文档,不带单独的标题:以当前界面语言、带图片的 Markdown 显示该组合包的详细介绍。该文档是组合包 `locale/.json` 中 `meta.detail` 指定的 Markdown 文件,没有时为组合包的 README,由 [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.zh.md#bundle-detail-documents) 按 [`dsh-plugin-detail` 的文档顺序](../../util/plugin-detail/README.zh.md#document-order)读取;不读取组合包声明的仓库。已安装组合包的文档来自其自身文件,不访问网络;按需官方组合包的文档按其提供的版本从 npm 文件 CDN 读取。文档加载期间,该部分显示三行骨架屏;没有文档的组合包不显示该部分;读取失败时说明原因,并提供**重试**。原始 HTML 会被省略,相对路径的图片从 Host 上该文档旁的位置加载,相对链接保持为文本,文档高度超过该部分的高度上限时在该部分内滚动。文件未安装的组合包(例如按需官方组合包)从 npm 文件 CDN 读取:读取前先询问 Host 的注册表比较,npmmirror 响应最快时,先从 npmmirror 读取该文档及其图片。已安装组合包的读取不等待该比较。任何 profile 变化和连接重置都会重新读取已显示的文档。
-官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。
+官方按需卡片遵循包的显示声明,在安装前即可离线读取。在“添加插件”中输入不带版本的官方包名时,使用 Host 提供的匹配版本或源码 checkout 目标,保留用户选择的安装源,并在安装后提供“立即启用”。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。
首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。
diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json
index c8637e13ca..6cd4a7c5fc 100644
--- a/packages/client/ui-plugin-manager/package.json
+++ b/packages/client/ui-plugin-manager/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-plugin-manager",
"description": "Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages",
- "version": "0.2.1-rc.3",
+ "version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
index b9516b2a10..595b38885c 100644
--- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
+++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
@@ -25,6 +25,12 @@
color: var(--dsw-alias-label-primary);
}
+@media (max-width: 767px) {
+ .page {
+ --plugin-page-inset: 20px;
+ }
+}
+
.page > * {
flex: none;
width: 100%;
diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
index 6acfce2d20..1eaa62f810 100644
--- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
+++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
@@ -99,6 +99,18 @@ function AddPluginMenu({ t, disabled, openInstall, renderSlot }: {
/** How long the list marks a package an install just enabled. */
const HIGHLIGHT_MS = 2_400
+/**
+ * Whether any plugin row this bundle's patch declares is running.
+ *
+ * A default-hidden bundle's rows may run without the profile selecting it, for example through
+ * `cordis.patch.yml`; that run state is the effective enablement the lists offer.
+ * @param pkg - the bundle as the Host reports it.
+ * @returns whether at least one declared row runs.
+ */
+function rowsRunning(pkg: PackageView): boolean {
+ return pkg.rows.some(row => row.enabled)
+}
+
/**
* Whether the main list shows an official optional bundle: any bundle outside `MORE_BUNDLES`, and any held-back one the profile enabled.
* @param pkg - the official bundle as the Host reports it.
@@ -1546,7 +1558,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
// The page omits profile bundles and persistent terminals. Other installed or offered bundles and
// unreadable selections remain manageable; Settings' Plugin list inspects the full Host inventory.
const listed = state.packages.filter(pkg => !HIDDEN_BUNDLES.has(pkg.name)
- && (pkg.listedByDefault !== false || pkg.enabled || pkg.installed)
+ && (pkg.listedByDefault !== false || pkg.enabled || pkg.installed || rowsRunning(pkg))
&& (pkg.installed || pkg.official || pkg.error !== undefined))
const mine = listed.filter(pkg => !pkg.official)
const official = listed.filter(pkg => pkg.official)
@@ -1559,7 +1571,9 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
const more = ordered
const loaded = state.status === 'ready' || state.status === 'error'
const refreshing = state.refreshStatus === 'refreshing'
- const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined
+ const openPkg = view.kind === 'package' || view.kind === 'row'
+ ? listed.find(pkg => pkg.name === view.name)
+ : undefined
const openItem = view.kind === 'item' ? ledger.items.find(item => item.id === view.id) : undefined
const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined
const onInstalled = tab === INSTALLED_TAB
diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts
index b4b982ebf5..60ed21484a 100644
--- a/packages/client/ui-plugin-manager/src/client/manager-store.ts
+++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts
@@ -139,7 +139,7 @@ export type InstallSubject = Extract pkg.name === spec)
+ let catalogSubject = install.subject?.selection === undefined || install.subject.spec !== spec ? undefined : install.subject
+ if (listed?.availability === 'missing' && listed.installTarget !== undefined
+ && listed.error === undefined && catalogSubject === undefined) {
+ const target = listed.installTarget
+ const local = target.spec.startsWith('link:')
+ catalogSubject = {
+ status: 'accepted', kind: local ? 'path' : 'registry', name: listed.name, version: target.version, bundle: true,
+ spec: target.spec, registry: null, ...local ? {} : { saveExact: true },
+ }
+ }
if (this.ctx.get('productAnalytics')?.enabled) {
this.analyticsAttempt = { input: sanitizeInstallInput(spec), started: Date.now() }
this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(spec) })
}
- const listed = state.packages.find(pkg => pkg.name === spec)
- const catalogSubject = install.subject?.selection === undefined || install.subject.spec !== spec ? undefined : install.subject
if (listed?.availability === 'installation' && listed.optional && listed.listedByDefault === false
&& !listed.enabled && listed.error === undefined && listed.readOnlyReason === undefined) {
this.abortInspect()
diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
index 041d5d2a39..1cb3a73e15 100644
--- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
+++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
@@ -803,6 +803,60 @@ describe('PluginManagerPage', () => {
expect(b.actions.uninstall).not.toHaveBeenCalled()
})
+ it('keeps a shipped bundle out of both lists until one of its declared rows runs', () => {
+ const hidden = '@deepseek-ai/dsh-experimental-liveshare'
+ const shown = '@deepseek-ai/dsh-experimental-agent-team-profile'
+ const offer = (rows: readonly PackageRow[]): PackageView => pkg({
+ name: hidden, official: true, installed: false, optional: true, removable: false, enabled: false, listedByDefault: false, rows,
+ })
+ const other = pkg({ name: shown, installed: false, optional: true, removable: false, enabled: false })
+ const b = renderTab({ packages: [offer([]), other] })
+ // The default installation carries the package but composes none of its rows: neither the main
+ // list nor the More list offers a card for it.
+ expect(document.querySelector(`[data-plugin-package="${hidden}"]`)).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: en.moreAction }))
+ expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy()
+ expect(document.querySelector(`[data-plugin-package="${hidden}"]`)).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+
+ // A row inserted through cordis.patch.yml runs while the profile selects no such bundle: the card
+ // is listed as experimental exactly like a bundle the profile selected.
+ b.set({
+ packages: [
+ offer([row({ rowId: 'liveshare-viewer-declarations', moduleName: `${hidden}/viewer-declarations` })]),
+ other,
+ ],
+ })
+ const extensions = document.querySelector('[data-plugin-group="extensions"]') as HTMLElement
+ expect(within(extensions).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package')))
+ .toEqual([shown, hidden])
+
+ // Disabling that row takes the card away again, as removing it would.
+ b.set({
+ packages: [
+ offer([row({
+ rowId: 'liveshare-viewer-declarations', moduleName: `${hidden}/viewer-declarations`,
+ enabled: false, phase: null,
+ })]),
+ other,
+ ],
+ })
+ expect(document.querySelector(`[data-plugin-package="${hidden}"]`)).toBeNull()
+ })
+
+ it('does not open a default-hidden bundle by id while its card is hidden', () => {
+ const hidden = '@deepseek-ai/dsh-experimental-liveshare'
+ const b = renderTab({
+ packages: [pkg({
+ name: hidden, official: true, installed: false, optional: true, removable: false, enabled: false,
+ listedByDefault: false,
+ })],
+ })
+ expect(document.querySelector(`[data-plugin-package="${hidden}"]`)).toBeNull()
+ act(() => { b.navigation.actions.setView({ kind: 'package', name: hidden }) })
+ expect(document.querySelector(`[data-plugin-detail="${hidden}"]`)).toBeNull()
+ })
+
it.each([false, true])('shows an empty list for hidden bundles with errors and installed=%s', (installed) => {
renderTab({
packages: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-experimental-terminal-bundle'].map(name => pkg({
diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
index 47dc13c330..0662768c8b 100644
--- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
+++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
@@ -1919,6 +1919,23 @@ const CATALOG: BundleInfo = {
installTarget: { spec: '@deepseek-ai/dsh-subagent-codex@2.0.0', version: '2.0.0' },
}
+it.each(['registry', 'source'] as const)('installs a typed default-hidden Official name using its %s target', async (mode) => {
+ const name = '@deepseek-ai/dsh-cua-bundle'
+ const spec = mode === 'source' ? 'link:/workspace/cua-bundle' : `${name}@2.0.0`
+ const entry = { ...CATALOG, name, listedByDefault: false, installTarget: { spec, version: '2.0.0' } }
+ const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([entry])) })
+ await controller.load()
+ face.openInstall()
+ face.editInstallSpec(name)
+ face.runInstall()
+ await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledOnce() })
+ expect(plugins.inspect).not.toHaveBeenCalled()
+ expect(state().install.subject?.selection).toBeUndefined()
+ expect(plugins.installBundle).toHaveBeenCalledWith(spec, {
+ enabled: false, ...mode === 'registry' ? { saveExact: true } : {}, requestId: state().install.requestId, registry: null,
+ })
+})
+
it('installs an absent Official entry directly at the host target and enables it through the normal installer', async () => {
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG])) })
await controller.load()
diff --git a/packages/client/ui-plugin-manager/tests/touch-styles.client.spec.ts b/packages/client/ui-plugin-manager/tests/touch-styles.client.spec.ts
index 79cd0229f9..f3e637dd54 100644
--- a/packages/client/ui-plugin-manager/tests/touch-styles.client.spec.ts
+++ b/packages/client/ui-plugin-manager/tests/touch-styles.client.spec.ts
@@ -59,6 +59,11 @@ function ruleBody(selector: string): string {
}
describe('plugin page touch rules', () => {
+ it('uses the compact page inset below 768px', () => {
+ const narrow = /@media \(max-width: 767px\) \{([\s\S]*?)\n\}/.exec(rules)?.[1]
+ expect(narrow).toContain('--plugin-page-inset: 20px')
+ })
+
it('fences every hover affordance behind a hover-capable pointer', () => {
// Unguarded, the browser keeps `:hover` on the tapped element until the next tap
// elsewhere: a card or menu row stays painted as if the pointer were still on it.
diff --git a/packages/client/ui-plugin-market/package.json b/packages/client/ui-plugin-market/package.json
index 9606553275..7c926938d4 100644
--- a/packages/client/ui-plugin-market/package.json
+++ b/packages/client/ui-plugin-market/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-plugin-market",
"description": "Discover tab of the dsh web Plugins page: browse configured plugin market indexes and install their bundles",
- "version": "0.2.1-rc.3",
+ "version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index e3b1049c7d..c65aef7e7d 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -15,23 +15,23 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
- en: 10be7f75a1576575
- zh: abf2905d89b0da48
+ en: beec1ea02d0adc12
+ zh: 9a0fc5e0e4d78722
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
- en: 3c79351ff2210ccb
- zh: 4cbe07ded89bbaa1
+ en: 289fe5c01b026e81
+ zh: 5680066a8443fba9
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
- en: b3744373b4b2c1fb
- zh: 2dbde8ee0bfa5f28
+ en: f13b4ba09521d300
+ zh: a6093993d8fd0cdb
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
- en: 32d4557c4429e00d
- zh: 293a6fef712f7502
+ en: 3271871a42bd52c8
+ zh: 89b7a9f03018454d
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
en: a943603b74080ef5
zh: 43f7a0188aff271b
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation:
- en: 846ac0f563a53a64
- zh: bb89d7647bdcc1ab
+ en: d223f2e301758b0f
+ zh: 5d1bc5a5e5431dbf
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map:
en: fd510d34404eb090
zh: d05b0890db929ae6
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index a1285641e2..6852adbbae 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -25,7 +25,7 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
## Use this package
-`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`) and [keyboard navigation](#input-modality) is inactive. Keyboard navigation reveals them until the next pointer press; keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
+`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`) and [keyboard navigation](#input-modality) is inactive. Keyboard navigation reveals them until the next pointer press; keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. A dialog that remains mounted restores focus left inside its closed layer, preserving focus already moved elsewhere. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
`isMobileDevice()` identifies iOS and Android phones and tablets for automatic text-input focus and keyboard dismissal. It recognizes `Android`, `iPhone`, `iPad`, or `iPod` in the UA, and iPads reporting `MacIntel` with more than one touch point. Other devices, including Windows touchscreen computers, return false regardless of window width. The function caches its first result for all callers in the page; component remounts reuse that result, and reloading the page reads the browser environment again. Identified mobile devices require explicit text-input focus. Browser modes or custom UAs that conceal the mobile platform can prevent identification; unrecognized UAs return false. A desktop UA alone cannot distinguish Android tablets from Linux computers.
@@ -59,7 +59,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. |
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. The header supplies tertiary at rest and secondary on hover; text and icons inherit it regardless of expandability or open state unless the caller supplies a feature-specific override. Semantic status colors remain explicit. Collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. `headerAccessory` places an independent action outside the disclosure target and shimmer; with an accessory, the title row shrinks to its content width. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. |
| `AnimatedRows` | Keyed list movement with 200 ms glides and 100 ms entry/exit fades. Starts after local pointer or keyboard input, honors reduced motion, and settles loading or view replacement. Mark rows with `data-row-key`, supply matching `rowKeys`, and position the parent for the inert exit overlay; `role` and `label` belong to the caller. |
-| `useRowDragPreview` | Native drag preview using an inert offscreen row copy. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. |
+| `useRowDragPreview` | Native drag preview using an inert offscreen row copy. The optional `inheritedProperties` list names the CSS custom properties whose computed values the offscreen wrapper copies; omitting it copies none. Keep the list reference stable, because replacing it clears an active preview. Unlisted custom properties follow normal inheritance at the preview's location. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. |
| `TextShimmer` | One left-to-right highlight across a row’s title, separators, summary, and suffix, with a 300ms initial delay, a one-second sweep and a 500ms rest. The mask spans the content width, capped by the visible row and tilts 15° from vertical, with a flat peak and soft edges. The moving decoration stays clipped to the row without enlarging its scrollable area. Base text inherits the caller’s color, including hover changes; the theme supplies the translucent highlight. Place icons and chevrons outside `TextShimmer` so only text and separators receive the highlight. A string child uses the same retained text interface; nested `TextShimmer` instances share the outer animation. Composite children render twice while active and must have no effects or element ids. Mark separators with `data-shimmer-decoration` so their fill receives the highlight. `DisclosureRow` forwards `contentClassName` for the text area and `contentLayoutClassName` for its inner layout. The inert decorative copy is excluded from interaction, selection, and accessibility; text updates retain the animation, and reduced-motion mode keeps the static base. |
| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. |
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
@@ -104,11 +104,15 @@ The catalog above lists what each export is for; this section covers the behavio
`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. `as="span"` preserves an inline text container.
+An open hover card closes immediately when an ancestor of its anchor becomes inert or hidden.
+
### Rendering agent output
The pure Markdown helper `formatQuoteText` normalizes CRLF and CR line endings, then prefixes each line with `> `.
-The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. A destination with a backslash and no URL scheme (a drive letter is not one) is a Windows path when it has no `/` or has a backslash before a letter or digit. Such a path keeps every backslash, including one before punctuation, and still decodes character references, so `D:\repo\.github` is not read as an escaped `.`. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
+The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Paths support percent escapes, `#L24` / `#L24-L30` fragments, and `:24` / `:24-30` / `:24:5` suffixes. Ranges open at their first line; columns are consumed but not selected. Fragments disable suffix parsing. Zero, unsafe integers, descending ranges, and unsupported fragments open the file without selecting a line. Literal `?`, `#`, and numeric colon suffixes in filenames must be percent-encoded. A destination with a backslash and no URL scheme (a drive letter is not one) is a Windows path when it has no `/` or has a backslash before a letter or digit. Such a path keeps every backslash, including one before punctuation, and still decodes character references, so `D:\repo\.github` is not read as an escaped `.`. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives that path and optional line; the label and file icon remain visible. Without a callback, local links remain text. Queries, malformed escapes, and control characters never reach the file opener.
+
+Explicit `./`, `../`, slash/backslash-rooted paths (including UNC), and Windows drive paths such as `C:/work/file.ts` take precedence over URL parsing. Otherwise, destinations accepted by the absolute URL parser remain URLs: `index.ts:24`, `Makefile:12`, and `localhost:3000` are links, without adding a protocol or testing whether they can open. Use `./index.ts:24` for a file reference. Other destinations are paths. Authored links reject `javascript:`, `vbscript:`, `data:`, and `blob:`; other parseable protocols retain native navigation. HTTP(S) links retain delegated navigation and modified-click behavior. No click-time failure notice is added.
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A bare URL ends before non-ASCII punctuation or symbols, such as `。` or `,`, so CJK prose that follows it stays outside the link. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text).
@@ -135,6 +139,8 @@ The atoms cannot read the application locale, so every piece of user-facing copy
`Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. The `outline` variant preserves a transparent 0.5px layout border and paints its stroke with an inset shadow. The stroke reads ui-theme's DPR-aware `--dsh-hairline-width`, with a 0.5px fallback outside the theme. Feature classes customize its color through `--dsh-button-outline-color` and preserve the border width, transparent border color, and box-shadow. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry.
+Portalled menus close when their anchor becomes inert or hidden; their owner’s retained content stays mounted.
+
`Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color.
`Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index c5b9360995..34c8f8250a 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -25,7 +25,7 @@ kind: "package-library"
## 使用本包
-`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 监听。
+`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。关闭后保留在 DOM 中的弹窗仅恢复仍停留于内部的焦点,不覆盖已经移到其他位置的焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
`isMobileDevice()` 识别 iOS 和 Android 手机及平板,供自动聚焦和收起键盘使用。它识别 UA 中的 `Android`、`iPhone`、`iPad` 或 `iPod`,以及报告 `MacIntel` 且触摸点数大于一的 iPad。其他设备(包括 Windows 触屏电脑)返回 false,不受窗口宽度影响。函数缓存首次判断结果,供页面内的所有调用方共享;组件重新挂载会复用该结果,重新加载页面时会再次读取浏览器环境。识别出的移动设备需要用户主动聚焦文本输入框。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。
@@ -59,7 +59,7 @@ kind: "package-library"
| `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 |
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。标题行默认使用 tertiary,悬停时使用 secondary;文字与图标继承该颜色,不受能否展开及开合状态影响,调用方可提供功能专属覆盖。语义状态色保持显式指定。收起时悬停预览向下箭头,展开后持续显示向上箭头。`headerAccessory` 将独立操作放在折叠触发区域和流光之外;有此操作时,标题行收窄到内容宽度。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 |
| `AnimatedRows` | 带键列表的位移动画:移动为 200 ms,进出淡变为 100 ms。列表内发生指针或键盘输入后才启用,遵循减少动态效果设置,加载或替换视图时直接完成。为行标记 `data-row-key` 并传入对应的 `rowKeys`,为父容器设置定位以承载不可交互的退出层;`role` 和 `label` 由调用方提供。 |
-| `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 |
+| `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。可选的 `inheritedProperties` 列表指定离屏包装层需要复制其计算值的 CSS 自定义属性;省略时不复制。列表引用需保持稳定,因为替换列表会清除活动预览。未列出的自定义属性按预览所在位置的正常继承规则生效。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 |
| `TextShimmer` | 同一道高光从左到右扫过一行的标题、分隔符、摘要与后缀:首次等待 300ms,扫动一秒,再静止 500ms。遮罩覆盖内容宽度,并限制在可见行宽内,相对垂直方向倾斜 15°,峰值区域平坦,两侧柔和渐隐。移动的装饰层始终裁剪在行内,不会扩大可滚动区域。底色继承调用方的颜色,包括悬停变化;主题提供半透明高光。图标和箭头放在 `TextShimmer` 外,只有文字和分隔符参与扫光。字符串子节点沿用稳定的文字接口;嵌套的 `TextShimmer` 共用外层动画。活动期间组合子节点渲染两次,因此不得带副作用或元素 id。分隔符标记 `data-shimmer-decoration`,让填色参与扫光。`DisclosureRow` 将 `contentClassName` 传给文字区域,将 `contentLayoutClassName` 传给内部布局。惰性装饰副本不参与交互、选区和无障碍访问;文字更新保留动画,减少动态效果模式保留静态底色。 |
| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 |
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
@@ -104,11 +104,15 @@ kind: "package-library"
`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。`as="span"` 保留行内文本容器。
+锚点的祖先变为 inert 或隐藏时,已打开的悬浮卡片立即关闭。
+
### 渲染 agent 输出
纯 Markdown 工具 `formatQuoteText` 规范化 CRLF 和 CR 换行符,再为每行添加 `> ` 前缀。
-最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。含反斜杠且无 URL 协议(盘符不算 URL 协议)的目标,若不含 `/`,或在字母或数字前有反斜杠,即为 Windows 路径。此类路径保留所有反斜杠(包括标点前的反斜杠),仍会解码字符引用,因此 `D:\repo\.github` 不会被读作转义的 `.`。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
+最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。路径支持百分号转义、`#L24` / `#L24-L30` 片段或 `:24` / `:24-30` / `:24:5` 后缀。范围定位到起始行;列号会被移除但不参与定位。有片段时不解析后缀。零、超出安全整数范围的值、倒序范围及不支持的片段只打开文件,不指定行号。文件名中的字面 `?`、`#` 和数字冒号后缀必须百分号编码。含反斜杠且无 URL 协议(盘符不算 URL 协议)的目标,若不含 `/`,或在字母或数字前有反斜杠,即为 Windows 路径。此类路径保留所有反斜杠(包括标点前的反斜杠),仍会解码字符引用,因此 `D:\repo\.github` 不会被读作转义的 `.`。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收该路径和可选行号;标签与文件图标保留。不传回调时,本地链接仍为文本。查询串、格式错误的转义和控制字符不会传给文件打开器。
+
+明确的 `./`、`../`、斜杠或反斜杠开头的路径(包括 UNC),以及 `C:/work/file.ts` 这样的 Windows 盘符路径优先于 URL 解析。其余目标若能被绝对 URL 解析器接受,就保留为 URL:`index.ts:24`、`Makefile:12` 和 `localhost:3000` 均为链接,不补协议,也不预先检查能否打开。文件引用使用 `./index.ts:24`。其他目标视为路径。正文链接禁止 `javascript:`、`vbscript:`、`data:` 和 `blob:`;其余可解析的协议保留原生导航。HTTP(S) 链接保留委托导航和修饰键点击行为。不添加点击后的失败提示。
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。裸 URL 在非 ASCII 标点或符号(如 `。`、`,`)前结束,紧随其后的中文正文不会并入链接。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。
@@ -135,6 +139,8 @@ kind: "package-library"
`Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。`outline` 变体保留 0.5px 透明边框用于布局,并通过内阴影绘制描边。描边宽度读取 ui-theme 随设备像素比更新的 `--dsh-hairline-width`,未安装主题时回退到 0.5px。功能样式通过 `--dsh-button-outline-color` 定制描边颜色,并保留边框宽度、透明边框颜色和 box-shadow。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。
+锚点变为 inert 或隐藏时,portal 菜单会关闭,其所属区域保留的内容继续挂载。
+
`Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。
`Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。
diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json
index c186e06dc8..40b94573d8 100644
--- a/packages/client/ui-primitives/package.json
+++ b/packages/client/ui-primitives/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-primitives",
"description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)",
- "version": "0.2.1-rc.3",
+ "version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx
index f11c6bad30..bd727d0c85 100644
--- a/packages/client/ui-primitives/src/HoverCard.tsx
+++ b/packages/client/ui-primitives/src/HoverCard.tsx
@@ -174,6 +174,10 @@ export function HoverCard({
const wrapper = rootRef.current
/* v8 ignore next -- the ref is attached before the layout effect runs and the listeners die with it. */
if (wrapper === null) return
+ if (wrapper.closest('[inert], [hidden]') !== null) {
+ dismissImmediately()
+ return
+ }
const r = wrapper.getBoundingClientRect()
const h = cardRef.current?.offsetHeight ?? 0
if (variant === 'preview') {
@@ -220,6 +224,11 @@ export function HoverCard({
setPos({ left: r.right + ANCHOR_GAP, top })
}
place()
+ // Observe only the anchor's ancestors: an offscreen drawer can hide without resizing its retained row.
+ const visibility = new MutationObserver(place)
+ for (let ancestor = rootRef.current?.parentElement; ancestor != null; ancestor = ancestor.parentElement) {
+ visibility.observe(ancestor, { attributes: true, attributeFilter: ['inert', 'hidden'] })
+ }
const observer = (variant === 'preview' || inline) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null
for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) {
if (element !== null && element !== undefined) observer?.observe(element)
@@ -228,10 +237,11 @@ export function HoverCard({
window.addEventListener('resize', place)
return () => {
observer?.disconnect()
+ visibility.disconnect()
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
- }, [open, variant, widthAnchorRef, positioned, inline, fitContent])
+ }, [open, variant, widthAnchorRef, positioned, inline, fitContent, dismissImmediately])
// The first placement ran before the card mounted (height read 0): once the
// card's real height is measurable, correct the bottom-edge clamp. The
diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx
index 68b3a5766e..90f76edd1a 100644
--- a/packages/client/ui-primitives/src/Menu.tsx
+++ b/packages/client/ui-primitives/src/Menu.tsx
@@ -240,6 +240,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
openRef.current = open
const [openSubmenuId, setOpenSubmenuId] = useState(null)
const [fixedPos, setFixedPos] = useState(null)
+ const close = useRef(onClose)
+ close.current = onClose
const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose)
// Portal mode: fixed-position the list from the anchor rect before paint;
@@ -250,6 +252,10 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
useLayoutEffect(() => {
if (!open || !portal) { setFixedPos(null); return }
const place = () => {
+ if (rootRef.current?.closest('[inert], [hidden]') != null) {
+ close.current()
+ return
+ }
let r: DOMRect | null
if (getAnchorRect !== undefined) {
r = getAnchorRect()
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
index fac585e700..004c962963 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
@@ -176,7 +176,9 @@ class StreamingRenderer {
* renderers, such as package READMEs. `textInteractionSource` marks the
* existing root as a source for text selection interactions.
* The provider's `openFile` enables local Markdown links in settled messages,
- * including `#L24` and `#L24-L30` destinations (ranges open at their first line).
+ * including `#L24` / `#L24-L30` fragments and `:24` / `:24-30` / `:24:5` suffixes
+ * (ranges open at their first line, columns are ignored, and invalid locations
+ * open only the file; see the README for URL/path classification).
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML never
* renders as markup, and unsafe protocols are disabled. Local links without an opener remain text;
* absolute HTTP(S) images render directly.
diff --git a/packages/client/ui-primitives/src/markdown/file-link.ts b/packages/client/ui-primitives/src/markdown/file-link.ts
index bb1ca89b0b..25c5edd88d 100644
--- a/packages/client/ui-primitives/src/markdown/file-link.ts
+++ b/packages/client/ui-primitives/src/markdown/file-link.ts
@@ -1,11 +1,24 @@
/** Local Markdown destinations accepted by the file-preview callback. */
/**
- * Decode a file destination and its optional GitHub-style line fragment.
- * Literal `?` and `#` in filenames must be percent-encoded.
+ * Recognize a relative, rooted, or absolute Windows path before URL parsing.
+ * @param value - A decoded or URI-normalized destination.
+ * @returns Whether a filesystem prefix makes the destination unambiguous.
+ */
+export function isExplicitFilePath(value: string): boolean {
+ return /^(?:[\\/]|\.{1,2}[\\/]|[a-z]:[\\/])/i.test(value)
+}
+
+/**
+ * Decode a file destination with a fragment, line range, or line/column suffix.
+ * Explicit relative, slash/backslash-rooted, and Windows drive paths take
+ * precedence over URLs. Other parseable absolute URLs are not file links.
+ * Fragments take precedence over colon suffixes; invalid locations open the
+ * file without selecting a line. Columns are consumed but not selected.
+ * Percent-encode literal `?`, `#`, and numeric colon suffixes in filenames.
* @param value - Parsed Markdown link destination.
* @returns A local path and optional first line, or undefined for URLs,
- * fragment-only links, queries, malformed escapes, or invalid line ranges.
+ * fragment-only links, queries, malformed escapes, or control characters.
*/
export function parseFileLink(value: string): { path: string; line?: number } | undefined {
const hash = value.indexOf('#')
@@ -18,15 +31,15 @@ export function parseFileLink(value: string): { path: string; line?: number } |
// Malformed percent escapes cannot identify a file unambiguously.
return undefined
}
- if (path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
- || /^[\\/]{2}/.test(path)
- || (/^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path))) return undefined
- if (hash < 0) return { path }
- const fragment = value.slice(hash + 1)
- const match = /^L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(fragment)
- if (match === null) return undefined
+ if (path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)) return undefined
+ if (!isExplicitFilePath(path) && URL.canParse(path)) return undefined
+ const suffix = hash < 0 ? /:(\d+)(?::\d+|-(\d+))?$/.exec(destination) : null
+ if (suffix !== null) path = path.slice(0, -suffix[0].length)
+ if (path.length === 0) return undefined
+ const match = suffix ?? (hash < 0 ? null : /^L(\d+)(?:-L(\d+))?$/.exec(value.slice(hash + 1)))
+ if (match === null) return { path }
const line = Number(match[1])
const end = match[2] === undefined ? line : Number(match[2])
- if (!Number.isSafeInteger(line) || !Number.isSafeInteger(end) || end < line) return undefined
+ if (!Number.isSafeInteger(line) || !Number.isSafeInteger(end) || line < 1 || end < line) return { path }
return { path, line }
}
diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx
index 5da48f6651..d97ca544d3 100644
--- a/packages/client/ui-primitives/src/markdown/render.tsx
+++ b/packages/client/ui-primitives/src/markdown/render.tsx
@@ -4,11 +4,11 @@
* cache frozen blocks as React elements; the rendered DOM is pinned
* byte-for-byte by `tests/fixtures/markdown-dom` and must not drift.
*
- * External link and image destinations pass a protocol allowlist; settled
+ * External links reject executable and embedded-document protocols; settled
* local file links use an explicit owner callback. Images additionally require
* absolute HTTP(S), raw HTML renders as literal text (no HTML enters the
- * DOM), and KaTeX runs without trusted commands. Fragment-anchor URLs fail
- * the allowlist, so footnote references and back-references render as plain
+ * DOM), and KaTeX runs without trusted commands. Fragment-only URLs are not
+ * absolute, so footnote references and back-references render as plain
* text rather than in-page links.
*
* Merge-extensible node unions fall through the documented default (render
@@ -24,7 +24,7 @@ import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
import type { CodeToolbarLabels } from '../CodeToolbar.tsx'
import { CodeBlock } from './CodeBlock.tsx'
-import { parseFileLink } from './file-link.ts'
+import { isExplicitFilePath, parseFileLink } from './file-link.ts'
import { renderTexToReact } from './katex.tsx'
import { LinkIconMedium, classifyLinkPath } from '../LinkIcon.tsx'
import { useMarkdownDelegate } from './MarkdownDelegate.tsx'
@@ -52,14 +52,16 @@ export interface MarkdownLabels {
}
function sanitizeUrl(url: string): string {
+ if (isExplicitFilePath(url)) return ''
try {
switch (new URL(url).protocol) {
- case 'http:':
- case 'https:':
- case 'mailto:':
- return url
- default:
+ case 'javascript:':
+ case 'vbscript:':
+ case 'data:':
+ case 'blob:':
return ''
+ default:
+ return url
}
} catch {
// Relative and otherwise unparsable destinations are disallowed alongside
@@ -572,7 +574,7 @@ function anchorWrapsOnlyImages(children: Md.PhrasingContent[]): boolean {
return children.length > 0 && children.every(child => child.type === 'image' || child.type === 'imageReference')
}
-/** Anchor over an already-authored href: allowlisted or unwrapped, with optional owner navigation for HTTP(S). */
+/** Anchor over a safe absolute href, with optional owner navigation for HTTP(S). */
function renderSafeLink(href: string, children: ReactNode[], key: Key, glyph = true): ReactNode {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return {children}
@@ -605,8 +607,9 @@ function MarkdownAnchor({ href, glyph, children }: {
/** Local destinations use the scoped file delegate after settlement. */
function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true, streaming = false): ReactNode {
- const file = streaming ? undefined : parseFileLink(url)
+ const file = parseFileLink(url)
if (file !== undefined) {
+ if (streaming) return {children}
return {children}
}
return renderSafeLink(normalizeUri(url), children, key, glyph)
@@ -753,7 +756,7 @@ function renderFootnoteReference(
const seen = context.footnoteCounts.get(id)
if (seen === undefined) context.footnoteOrder.push(id)
context.footnoteCounts.set(id, (seen ?? 0) + 1)
- // The in-page anchor fails the protocol allowlist, so only the numbered
+ // The in-page anchor is not an absolute URL, so only the numbered
// superscript renders (matching the replaced pipeline's unwrapped link).
return {String(context.footnoteOrder.indexOf(id) + 1)}
}
diff --git a/packages/client/ui-primitives/src/useModalLayer.ts b/packages/client/ui-primitives/src/useModalLayer.ts
index dff4a22697..2942c992f0 100644
--- a/packages/client/ui-primitives/src/useModalLayer.ts
+++ b/packages/client/ui-primitives/src/useModalLayer.ts
@@ -45,9 +45,17 @@ const focusable = 'button:not(:disabled), input:not(:disabled), textarea:not(:di
*/
export function useModalLayer(dialog: RefObject, open: boolean, onClose: () => void): void {
const close = useRef(onClose)
+ const pendingRestore = useRef(undefined)
close.current = onClose
useLayoutEffect(() => {
+ const target = pendingRestore.current
+ pendingRestore.current = undefined
const element = dialog.current
+ // Restore only focus left inside the retained dialog, preserving the owner's chosen target.
+ if (!open && element?.contains(element.ownerDocument.activeElement)
+ && target?.isConnected && !target.closest('[inert], [hidden]') && !isBehindModal(target)) {
+ focusWithoutRing(target, { preventScroll: true })
+ }
if (!open || element === null) return
const document = element.ownerDocument
const composition = observeComposition(document)
@@ -90,7 +98,10 @@ export function useModalLayer(dialog: RefObject, open: boole
if (stack.length === 0) layers.delete(document)
if (wasTop) {
const target = previous instanceof HTMLElement && previous.isConnected ? previous : stack.at(-1)?.element
- if (target !== undefined) focusWithoutRing(target)
+ if (target !== undefined) {
+ pendingRestore.current = target
+ focusWithoutRing(target)
+ }
}
}
}, [dialog, open])
diff --git a/packages/client/ui-primitives/src/useRowDragPreview.ts b/packages/client/ui-primitives/src/useRowDragPreview.ts
index f60e4bdc93..9a1e6fce84 100644
--- a/packages/client/ui-primitives/src/useRowDragPreview.ts
+++ b/packages/client/ui-primitives/src/useRowDragPreview.ts
@@ -7,15 +7,18 @@ import css from './RowDragPreview.module.css'
/**
* Own a row's inert drag preview until drag end, replacement, or unmount.
* The offscreen copy keeps WebKit snapshots separate from overlapping panels.
+ * The wrapper copies only explicitly requested computed custom properties.
* Its cloned slot DOM remains in document.body for that lifetime; query live rows
* within their list to exclude duplicate text and element IDs.
* @param className - preview background matching the row's surface; WebKit fills
* transparent snapshot pixels white, including rounded corners.
+ * @param inheritedProperties - custom-property names whose computed values the preview needs.
+ * Omit to copy none. Keep the list reference stable; replacing it clears the active preview.
* @returns preview callbacks; cleanup reports whether this row started a drag,
* even when its preview failed or the row became non-draggable before drag end.
* Start accepts the source row when a child handle owns the drag event.
*/
-export function useRowDragPreview(className: string): {
+export function useRowDragPreview(className: string, inheritedProperties?: readonly `--${string}`[]): {
start: (event: DragEvent, row?: HTMLElement) => void
clear: () => boolean
} {
@@ -48,6 +51,9 @@ export function useRowDragPreview(className: string): {
image.className = clsx(css.preview, className)
image.inert = true
image.setAttribute('aria-hidden', 'true')
+ inheritedProperties?.forEach((property) => {
+ image.style.setProperty(property, style.getPropertyValue(property))
+ })
Object.assign(image.style, {
left: `${String(-rect.width)}px`, width: `${String(rect.width)}px`,
height: `${String(rect.height)}px`, font: style.font,
@@ -64,7 +70,7 @@ export function useRowDragPreview(className: string): {
}
},
}
- }, [className])
+ }, [className, inheritedProperties])
useEffect(() => () => { preview.clear() }, [preview])
return preview
}
diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx
index ddc6718159..56e4ef5044 100644
--- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx
@@ -121,6 +121,22 @@ describe('HoverCard', () => {
expect(screen.getByRole('tooltip').textContent).toBe('Details')
})
+ it.each(['inert', 'hidden'])('dismisses a portal when its retained anchor ancestor becomes %s', async (attribute) => {
+ const view = render(row} content={