fix(client): gate every full access picker

This commit is contained in:
ZiyaZhang
2026-07-30 05:48:21 -07:00
parent 2ed4d982e7
commit 6d416b06c5
36 changed files with 624 additions and 195 deletions

View File

@@ -12,7 +12,7 @@
import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
import css from './PopupSelectView.module.css'
@@ -56,23 +56,24 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
// closes the shell before its own handlers run; that click's target then
// takes focus naturally, so no focusComposer here.
useEffect(() => {
if (!state.open) return
if (!state.open || state.confirming !== null) return
const onPointerDown = (ev: PointerEvent): void => {
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
popup.dismiss()
}
document.addEventListener('pointerdown', onPointerDown, true)
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
}, [state.open, popup])
}, [state.open, state.confirming, popup])
// Focus the search input after it mounts (separate effect so the ref is populated).
useEffect(() => {
if (state.open) searchRef.current?.focus()
}, [state.open])
if (state.open && state.confirming === null) searchRef.current?.focus()
}, [state.open, state.confirming])
if (!state.open) return null
const rows = filterOptions(state.options, state.search)
const confirmation = state.confirming?.confirmation
const onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
@@ -99,55 +100,73 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
}
return (
<div
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={`/${String(state.command)} options`}
onKeyDown={onKeyDown}
>
<input
ref={searchRef}
className={css.search}
type="text"
placeholder="Search…"
aria-label="Filter options"
value={state.search}
readOnly={state.submitting}
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
/>
{state.error !== null && (
<div className={css.error} role="alert">
<span className={css.errorText}>{state.error}</span>
{state.status === 'failed' && (
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>Retry</button>
<>
{state.confirming === null && (
<div
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={`/${String(state.command)} options`}
onKeyDown={onKeyDown}
>
<input
ref={searchRef}
className={css.search}
type="text"
placeholder="Search…"
aria-label="Filter options"
value={state.search}
readOnly={state.submitting}
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
/>
{state.error !== null && (
<div className={css.error} role="alert">
<span className={css.errorText}>{state.error}</span>
{state.status === 'failed' && (
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>Retry</button>
)}
</div>
)}
{state.status === 'pending' && <div className={css.status}>Loading options</div>}
{state.submitting && <div className={css.status}>Applying</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}
role="option"
aria-selected={index === state.active}
className={clsx(css.row, index === state.active && css.rowActive)}
// mousedown would race the document capture listener; the shell
// owns focus anyway, so a plain click (inside the card → no
// dismiss) works.
onClick={() => { void popup.select(index) }}
onMouseEnter={() => { popup.highlight(index) }}
>
<span className={css.label}>{option.label}</span>
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
</div>
))}
</div>
)}
</div>
)}
{state.status === 'pending' && <div className={css.status}>Loading options</div>}
{state.submitting && <div className={css.status}>Applying</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}
role="option"
aria-selected={index === state.active}
className={clsx(css.row, index === state.active && css.rowActive)}
// mousedown would race the document capture listener; the shell
// owns focus anyway, so a plain click (inside the card → no
// dismiss) works.
onClick={() => { void popup.select(index) }}
onMouseEnter={() => { popup.highlight(index) }}
>
<span className={css.label}>{option.label}</span>
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
</div>
))}
</div>
{confirmation !== undefined && (
<RiskConfirmation
open
title={confirmation.title}
description={confirmation.description}
acknowledgeLabel={confirmation.acknowledgeLabel}
cancelLabel={confirmation.cancelLabel}
confirmLabel={confirmation.confirmLabel}
acknowledged={state.acknowledged}
onAcknowledgedChange={(value) => { popup.acknowledge(value) }}
onCancel={() => { popup.cancelConfirmation() }}
onConfirm={() => { void popup.confirm() }}
/>
)}
</div>
</>
)
}

View File

