mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(client): gate every full access picker
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user