refactor(client): drop owner-era leftovers and gate sidebar New Session on session/create

- The Conversation binding tracking returns to master: the retainInfo subscription and the
  plugin-owned binding effects are a separate fix, not a served-page seam.
- The deliverables opener drops the ownerHostRoutes parameter no caller passes any more.
- Assertions on existing connection and HMR behavior, the menu-open-state spec, and the
  settings-general roster splice return to master.
- Workspace shortcuts no longer re-read Session access through source(); the Host refuses a
  denied rename, fork, or archive.
- The sidebar New Session button, the window-chrome New Session control, and the expanded
  brand shortcut render only while useRemoteAccess('session/create') allows it.
This commit is contained in:
imccyu authored and mektpoy committed 2026-10-22 21:44:25 +08:00
1 parent a154f70ab9
commit 0d521bdd0d
19 files changed
+104 -294

No files matched your search

@@ -478,25 +478,6 @@ describe('connection client apply', () => {
})
})
it('hands its carrier the document-relative route', async () => {
const handle = await mount()
const original = globalThis.fetch
const seen: string[] = []
globalThis.fetch = async (input: URL | RequestInfo) => {
seen.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url)
return Response.json({ type: 'server-response', rpcId: 'mismatch', result: { ok: true, value: null } })
}
try {
await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
} finally {
globalThis.fetch = original
}
// The caller posts the document-relative route and each carrier resolves it against its own
// base: the page's own fetch against its document (mount prefix included), a transport that
// owns another base against that one (the `RpcFetch` contract in ../src/client/rpc.ts).
expect(seen[0]).toBe('api/goals/create')
})
it('uses an already decoded rpc carrier from the transport hooks instead of the HTTP caller', async () => {
;(globalThis as Win).location = { hostname: 'preview.example' }
const rpc: ClientConnectionRpc = {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { EVENTS_ENDPOINT, EVENTS_ROUTE, parsePluginsEventFrame } from '../src/events.ts'
import { parsePluginsEventFrame } from '../src/events.ts'
describe('plugin event wire parser', () => {
it('accepts complete rebuilt and graph frames', () => {
@@ -20,9 +20,4 @@ describe('plugin event wire parser', () => {
expect(parsePluginsEventFrame(value)).toEqual({ kind: 'invalid' })
}
})
it('separates the server route from the browser-relative endpoint', () => {
expect(EVENTS_ENDPOINT).toBe('/plugins/events')
expect(EVENTS_ROUTE).toBe('plugins/events')
})
})
@@ -233,31 +233,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
const bindings = new Set<SessionBinding>()
const trackedBindings = new WeakSet<SessionBinding>()
// One binding-scope effect per tracked binding, owned by the plugin only until the scope releases:
// the long-lived plugin effect below must not hold a closure over a released binding's Context.
const bindingEffects = new Map<SessionBinding, () => void | Promise<void>>()
const trackBinding = (binding: SessionBinding): void => {
if (trackedBindings.has(binding)) return
trackedBindings.add(binding)
bindings.add(binding)
bindingEffects.set(binding, binding.ctx.effect(() => {
// Controller withdrawal precedes asynchronous Session-scope disposal.
const disposeRetention = sessions.retainInfo(binding.sessionId).subscribe(() => {
if (sessions.binding(binding.sessionId) !== binding) bindings.delete(binding)
})
return () => {
disposeRetention()
bindings.delete(binding)
bindingEffects.delete(binding)
}
}, 'ui-conversation: active Provider binding'))
binding.ctx.effect(() => () => { bindings.delete(binding) }, 'ui-conversation: active Provider binding')
}
ctx.effect(() => () => {
// A binding that released before the plugin already deleted its own entry; this releases the rest.
for (const dispose of [...bindingEffects.values()]) void dispose()
bindingEffects.clear()
bindings.clear()
}, 'ui-conversation: View binding ownership')
const refreshViews = (): void => {
const current = conversationViews.getSnapshot()
const next = viewTabs()
@@ -277,7 +258,6 @@ export function apply(ctx: Context, config: Config = Config({})): void {
disposeDeveloperTools()
disposeLocale()
disposeViews()
bindings.clear()
}
}, 'ui-conversation: View selection')
@@ -604,56 +604,6 @@ describe('Conversation inject API', () => {
await b.runtime.dispose()
})
it('releases View refresh ownership before a withdrawn Session or plugin can receive locale changes', async () => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
const removeView = b.slots.register(
{ name: 'conversation.view', id: 'chat', label: 'Chat' },
(() => null) as never,
)
onTestFinished(removeView)
const binding = b.rootReference.binding
b.runtime.ctx.uiSession.bindingSource(b.rootReference)
const resolve = vi.spyOn(b.runtime.ctx.uiConversation, 'binding')
const errors = vi.spyOn(console, 'error')
onTestFinished(() => { resolve.mockRestore(); errors.mockRestore() })
resolve.mockClear()
b.rootReference.release()
expect(b.runtime.sessions.binding(ROOT)).toBeUndefined()
b.runtime.ctx.locale.setLocale('en')
expect(resolve).not.toHaveBeenCalled()
expect(errors).not.toHaveBeenCalled()
await binding.ctx.fiber.dispose()
await b.feature.dispose()
b.runtime.ctx.locale.setLocale('zh')
expect(resolve).not.toHaveBeenCalled()
expect(errors).not.toHaveBeenCalled()
})
it('detaches View refresh when Conversation unloads before its retained Session', async () => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
const removeView = b.slots.register(
{ name: 'conversation.view', id: 'chat', label: 'Chat' },
(() => null) as never,
)
onTestFinished(removeView)
b.runtime.ctx.uiSession.bindingSource(b.rootReference)
const resolve = vi.spyOn(b.runtime.ctx.uiConversation, 'binding')
const errors = vi.spyOn(console, 'error')
onTestFinished(() => { resolve.mockRestore(); errors.mockRestore() })
await b.feature.dispose()
expect(b.runtime.sessions.binding(ROOT)).toBe(b.rootReference.binding)
resolve.mockClear()
b.runtime.ctx.locale.setLocale('en')
b.rootReference.release()
b.runtime.ctx.locale.setLocale('zh')
expect(resolve).not.toHaveBeenCalled()
expect(errors).not.toHaveBeenCalled()
})
it('projects the dynamic View registration ledger', async () => {
const b = await bench()
const source = b.viewSource(ROOT)
@@ -27,14 +27,6 @@ export class PresentedOpenController {
private readonly lifetime = new AbortController()
private readonly pending = new Set<Promise<void | PresentedOpenFailure>>()
/**
* @param ownerHostRoutes - whether the Owner's own HTTP host routes are reachable from this page.
* False on a page a business package re-labeled for another Peer: `present.host` and the open,
* reveal, and changes routes behind it belong to the Owner's origin, so leaving `host` null keeps
* the cards' unavailable fallback and stops the dependent owner-only reads.
*/
constructor(private readonly ownerHostRoutes = true) {}
/**
* Open a declared file once while a request for the same coordinates is pending.
* Failures remain visible on the card and a later gesture retries them.
@@ -85,7 +77,7 @@ export class PresentedOpenController {
* @returns after metadata or a retryable error is published.
*/
async loadHost(): Promise<void> {
if (!this.ownerHostRoutes || this.lifetime.signal.aborted) return
if (this.lifetime.signal.aborted) return
if (this.loading !== undefined) return this.loading
this.host.set(null)
const task = this.readHost(AbortSignal.any([this.lifetime.signal, this.metadata.signal]))
@@ -864,7 +864,6 @@ describe('plugin registration', () => {
session,
} as never)
ctx.provide('remote.session', session as never)
ctx.provide('remoteAccess', { owner: true } as never)
ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
@@ -131,19 +131,6 @@ it('coalesces metadata reads and suppresses their publication after disposal', a
})
it('issues no owner-only host read on a page a business package re-labeled for another Peer', async () => {
const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'owner-host', available: true, fileManager: 'finder' }))
vi.stubGlobal('fetch', fetcher)
const controller = new PresentedOpenController(false)
await controller.loadHost()
controller.resetHost()
await controller.loadHost()
// Unread metadata keeps the card's unavailable fallback, and the reads that hang off it stay off too.
expect(fetcher).not.toHaveBeenCalled()
expect(controller.host.getSnapshot()).toBeNull()
await controller.dispose()
})
it('invalidates cached desktop metadata without eagerly fetching an unused Host', async () => {
const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'old', available: false, fileManager: null }))
vi.stubGlobal('fetch', fetcher)
@@ -23,11 +23,7 @@ import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocum
const SELF = '@deepseek-ai/dsh-client-ui-settings-general'
const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
// The browser entry-customization entry needs `pluginNavigation`, which this settings-only roster does
// not provide; these tests cover the settings shell gate, so leave that entry out of the boot.
const it = createClientTest({
roster: webApp.without(['@deepseek-ai/dsh-client-ui-entry-customization']),
})
const it = createClientTest({ roster: webApp })
/** The whole roster's first boot pays the cold module transform of every plugin package. */
const COLD_BOOT_TIMEOUT_MS = 60_000
/** Dictionary namespace this plugin owns; every seat it fills declares it. */
+2 -2
View File
@@ -18,8 +18,8 @@
en: a970f0547496634f
zh: 36ef9a85b26e19f1
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/brand-and-new-session:
en: f2c2254325cea5db
zh: c9ef643b431f8328
en: 44db2d448bc2ea6d
zh: cbb1143afb524d5b
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/global-panel-entries:
en: b109498d88367117
zh: 6d407f265bacdb0d
+1 -1
View File
@@ -31,7 +31,7 @@ The sidebar is the navigation shell: users see the brand, start new sessions, co
### Brand and New Session
The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots; the collapsed rail renders the same mark slot. On Web and Windows desktop, clicking the expanded brand starts a new session; hovering or focusing it shows no tooltip. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks a code badge below the label as `version[-commit][-dirty]`, using `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. Clicking the fallback mark or label of such a build copies that badge text instead of starting a session, also on macOS desktop, and a successful write shows a Version copied toast. New Session targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. The expanded New Session button displays the effective binding as trailing grey text on keyboard focus regardless of hover capability, and on hover when the device supports it. While the shortcut is visible, the centered icon and label fade before it when space is narrow; CSS reserves the shortcut’s width without measuring the button. Icon-only controls retain tooltips with platform-formatted keycaps, including the resident macOS header control when the sidebar is hidden. All controls expose `aria-keyshortcuts`.
The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots; the collapsed rail renders the same mark slot. On Web and Windows desktop, clicking the expanded brand starts a new session; hovering or focusing it shows no tooltip. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks a code badge below the label as `version[-commit][-dirty]`, using `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. Clicking the fallback mark or label of such a build copies that badge text instead of starting a session, also on macOS desktop, and a successful write shows a Version copied toast. New Session targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. The expanded New Session button displays the effective binding as trailing grey text on keyboard focus regardless of hover capability, and on hover when the device supports it. While the shortcut is visible, the centered icon and label fade before it when space is narrow; CSS reserves the shortcut’s width without measuring the button. Icon-only controls retain tooltips with platform-formatted keycaps, including the resident macOS header control when the sidebar is hidden. All controls expose `aria-keyshortcuts`. While the global access snapshot excludes `session/create`, the brand row stays inert and neither the New Session button nor the window-chrome New Session control renders; the Host still decides every create request.
### Global panel entries
+1 -1
View File
@@ -31,7 +31,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
### 品牌与 New Session
展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot;收起轨道则渲染同一个 mark slot。在 Web 和 Windows 桌面端,点击展开态的品牌会新建会话,悬停或聚焦时不显示 tooltip。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标使用 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。此类构建中点击回退的标记或标签会复制该徽标文本而非新建会话,macOS 桌面端同样适用;写入成功后弹出“版本号复制成功”toast。New Session 优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。 展开态的新建会话按钮在键盘聚焦时于右侧以灰色文字显示当前有效绑定,不受设备悬停能力限制;支持悬停的设备也会在悬停时显示。快捷键可见且空间较窄时,居中的图标与文字在快捷键之前渐隐;CSS 为快捷键保留宽度,无需测量按钮。纯图标控件保留使用平台键位样式的 tooltip,侧栏隐藏时常驻的 macOS 头部控件也保持一致。所有控件均提供 `aria-keyshortcuts`。
展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot;收起轨道则渲染同一个 mark slot。在 Web 和 Windows 桌面端,点击展开态的品牌会新建会话,悬停或聚焦时不显示 tooltip。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标使用 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。此类构建中点击回退的标记或标签会复制该徽标文本而非新建会话,macOS 桌面端同样适用;写入成功后弹出“版本号复制成功”toast。New Session 优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。 展开态的新建会话按钮在键盘聚焦时于右侧以灰色文字显示当前有效绑定,不受设备悬停能力限制;支持悬停的设备也会在悬停时显示。快捷键可见且空间较窄时,居中的图标与文字在快捷键之前渐隐;CSS 为快捷键保留宽度,无需测量按钮。纯图标控件保留使用平台键位样式的 tooltip,侧栏隐藏时常驻的 macOS 头部控件也保持一致。所有控件均提供 `aria-keyshortcuts`。全局访问快照不含 `session/create` 时,品牌行不可点击,New Session 按钮和窗口标题栏中的 New Session 控件都不渲染;每个新建请求仍由 Host 判定。
### 全局面板入口
@@ -20,10 +20,14 @@ export type HeaderLeadingControlsProps =
* controls off screen; this occupant puts them back beside the traffic
* lights. The frame mounts the seat only in that state and owns its
* placement, so the occupant renders unconditionally.
* @param props - Injected sidebar actions plus the sidebar locale seat.
* @returns the two window-chrome controls.
* @param props - Injected sidebar actions, the global access Hook, and the sidebar locale seat.
* @returns the sidebar toggle, plus the New Session control while the access snapshot allows `session/create`.
*/
export function HeaderLeadingControls({ toggleSidebar, startSession, trackEntryClick, useShortcuts, t }: HeaderLeadingControlsProps) {
export function HeaderLeadingControls({
toggleSidebar, startSession, trackEntryClick, useRemoteAccess, useShortcuts, t,
}: HeaderLeadingControlsProps) {
// A page whose access snapshot excludes starting a Session keeps only the sidebar toggle here.
const canStartSession = useRemoteAccess('session/create')
const shortcut = useShortcuts(rows => rows.find(row => row.id === 'sidebar.left.toggle'))
const newShortcut = useShortcuts(rows => rows.find(row => row.id === 'session.new'))
return (
@@ -39,20 +43,22 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, trackEntryC
<IconPanelLeftOutlineRegular size={16} />
</button>
</Tooltip>
<Tooltip label={t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500}>
<button
type="button"
className={css.iconButton}
aria-label={t('session.new.label')}
aria-keyshortcuts={newShortcut?.aria}
onClick={() => {
trackEntryClick({ menu_name: 'new_session', position: 'titlebar' })
startSession()
}}
>
<IconNewChatOutlineRegular size={16} />
</button>
</Tooltip>
{canStartSession && (
<Tooltip label={t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500}>
<button
type="button"
className={css.iconButton}
aria-label={t('session.new.label')}
aria-keyshortcuts={newShortcut?.aria}
onClick={() => {
trackEntryClick({ menu_name: 'new_session', position: 'titlebar' })
startSession()
}}
>
<IconNewChatOutlineRegular size={16} />
</button>
</Tooltip>
)}
</div>
)
}
@@ -135,6 +135,7 @@ export function SidebarRoot({
selectPanel,
setPinned,
trackEntryClick,
useRemoteAccess,
usePanels,
useEntryPins,
useEntryActions,
@@ -149,6 +150,8 @@ export function SidebarRoot({
const hasEntryActions = useEntryActions(registered => registered)
const shortcut = useShortcuts(rows => rows.find(row => row.id === 'sidebar.left.toggle'))
const newShortcut = useShortcuts(rows => rows.find(row => row.id === 'session.new'))
// A page whose access snapshot excludes starting a Session offers no New Session affordance.
const canStartSession = useRemoteAccess('session/create')
const toggleLabel = collapsed ? t('toggle.open') : t('toggle.collapse')
// Wide content stays mounted while the collapse animates (fading via
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
@@ -278,7 +281,8 @@ export function SidebarRoot({
traffic lights and keeps the toggle at the sidebar's top-right. */}
{darwinDesktop && <div className={css.topStrip} data-window-drag>{toggle}</div>}
<div className={css.logoRow} data-window-drag>
{/* Expanded, the brand doubles as a New Session shortcut — except on
{/* Expanded, the brand doubles as a New Session shortcut while the
access snapshot allows `session/create` — except on
macOS, where it stays part of the logo row's window-drag surface
(a button would subtract itself through the global no-drag rule);
the collapsed rail's logo is the expand toggle below instead.
@@ -307,7 +311,7 @@ export function SidebarRoot({
</span>
</span>
)
return darwinDesktop
return darwinDesktop || !canStartSession
? <span className={clsx(css.brand, css.wide)}>{identity}</span>
: (
<button
@@ -328,25 +332,27 @@ export function SidebarRoot({
</div>
{/* The label fades before the hover/focus shortcut, including on translucent backgrounds. */}
<Tooltip label={t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500} side={captionTooltipSide} disabled={wide}>
<button
type="button"
className={css.newSession}
aria-label={t('session.new.label')}
aria-keyshortcuts={newShortcut?.aria}
onClick={openNewSession}
>
<span className={css.newSessionLabelMask}><span className={css.newSessionContent}>
{wide
? <IconNewChatOutlineMedium size={14} />
: <IconNewChatOutlineRegular size={windowsTitlebar ? 16 : 18} />}
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
</span></span>
{wide && newShortcut !== undefined && newShortcut.keys.length > 0 && <span className={css.newSessionShortcut} aria-hidden="true">
<ShortcutKeys keys={newShortcut.keys} />
</span>}
</button>
</Tooltip>
{canStartSession && (
<Tooltip label={t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500} side={captionTooltipSide} disabled={wide}>
<button
type="button"
className={css.newSession}
aria-label={t('session.new.label')}
aria-keyshortcuts={newShortcut?.aria}
onClick={openNewSession}
>
<span className={css.newSessionLabelMask}><span className={css.newSessionContent}>
{wide
? <IconNewChatOutlineMedium size={14} />
: <IconNewChatOutlineRegular size={windowsTitlebar ? 16 : 18} />}
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
</span></span>
{wide && newShortcut !== undefined && newShortcut.keys.length > 0 && <span className={css.newSessionShortcut} aria-hidden="true">
<ShortcutKeys keys={newShortcut.keys} />
</span>}
</button>
</Tooltip>
)}
{panels.length > 0 && (
<nav className={css.panelList} aria-label={t('panels.label')}>
@@ -157,6 +157,37 @@ describe('SidebarRoot shell', () => {
expect(b.trackEntryClick).toHaveBeenCalledExactlyOnceWith({ menu_name: 'new_session', position: 'sidebar' })
})
it('offers no New Session control while the access snapshot excludes session/create', () => {
const toggleSidebar = vi.fn()
const startSession = vi.fn()
const useRemoteAccess = vi.fn((action: string) => action !== 'session/create')
render(<SidebarRoot
collapsed={false} width={300}
useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useEntryPins={selector => selector([])}
useEntryActions={selector => selector(false)} setPinned={vi.fn()}
useShortcuts={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
{...allowAllAccess}
useRemoteAccess={useRemoteAccess}
startSession={startSession} toggleSidebar={toggleSidebar} trackEntryClick={vi.fn()} t={t}
renderSlot={((key: string) => {
if (key === 'sidebar.brand.mark') return <span data-testid="brand-mark">M</span>
if (key === 'sidebar.brand.name') return <span data-testid="brand-name">Brand</span>
return null
}) as SidebarRootComponentProps['renderSlot']}
/>)
// Neither the brand row nor the dedicated button starts a Session; the brand still renders.
expect(useRemoteAccess).toHaveBeenCalledWith('session/create')
expect(screen.queryByRole('button', { name: 'New session' })).toBeNull()
expect(screen.getByTestId('brand-mark')).toBeTruthy()
expect(screen.getByTestId('brand-name')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(toggleSidebar).toHaveBeenCalledOnce()
expect(startSession).not.toHaveBeenCalled()
})
it('renders generic brand fallbacks when no package fills the slots', () => {
vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456')
vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true')
@@ -359,8 +390,8 @@ it('wires the shell.leading controls to the shared sidebar actions', () => {
const toggleSidebar = vi.fn()
const startSession = vi.fn()
const trackEntryClick = vi.fn<HeaderLeadingControlsProps['trackEntryClick']>()
// This occupant only consumes its actions and locale, not Session hooks.
const props = { toggleSidebar, startSession, trackEntryClick, t, useShortcuts: select => select([{ id: 'sidebar.left.toggle' as ShortcutCommandId, label: 'Toggle sidebar', aliases: [], binding: null, modified: false, conflicts: [], issue: null, keys: ['⌘', 'B'], aria: 'Meta+B' }]) } as HeaderLeadingControlsProps
// This occupant only consumes its actions, the global access Hook, and locale, not Session hooks.
const props = { toggleSidebar, startSession, trackEntryClick, t, useRemoteAccess: allowAllAccess.useRemoteAccess, useShortcuts: select => select([{ id: 'sidebar.left.toggle' as ShortcutCommandId, label: 'Toggle sidebar', aliases: [], binding: null, modified: false, conflicts: [], issue: null, keys: ['⌘', 'B'], aria: 'Meta+B' }]) } as HeaderLeadingControlsProps
render(<HeaderLeadingControls {...props} />)
fireEvent.click(screen.getByRole('button', { name: en['toggle.open'] }))
expect(toggleSidebar).toHaveBeenCalledOnce()
@@ -370,6 +401,12 @@ it('wires the shell.leading controls to the shared sidebar actions', () => {
// The window-chrome New Session control reports a titlebar click before starting the Session.
expect(trackEntryClick).toHaveBeenCalledExactlyOnceWith({ menu_name: 'new_session', position: 'titlebar' })
expect(trackEntryClick.mock.invocationCallOrder[0]).toBeLessThan(startSession.mock.invocationCallOrder[0]!)
cleanup()
// A page whose access snapshot excludes starting a Session keeps only the sidebar toggle.
render(<HeaderLeadingControls {...props} useRemoteAccess={action => action !== 'session/create'} />)
expect(screen.getByRole('button', { name: en['toggle.open'] })).toBeTruthy()
expect(screen.queryByRole('button', { name: en['session.new.label'] })).toBeNull()
})
describe('Windows caption New Session analytics', () => {
@@ -27,8 +27,8 @@
en: 29facc4eae028dae
zh: 754ebac4cc294e65
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
en: 3b03cc192e42c196
zh: c0379c5e067667cf
en: 9e36b54394c1d8b6
zh: 34d464a7eddfc58d
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
@@ -17,7 +17,6 @@ export const zh = {
'shortcut.directoryBusy': '正在选择或添加工作区',
'shortcut.noCompletedTurn': '当前会话没有已结束的轮次',
'shortcut.forkFailed': '无法分叉会话,请重试',
'shortcut.notPermitted': '当前页面不允许此操作',
'section.workspaces': '工作区',
'section.sessions': '会话',
'viewOptions.label': '视图选项',
@@ -144,7 +143,6 @@ export const en = {
'shortcut.directoryBusy': 'Selecting or adding a workspace',
'shortcut.noCompletedTurn': 'This session has no completed turn',
'shortcut.forkFailed': 'Could not fork the session. Try again.',
'shortcut.notPermitted': 'This page may not perform that action',
'section.workspaces': 'Workspaces',
'section.sessions': 'Sessions',
'viewOptions.label': 'View options',
@@ -268,7 +266,6 @@ export const ja = {
'shortcut.directoryBusy': 'ワークスペースを選択または追加しています',
'shortcut.noCompletedTurn': 'このセッションには完了したターンがありません',
'shortcut.forkFailed': 'セッションをフォークできませんでした。もう一度お試しください。',
'shortcut.notPermitted': 'このページではこの操作を実行できません',
'section.workspaces': 'ワークスペース',
'section.sessions': 'セッション',
'viewOptions.label': '表示オプション',
@@ -377,5 +374,4 @@ export const ja = {
'time.months': '{n}か月',
'time.years': '{n}年',
'time.ago': '{t}前',
} satisfies Record<keyof typeof en, string>
@@ -49,8 +49,6 @@ export function createWorkspaceShortcutControls(): WorkspaceShortcutControls {
/**
* Register navigation commands against the existing workspace owner.
* Rename requires a nonblank main Conversation with no modal obscuring it.
* Rename, fork, and archive stay bound while the current Session's access snapshot excludes their
* endpoint and report refusal.
* @param ctx - plugin context with the shortcut, locale, and model services.
* @param navigation - session creation and forking from the pointer controls' navigation service.
* @param controls - browser-owned opening requests.
@@ -65,8 +63,6 @@ export function installWorkspaceShortcuts(
reportForkFailure: (reason: 'unavailable' | 'failed') => void,
): void {
const t = ctx.locale.bind('workspace')
const allows = (sessionId: SessionId, endpoint: string): boolean =>
ctx.remoteAccess.source('session', sessionId, endpoint).getSnapshot()
const current = () => Object.values(ctx.sessions.list.getSnapshot().byId)
.find(row => (row.retainedBy.mainView ?? 0) > 0)
const addReason = () => ctx.slots.entries('sidebar.workspaces.directoryFlow').length === 0
@@ -93,9 +89,6 @@ export function installWorkspaceShortcuts(
})
register('session.rename', () => t('rename.session.title'), ['rename session'], 'KeyG', ['primary', 'alt'], ['primary', 'alt'], (context) => {
const target = current()
if (target !== undefined && !allows(target.id, 'session/rename')) {
return { status: 'blocked', reason: t('shortcut.notPermitted') }
}
return target === undefined || target.blank || context.modal !== null || ctx.layout.panelInfo.getSnapshot().activePanelId !== null
? { status: 'blocked', reason: t('shortcut.noSession') }
: { status: 'handled', run: () => { controls.rename(target.id, target.title?.trim() ?? '') } }
@@ -103,7 +96,6 @@ export function installWorkspaceShortcuts(
register('session.fork', () => t('menu.fork'), ['fork session'], 'KeyF', ['primary', 'alt'], ['primary', 'shift'], () => {
const target = current()
if (target === undefined) return { status: 'blocked', reason: t('shortcut.noSession') }
if (!allows(target.id, 'session/fork')) return { status: 'blocked', reason: t('shortcut.notPermitted') }
if (target.blank) return { status: 'blocked', reason: t('shortcut.noCompletedTurn') }
return { status: 'handled', run: () => {
// The main view owns history opening for this target.
@@ -118,10 +110,9 @@ export function installWorkspaceShortcuts(
})
register('session.archive', () => t('menu.archiveSession'), ['archive session'], 'KeyA', ['primary', 'shift'], ['primary', 'alt'], () => {
const target = current()
if (target === undefined) return { status: 'blocked', reason: t('shortcut.noSession') }
if (!allows(target.id, 'workspace/archiveSession')) return { status: 'blocked', reason: t('shortcut.notPermitted') }
return { status: 'handled', run: () => {
archiveSession(target.id)
} }
return target === undefined ? { status: 'blocked', reason: t('shortcut.noSession') }
: { status: 'handled', run: () => {
archiveSession(target.id)
} }
})
}
@@ -1,68 +0,0 @@
// @vitest-environment jsdom
import { createElement, useState } from 'react'
import { fireEvent, within } from '@testing-library/react'
import { describe, expect, it, onTestFinished } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { menuOpenStateFactory, type MenuOpenState } from '../src/client/contract/slots.ts'
const menuSlot = 'sidebar.workspaces.session.menu.item'
describe('Session row menu open state', () => {
it('hands each entry its owner’s exact state pair and closes only that menu', async () => {
const runtime = await SlotTestRuntime.create()
onTestFinished(() => runtime.dispose())
const ownerStates = new Map<string, MenuOpenState>()
const entryStates = new Map<string, MenuOpenState>()
function MenuOwner({ renderSlot }: PropsRenderSlots<typeof menuSlot>) {
const first = useState(true)
const second = useState(true)
ownerStates.set('first', first)
ownerStates.set('second', second)
return createElement('div', null, ...([['first', first], ['second', second]] as const).map(([id, state]) =>
createElement('section', { key: id, 'aria-label': id },
createElement('button', { onClick: () => { state[1](true) } }, 'Open'),
state[0] ? renderSlot(menuSlot, { sessionId: id as SessionId, displayTitle: id }, { hookContext: state }) : null,
),
))
}
await runtime.root.declare({
[menuSlot]: {
kind: 'list', scope: 'root',
inject: { hooks: { menuOpenState: menuOpenStateFactory, shortcuts: createSnapshotStore<readonly ShortcutCatalogEntry[]>([]) } },
},
}, MenuOwner)
await runtime.mount({
inject: ['slots'],
apply(ctx) {
return ctx.slots.register({ name: menuSlot, id: 'test.close' },
({ displayTitle, useMenuOpenState }: PropsRuntime<typeof menuSlot>) => {
const state = useMenuOpenState()
entryStates.set(displayTitle, state)
return createElement('button', { role: 'menuitem', onClick: () => { state[1](false) } }, 'Close')
})
},
})
const view = runtime.renderRoot()
const first = within(view.getByRole('region', { name: 'first' }))
const second = within(view.getByRole('region', { name: 'second' }))
expect(entryStates.get('first')).toBe(ownerStates.get('first'))
expect(entryStates.get('second')).toBe(ownerStates.get('second'))
expect(entryStates.get('first')).not.toBe(entryStates.get('second'))
fireEvent.click(first.getByRole('menuitem', { name: 'Close' }))
expect(first.queryByRole('menuitem')).toBeNull()
expect(second.getByRole('menuitem', { name: 'Close' })).toBeTruthy()
fireEvent.click(first.getByRole('button', { name: 'Open' }))
expect(first.getByRole('menuitem', { name: 'Close' })).toBeTruthy()
expect(entryStates.get('first')).toBe(ownerStates.get('first'))
fireEvent.click(second.getByRole('menuitem', { name: 'Close' }))
expect(second.queryByRole('menuitem')).toBeNull()
expect(first.getByRole('menuitem', { name: 'Close' })).toBeTruthy()
})
})
@@ -42,11 +42,6 @@ async function bench(runtime: 'web' | 'desktop' = 'desktop', platform: ShortcutP
sessionId: sid(id), session: { ...history, loadOlder },
}]))
ctx.provide('sessions', { list, binding: (id: SessionId) => bindings.get(id) })
// Endpoints the current Session's access snapshot excludes; empty for the owner.
const denied = new Set<string>()
ctx.provide('remoteAccess', {
source: (_kind: string, _id: string, endpoint: string) => createSnapshotStore(!denied.has(endpoint)),
})
const panelInfo = createSnapshotStore<PanelInfo>({ activePanelId: null })
ctx.provide('layout', { panelInfo })
const directory = createSnapshotStore(true)
@@ -68,45 +63,12 @@ async function bench(runtime: 'web' | 'desktop' = 'desktop', platform: ShortcutP
const select = (id: string) => { list.set({ ...list.getSnapshot(), byId: {
[sid('a')]: row('a', id === 'a'), [sid('b')]: row('b', id === 'b'),
} }) }
return {
ctx, fiber, registry, commands, navigation, controls, reportForkFailure, list, directory, select, history, loadOlder,
panelInfo, denied,
}
return { ctx, fiber, registry, commands, navigation, controls, reportForkFailure, list, directory, select, history, loadOlder, panelInfo }
}
afterEach(() => { vi.restoreAllMocks() })
describe('workspace shortcut ownership', () => {
it('refuses the Session write commands the current Session\'s access snapshot excludes', async () => {
const b = await bench()
for (const endpoint of ['session/rename', 'session/fork', 'workspace/archiveSession']) b.denied.add(endpoint)
// The pointer controls are absent on such a page; these commands stay bound, so each one answers
// with the page's own refusal instead of running a write the Host would refuse.
for (const id of ['session.rename', 'session.fork', 'session.archive']) {
expect(b.commands.get(id)!.resolve(context)).toMatchObject({ status: 'blocked', reason: en['shortcut.notPermitted'] })
}
// Reading is not a write: search stays available on the same page.
expect(b.commands.get('session.search')!.resolve(context)).toMatchObject({ status: 'handled' })
})
it('rechecks session/fork before each fork and retains shared failure reporting', async () => {
const b = await bench('web')
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
b.navigation.forkSession.mockRejectedValueOnce(new SessionForkError(
new RemoteError('session/fork-unavailable', 'no completed turn', { sessionId: sid('a') }), sid('a'),
))
const invoke = () => b.registry.dispatch(key('KeyF', { shift: true }), context, vi.fn())
expect(invoke().status).toBe('handled')
await vi.waitFor(() => { expect(b.reportForkFailure).toHaveBeenCalledExactlyOnceWith('unavailable') })
b.denied.add('session/fork')
expect(invoke()).toMatchObject({ status: 'blocked', reason: en['shortcut.notPermitted'] })
expect(b.navigation.forkSession).toHaveBeenCalledOnce()
b.denied.delete('session/fork')
expect(invoke().status).toBe('handled')
expect(b.navigation.forkSession).toHaveBeenCalledTimes(2)
expect(warning).not.toHaveBeenCalled()
})
it('refuses rename and archive without a selected Session and keeps a busy picker closed', async () => {
const b = await bench()
b.select('none')