chore(lint): clear the semantic .tsx backlog

Hand fixes for the findings --fix cannot touch, mirroring the fixes
already applied on the fe-docs feature branch (same file, same shape)
so its eventual rebase resolves cleanly:

- restore the return the no-confusing-void-expression autofix ate in
  useAbsentSnapshot (typed S | undefined; hook call kept for hook-order
  stability, undefined returned explicitly);
- re-type DOM queries the no-unnecessary-type-assertion autofix broke:
  getByRole<HTMLButtonElement>(...) generics instead of the removed
  as-casts (the eslint program and the client tsconfig aggregate
  disagree about these casts; the generic form satisfies both);
- justified eslint-disable for the deliberate legacy paths: keyCode 229
  IME-composition detection, execCommand clipboard fallbacks, lib.dom
  clipboard optionality, and the any-typed Reflect.get/this probes in
  test fakes;
- drop the dead react/no-danger directive (eslint-plugin-react is not
  loaded, so the rule never applied) keeping its shiki rationale;
- delete the tautological 'Z' comparison and the renameTarget null
  check already implied by renameBlocked;
- css-module non-null assertions replaced by type widening
  (Button className, TAG_CLASS Record) per the established pattern;
- misc: max-len comment wraps, void generic drop in the deferred test
  helper, unused type imports, floating selectWorkspace promises voided,
  member-delimiter newlines in inline type literals.