@@ -6,12 +6,23 @@
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
/** Copy for an option that must be acknowledged before onSelect can run. */
export interface SelectConfirmation {
readonly title: string
readonly description: string
readonly acknowledgeLabel: string
readonly cancelLabel: string
readonly confirmLabel: string
}
/** One option row of a popupSelect shell. */
export interface SelectOption {
readonly id: string
readonly label: string
readonly detail?: string
readonly active?: boolean
/** Optional in-page risk gate owned by the shared popup shell. */
readonly confirmation?: SelectConfirmation
}
/**

View File

@@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
export type { PopupSelectInjected } from './PopupSelectView.tsx'
export type {
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption,
} from './contract.ts'
declare module 'cordis' {

View File

@@ -67,12 +67,17 @@ export interface PopupState {
readonly active: number
/** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
readonly submitting: boolean
/** Option waiting for explicit risk acknowledgement; null during normal selection. */
readonly confirming: SelectOption | null
/** Caller-controlled checkbox state for the pending confirmation. */
readonly acknowledged: boolean
/** Surfaced settlement failure (options load or onSelect); null when none. */
readonly error: string | null
}
const CLOSED: PopupState = {
open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null,
open: false, command: null, status: 'pending', options: [], search: '', active: 0,
submitting: false, confirming: null, acknowledged: false, error: null,
}
/**
@@ -166,7 +171,7 @@ export class PopupSelectController<TCtx = unknown> {
*/
setSearch(search: string): void {
const s = this.state.getSnapshot()
if (!s.open || s.submitting || search === s.search) return
if (!s.open || s.submitting || s.confirming !== null || search === s.search) return
this.state.set({ ...s, search, active: 0 })
}
@@ -177,7 +182,7 @@ export class PopupSelectController<TCtx = unknown> {
*/
move(dir: 1 | -1): void {
const s = this.state.getSnapshot()
if (!s.open || s.status !== 'ready' || s.submitting) return
if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
const rows = filterOptions(s.options, s.search)
if (rows.length === 0) return
const active = (s.active + dir + rows.length) % rows.length
@@ -191,7 +196,7 @@ export class PopupSelectController<TCtx = unknown> {
*/
highlight(index: number): void {
const s = this.state.getSnapshot()
if (!s.open || s.status !== 'ready' || s.submitting) return
if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return
this.state.set({ ...s, active: index })
}
@@ -209,10 +214,46 @@ export class PopupSelectController<TCtx = unknown> {
async select(index: number): Promise<void> {
const binding = this.binding
const s = this.state.getSnapshot()
if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return
if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
const option = filterOptions(s.options, s.search)[index]
if (option === undefined) return
this.state.set({ ...s, submitting: true, error: null })
if (option.confirmation !== undefined) {
this.state.set({ ...s, confirming: option, acknowledged: false, error: null })
return
}
await this.settle(binding, option)
}
/**
* Update the explicit checkbox for the currently pending risk gate.
* @param acknowledged - whether the user has acknowledged the displayed risk.
*/
acknowledge(acknowledged: boolean): void {
const s = this.state.getSnapshot()
if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return
this.state.set({ ...s, acknowledged })
}
/** Cancel only the risk gate and return to the still-open option picker. */
cancelConfirmation(): void {
const s = this.state.getSnapshot()
if (!s.open || s.submitting || s.confirming === null) return
this.state.set({ ...s, confirming: null, acknowledged: false })
}
/** Settle the gated option only after the checkbox is acknowledged. */
async confirm(): Promise<void> {
const binding = this.binding
const s = this.state.getSnapshot()
if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return
await this.settle(binding, s.confirming)
}
/** Run the business settlement for an already admitted option. */
private async settle(binding: OpenBinding<TCtx>, option: SelectOption): Promise<void> {
const s = this.state.getSnapshot()
if (this.binding !== binding || !s.open || s.submitting) return
this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null })
try {
await binding.spec.onSelect(option, binding.context)
} catch (error) {

View File

@@ -32,6 +32,17 @@ const OPTIONS: SelectOption[] = [
{ id: 'light', label: 'Light', active: true },
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
]
const GATED: SelectOption = {
id: 'full',
label: 'Full access',
confirmation: {
title: 'Enable Full access?',
description: 'Sensitive operations.',
acknowledgeLabel: 'I understand the risks',
cancelLabel: 'Cancel',
confirmLabel: 'Enable Full access',
},
}
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
@@ -143,6 +154,37 @@ describe('PopupSelectView', () => {
expect(view.container.childElementCount).toBe(0)
})
it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => {
const onSelect = vi.fn()
const { popup, consume } = await mountOpen({
options: () => Promise.resolve([GATED]),
onSelect,
})
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
expect(screen.queryByLabelText('/theme options')).toBeNull()
expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy()
const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement
expect(enable.disabled).toBe(true)
expect(onSelect).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' }))
expect(enable.disabled).toBe(false)
await act(async () => { fireEvent.click(enable) })
expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A')
expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
expect(popup.state.getSnapshot().open).toBe(false)
})
it('canceling a gated option returns to the picker with acknowledgement reset', async () => {
await mountOpen({ options: () => Promise.resolve([GATED]) })
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByLabelText('/theme options')).toBeTruthy()
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
expect(screen.getByRole<HTMLInputElement>('checkbox').checked).toBe(false)
})
it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => {
let release!: () => void
const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))

View File

@@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [
{ id: 'light', label: 'Light', active: true },
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
]
const GATED: SelectOption = {
id: 'full',
label: 'Full access',
confirmation: {
title: 'Enable Full access?',
description: 'Sensitive operations.',
acknowledgeLabel: 'I understand',
cancelLabel: 'Cancel',
confirmLabel: 'Enable Full access',
},
}
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
@@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => {
})
describe('select', () => {
it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => {
const onSelect = vi.fn()
const deps = makeDeps()
const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
await popup.select(0)
expect(popup.state.getSnapshot()).toMatchObject({
open: true, confirming: GATED, acknowledged: false, submitting: false,
})
expect(onSelect).not.toHaveBeenCalled()
await popup.confirm()
expect(onSelect).not.toHaveBeenCalled()
popup.acknowledge(true)
await popup.confirm()
expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A)
expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
expect(popup.state.getSnapshot().open).toBe(false)
})
it('cancels a confirmation back to the picker without selecting or consuming', async () => {
const onSelect = vi.fn()
const deps = makeDeps()
const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
await popup.select(0)
popup.acknowledge(true)
popup.cancelConfirmation()
expect(popup.state.getSnapshot()).toMatchObject({
open: true, confirming: null, acknowledged: false, submitting: false,
})
expect(onSelect).not.toHaveBeenCalled()
expect(deps.consume).not.toHaveBeenCalled()
})
it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => {
const seen: Array<{ option: SelectOption; context: Ctx }> = []
const deps = makeDeps()