This commit is contained in:
imccyu
2026-07-27 22:23:41 +08:00
parent 2adf44fb80
commit cdd4d59ea0
31 changed files with 134 additions and 68 deletions
@@ -86,7 +86,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
seq: number
onOpenDetails: OpenDetails
selected: boolean
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */
/** `run_code` sub-dispatches in dispatch order (reference-stable per
* parent; running entries settle in place); undefined for ordinary calls. */
subCalls?: readonly CodeSubCall[] | undefined
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
selectedCallId?: string | undefined
@@ -162,7 +163,10 @@ function StreamingTail({ useSession, onGrow }: {
return <AssistantMarkdown blocks={partial.blocks} streaming />
}
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
/**
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const runningCalls = useSession(s => s.runningCalls)
@@ -32,6 +32,9 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
@@ -40,6 +43,9 @@ async function writeClipboard(text: string): Promise<void> {
}
return
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
@@ -56,6 +62,7 @@ async function writeClipboard(text: string): Promise<void> {
} catch {
// Clipboard unavailable; the button stays idle.
}
/* eslint-enable @typescript-eslint/no-deprecated */
el.remove()
}
@@ -41,8 +41,8 @@ export function ConversationSession({
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
const unmirror = bindDraftMirror(actions.setDraft)
return () => { unmirror() }
// Mount-only: later store writes come from the machine mirror.
// eslint-disable-next-line react-hooks/exhaustive-deps
// Mount-only (deps pinned to inputActions): later store writes come from
// the machine mirror, not this seed effect.
}, [inputActions])
if (blank && composerPhase === 'blank') return null
@@ -31,7 +31,11 @@ export function InputBar({
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.getSnapshot)
const noticeStore = keyboard.notices
const notice = useSyncExternalStore(
(fn: () => void) => noticeStore.subscribe(fn),
() => noticeStore.getSnapshot(),
)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
@@ -75,6 +79,8 @@ export function InputBar({
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
@@ -92,7 +98,7 @@ export function InputBar({
// the browser stack cannot represent); never let the native stack run.
e.preventDefault()
if (machineBusy || locked) return
const redo = e.key === 'y' || (e.shiftKey && (e.key === 'z' || e.key === 'Z'))
const redo = e.key === 'y' || e.shiftKey
if (redo) keyboard.redo()
else keyboard.undo()
return
@@ -134,6 +140,8 @@ export function InputBar({
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
keyboard.setDraft(next)
// selectionStart is number|null in lib.dom; the eslint program narrows it.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
keyboard.track(next, e.target.selectionStart ?? next.length)
}
@@ -145,10 +153,13 @@ export function InputBar({
// too (one char = one step). Mouse selection of a chip is handled in the
// backdrop click handler below. Undo/redo must NOT reach the browser: the
// machine owns the transaction log.
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0,
})
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
const el = e.currentTarget
@@ -36,6 +36,8 @@ const SCOPE_TAG: symbol = (() => {
const spy = new Proxy(new Context(), {
get(target, prop, receiver) {
recorded.push(prop)
// Reflect.get is typed any; the probe only records property names.
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver)
},
})
@@ -80,7 +82,8 @@ async function bench() {
}
type TestProvider = {
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
hooks?: Record<string, unknown>; props?: Record<string, unknown>
hooks?: Record<string, unknown>
props?: Record<string, unknown>
}
}
const providers: TestProvider[] = []
@@ -158,10 +161,12 @@ async function bench() {
const inputSurface = (id: SessionId) => {
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
const state = contribution.hooks!['input'] as {
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
getSnapshot: () => { draft: string }
subscribe: (fn: () => void) => () => void
}
const actions = contribution.props!['inputActions'] as {
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
setDraft: (text: string) => void
submit: (mode?: 'queue' | 'steer') => void
}
return { state, actions }
}
@@ -263,7 +268,7 @@ describe('conversation slot inject surface', () => {
// no draft movement, plain re-open.
const { state, actions } = b.inputSurface(ROOT)
actions.setDraft('carry me')
resident.selectWorkspace('workspace-1' as never)
void resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
expect(state.getSnapshot().draft).toBe('carry me')
@@ -271,7 +276,7 @@ describe('conversation slot inject surface', () => {
// new session's machine receives the text, then navigation lands there.
const OTHER = 'other-1' as SessionId
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
resident.selectWorkspace('workspace-2' as never)
void resident.selectWorkspace('workspace-2' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
expect(state.getSnapshot().draft).toBe('')
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
@@ -36,7 +36,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const subs = new Set<() => void>()
return {
set(next: Partial<ConversationSnapshot>) {
set: (next: Partial<ConversationSnapshot>) => {
snap = { ...snap, ...next }
for (const fn of [...subs]) fn()
},
@@ -108,6 +108,10 @@ async function bench(nodes: ToolResultNode[]) {
return info
},
maybeProvideInfo(id: string | undefined) {
// `this` inside an object-literal method is any under strict lint; the
// fake resolves through its own provideInfo above.
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
},
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
@@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -39,7 +40,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const subs = new Set<() => void>()
return {
set(next: Partial<ConversationSnapshot>) {
set: (next: Partial<ConversationSnapshot>) => {
snap = { ...snap, ...next }
for (const fn of [...subs]) fn()
},
@@ -22,7 +22,7 @@ afterEach(cleanup)
describe('tails', () => {
it('node-half apply is an intentional no-op', () => {
expect(nodeApply()).toBeUndefined()
expect(() => { nodeApply() }).not.toThrow()
})
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
@@ -12,7 +12,6 @@ import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
@@ -33,7 +33,10 @@ function DetailsColumn(props: { children?: ReactNode }) {
return <div className={css.detailsCol}>{props.children}</div>
}
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */
/**
* One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin.
* `side` keys the hover-reveal CSS to the owning column.
*/
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
const [dragging, setDragging] = useState(false)
const origin = useRef(0)
+1 -1
View File
@@ -19,7 +19,7 @@ export function Button({ variant = 'ghost', size = 'md', icon, className, childr
variant?: ButtonVariant
size?: 'md' | 'sm'
icon?: ReactNode
className?: string
className?: string | undefined
children?: ReactNode
} & ButtonHTMLAttributes<HTMLButtonElement>) {
return (
@@ -27,7 +27,8 @@ interface AnchorProps {
* Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text.
* @param props.side - placement relative to the anchor (default 'right').
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions).
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
* toggling never remounts it (which would cut its CSS transitions).
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
*/
@@ -20,6 +20,9 @@ export interface CodeBlockProps {
/** @returns true only when the host accepted the write. */
async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
@@ -30,6 +33,9 @@ async function writeClipboard(text: string): Promise<boolean> {
}
}
// jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
@@ -48,6 +54,7 @@ async function writeClipboard(text: string): Promise<boolean> {
} finally {
el.remove()
}
/* eslint-enable @typescript-eslint/no-deprecated */
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
@@ -73,9 +80,9 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
<pre className={css.plain}><code>{trimmed}</code></pre>
)
: (
// eslint-disable-next-line react/no-danger -- shiki's output is a static
// span tree it generated from `code` (no user HTML passes through), the
// sanctioned innerHTML consumption path per shiki's own docs.
// shiki's output is a static span tree it generated from `code` (no user
// HTML passes through), the sanctioned innerHTML consumption path per
// shiki's own docs.
<div dangerouslySetInnerHTML={{ __html: html }} />
)
@@ -15,6 +15,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
if (!open) return ''
let s: string
try {
// lib typing hides stringify's undefined arm (undefined/function/symbol payloads).
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
s = JSON.stringify(payload, null, 2) ?? String(payload)
} catch {
s = String(payload)
@@ -53,7 +53,9 @@ function buildComponents(streaming: boolean): Components {
// plain arm — retokenizing a growing fence on every chunk is quadratic
// main-thread work; the finalize swap highlights it once.
pre: ({ children }) => {
/* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */
// The markdown pipeline always hands `pre` its single `code` element;
// the undefined arm guards a react-markdown representation change.
/* v8 ignore next 2 */
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
const raw = child?.props.children
// A fence whose content isn't one plain string (e.g. an empty fence)
@@ -37,6 +37,8 @@ export function parseQuestionTitle(title: string): string {
/** Return whether a textarea key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
}
@@ -61,7 +63,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(null)
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const question = questions[index]!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0
@@ -99,7 +99,7 @@ describe('QuestionComposer', () => {
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
{ id: 'signals', selected: ['系统设计', '代码质量'] },
]))
expect((screen.getByRole('button', { name: '正在提交…' })).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
})
it('skips individual questions without discarding earlier answers', () => {
@@ -173,7 +173,7 @@ describe('QuestionComposer', () => {
// Receipt rejection surfaces through the domain face's thrown message.
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
expect((screen.getByRole('button', { name: '跳过本题' })).disabled).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过本题' }).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
@@ -199,7 +199,7 @@ describe('QuestionComposer', () => {
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('网络中断')).toBeTruthy()
expect((screen.getByRole('button', { name: '提交' })).disabled).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('字符串错误')).toBeTruthy()
@@ -49,7 +49,7 @@ describe('GeneralSection', () => {
mount()
expect(screen.getByText('Permission')).toBeTruthy()
expect(screen.getByText('Choose default permission mode')).toBeTruthy()
const selector = screen.getByRole('button', { name: /Read only/ })
const selector = screen.getByRole<HTMLButtonElement>('button', { name: /Read only/ })
expect(selector.disabled).toBe(true)
})
@@ -41,7 +41,7 @@ function createPanelStore() {
})
}
const chatStore = () => defineStore({
const _chatStore = () => defineStore({
init: () => ({ selection: null as { id: string } | null, draft: '' }),
actions: {
select: (d, t: { id: string }) => { d.selection = t },
@@ -49,7 +49,7 @@ const chatStore = () => defineStore({
clearDraft: (d) => { d.draft = '' },
},
})
type ChatHandle = ReturnType<typeof chatStore>
type ChatHandle = ReturnType<typeof _chatStore>
type FrameProps =
& PropsRuntime<'chain.frame'>
@@ -211,7 +211,7 @@ describe('terminal-design type chain', () => {
// @ts-expect-error non-chain keys have no renderSlotChain dispatch
chainSlots.renderSlotChain('chain.conv', {})
// @ts-expect-error a children set without chain keys provides no renderSlotChain
fp.renderSlotChain
type _NoChainSeat = typeof fp.renderSlotChain
// renderSlot owner share typed at the call site.
// @ts-expect-error owner shape mismatch (width missing)
@@ -16,11 +16,11 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
subtool: 'Sub',
}
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
user: css.tagUser!,
message: css.tagMessage!,
tool: css.tagTool!,
subtool: css.tagSubtool!,
const TAG_CLASS: Record<TrajectoryCellKind, string | undefined> = {
user: css.tagUser,
message: css.tagMessage,
tool: css.tagTool,
subtool: css.tagSubtool,
}
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
@@ -84,7 +84,7 @@ export function TrajectoryCell({
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
<span className={css.index}>#{index}</span>
<span className={css.tagSlot}>
<span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span>
<span className={[css.tag, TAG_CLASS[kind]].filter((c): c is string => c !== undefined).join(' ')}>{KIND_LABEL[kind]}</span>
</span>
<span className={css.text}>{text}</span>
<span className={css.trailing}>
@@ -248,7 +248,7 @@ describe('WaterfallView standalone branches', () => {
describe('node half', () => {
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
expect(nodeApply()).toBeUndefined()
expect(() => { nodeApply() }).not.toThrow()
})
})
@@ -21,7 +21,10 @@ import { ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
/**
* Column slide length (--ds-transition-duration-slow): rail-search focus waits it out —
* focus() forces a synchronous layout and would jank the slide.
*/
const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [
@@ -292,7 +295,7 @@ export function WorkspaceBrowser({
setRenameError(null)
}
const confirmRename = () => {
if (renameBlocked || renameTarget === null) return
if (renameBlocked) return
setRenaming(true)
setRenameError(null)
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
@@ -481,7 +484,7 @@ export function WorkspaceBrowser({
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
<Button
variant="outline"
className={css.deleteAction!}
className={css.deleteAction}
disabled={deleting}
onClick={confirmDelete}
>
@@ -161,8 +161,8 @@ export function WorkspaceCreateFlow({
title={folderConflict ? 'A workspace with this name already exists' : 'Couldnt open folder'}
footer={(
<>
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
<Button variant="primary" className={css.modalAction!} onClick={openLocalFolder}>Choose again</Button>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
<Button variant="primary" className={css.modalAction} onClick={openLocalFolder}>Choose again</Button>
</>
)}
>
@@ -179,10 +179,10 @@ export function WorkspaceCreateFlow({
description="The name is used for both the workspace and its new folder."
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
className={css.modalAction}
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
onClick={confirmCreate}
>
@@ -406,13 +406,13 @@ describe('WorkspaceBrowser', () => {
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
expect(input.value).toBe('Alpha')
// Unchanged and blank names stay blocked.
expect((screen.getByRole('button', { name: 'Rename' })).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: ' ' } })
expect((screen.getByRole('button', { name: 'Rename' })).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
// A duplicate of another workspace's title shows the inline conflict.
fireEvent.change(input, { target: { value: ' Beta ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
expect((screen.getByRole('button', { name: 'Rename' })).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: 'Gamma' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
@@ -475,13 +475,13 @@ describe('WorkspaceBrowser', () => {
expect(dialog.textContent).toContain('folder and session logs will be kept')
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
const confirm = screen.getByRole('button', { name: 'Delete workspace' })
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(deleteWorkspace).toHaveBeenCalledOnce()
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
expect(confirm.disabled).toBe(true)
expect((screen.getByRole('button', { name: 'Cancel' })).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
@@ -133,8 +133,8 @@ describe('WorkspacePicker', () => {
const pending = new Promise<string | null>((settle) => { resolve = settle })
const b = mount([], vi.fn(), vi.fn(() => pending))
chooseItem('Open local folder…')
expect((screen.getByRole('menuitem', { name: 'Open local folder…' })).disabled).toBe(true)
expect((screen.getByRole('menuitem', { name: 'Create a new workspace' })).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
await act(async () => { resolve(null); await pending })
@@ -161,7 +161,7 @@ describe('WorkspacePicker', () => {
chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
expect((screen.getByRole('button', { name: 'Create workspace' })).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
expect(b.createWorkspace).not.toHaveBeenCalled()
})
+14 -7
View File
@@ -200,7 +200,8 @@ function standardKit(
scope: SlotScope,
info: SessionMaybeProvideInfo | undefined,
): {
kit: InjectedProps; actions: object | undefined
kit: InjectedProps
actions: object | undefined
} {
const kit: InjectedProps = {
useSessions: observableHook(host.sessions.list),
@@ -253,7 +254,9 @@ function standardKit(
* composition point, one per scope branch).
*/
function SessionEntry({ entry, ownerProps, info }: {
entry: StoredEntry; ownerProps: object; info: SessionProvideInfo
entry: StoredEntry
ownerProps: object
info: SessionProvideInfo
}) {
const host = useHost()
const Comp = entry.component as FC<InjectedProps>
@@ -280,7 +283,9 @@ function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: obje
}
function StrictSessionEntry({ slotKey, entry, ownerProps }: {
slotKey: string; entry: StoredEntry; ownerProps: object
slotKey: string
entry: StoredEntry
ownerProps: object
}) {
const info = useSessionMaybeProvideInfo()
if (info.sessionId === undefined) return null
@@ -292,7 +297,9 @@ function StrictSessionEntry({ slotKey, entry, ownerProps }: {
}
function SlotOutlet({ slotKey, ownerProps, opts }: {
slotKey: string; ownerProps: object; opts?: (RenderOpts & ChainRenderOpts) | undefined
slotKey: string
ownerProps: object
opts?: (RenderOpts & ChainRenderOpts) | undefined
}) {
const host = useHost()
// Version tick drives entries() re-read; the host batches per microtask.
@@ -335,7 +342,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
return guarded(entry)
}
if (spec.kind === 'keyed') {
const entry = entries.find(e => e.options?.key === opts?.entryKey)
const entry = entries.find(e => e.options.key === opts?.entryKey)
if (!entry) return <>{opts?.fallback ?? null}</>
return guarded(entry)
}
@@ -390,8 +397,8 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
// list: registration order refined by explicit order, optional id filter.
const withListOptions = entries.map(entry => ({
entry,
id: entry.options?.id,
order: entry.options?.order ?? 0,
id: entry.options.id,
order: entry.options.order ?? 0,
}))
let list = [...withListOptions].sort((a, b) => a.order - b.order)
if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only)
@@ -77,7 +77,10 @@ export function maybeObservableHook<T>(source: HostObservable<T> | undefined): M
}
function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined {
// The uSES subscription must still run (hook-order stability); the absent
// source always snapshots undefined, returned explicitly.
observableHook(absentSource)(() => undefined)
return undefined
}
/**
@@ -33,7 +33,10 @@ const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry
* but entry.store is typed to the full contract — the real defineStore lives
* in runtime, which web-react tests must not import (dependency direction).
*/
function miniStore<T extends object>(init: () => T, mutators: Record<string, (state: T, ...params: never[]) => T>): StoreHandle<T, ActionsDecl<T>> {
function miniStore<T extends object>(
init: () => T,
mutators: Record<string, (state: T, ...params: never[]) => T>,
): StoreHandle<T, ActionsDecl<T>> {
return {
spec: { init, actions: {} },
create: () => {
@@ -3,10 +3,10 @@ import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import { useInvoke } from '@deepseek-ai/dsh-client-web-react'
function deferred<T>() {
let resolve!: (v: T) => void
function deferred() {
let resolve!: () => void
let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
@@ -28,7 +28,7 @@ const newProbe = (): Probe => ({ invoke: () => {}, pending: false, renders: 0 })
describe('useInvoke', () => {
it('tracks pending across the action lifecycle', async () => {
const d = deferred<void>()
const d = deferred()
const probe = newProbe()
render(<Harness fn={() => d.promise} probe={probe} />)
expect(probe.pending).toBe(false)
@@ -39,8 +39,8 @@ describe('useInvoke', () => {
})
it('keeps pending true until the last concurrent call settles', async () => {
const d1 = deferred<void>()
const d2 = deferred<void>()
const d1 = deferred()
const d2 = deferred()
const queue = [d1, d2]
const probe = newProbe()
render(<Harness fn={() => queue.shift()!.promise} probe={probe} />)
@@ -68,7 +68,7 @@ describe('useInvoke', () => {
it('resets pending and logs when the action rejects', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const d = deferred<void>()
const d = deferred()
const probe = newProbe()
render(<Harness fn={() => d.promise} probe={probe} />)
act(() => { probe.invoke() })
-1
View File
@@ -7,7 +7,6 @@
*/
import type { ReactNode } from 'react'
import type { Context } from 'cordis'
import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { DocumentTitle } from './DocumentTitle.tsx'
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.