Browse Source

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.
imccyu 1 tháng trước cách đây
mục cha
commit
cdd4d59ea0
31 tập tin đã thay đổi với 134 bổ sung68 xóa
  1. 6 2
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  2. 7 0
      packages/client/ui-conversation/src/client/chat/MessageItem.tsx
  3. 2 2
      packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
  4. 13 2
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  5. 10 5
      packages/client/ui-conversation/tests/apply-inject.spec.tsx
  6. 1 1
      packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
  7. 4 0
      packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
  8. 3 2
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  9. 1 1
      packages/client/ui-conversation/tests/coverage-tails.spec.tsx
  10. 0 1
      packages/client/ui-conversation/tests/input-scenarios.spec.tsx
  11. 4 1
      packages/client/ui-layout/src/client/AppFrame.tsx
  12. 1 1
      packages/client/ui-primitives/src/Button.tsx
  13. 2 1
      packages/client/ui-primitives/src/Tooltip.tsx
  14. 10 3
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  15. 2 0
      packages/client/ui-primitives/src/markdown/JsonBlock.tsx
  16. 3 1
      packages/client/ui-primitives/src/markdown/MarkdownText.tsx
  17. 5 0
      packages/client/ui-question/src/client/QuestionComposer.tsx
  18. 3 3
      packages/client/ui-question/tests/question-composer.spec.tsx
  19. 1 1
      packages/client/ui-settings-general/tests/components.spec.tsx
  20. 3 3
      packages/client/ui-slots/tests/type-chain.spec.tsx
  21. 6 6
      packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
  22. 1 1
      packages/client/ui-trajectory/tests/views.spec.tsx
  23. 6 3
      packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
  24. 4 4
      packages/client/ui-workspace/src/client/WorkspacePicker.tsx
  25. 5 5
      packages/client/ui-workspace/tests/workspace-browser.spec.tsx
  26. 3 3
      packages/client/ui-workspace/tests/workspace-picker.spec.tsx
  27. 14 7
      packages/client/web-react/src/scoped-slots.tsx
  28. 3 0
      packages/client/web-react/src/session-provider.tsx
  29. 4 1
      packages/client/web-react/tests/scoped-slots.spec.tsx
  30. 7 7
      packages/client/web-react/tests/use-invoke.spec.tsx
  31. 0 1
      packages/client/web/src/app.tsx

+ 6 - 2
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -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)

+ 7 - 0
packages/client/ui-conversation/src/client/chat/MessageItem.tsx

@@ -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()
 }
 

+ 2 - 2
packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx

@@ -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

+ 13 - 2
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -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

+ 10 - 5
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -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')

+ 1 - 1
packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx

@@ -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()
     },

+ 4 - 0
packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx

@@ -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 () => {} },

+ 3 - 2
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -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()
     },

+ 1 - 1
packages/client/ui-conversation/tests/coverage-tails.spec.tsx

@@ -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', () => {

+ 0 - 1
packages/client/ui-conversation/tests/input-scenarios.spec.tsx

@@ -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'

+ 4 - 1
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -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
packages/client/ui-primitives/src/Button.tsx

@@ -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 (

+ 2 - 1
packages/client/ui-primitives/src/Tooltip.tsx

@@ -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.
  */

+ 10 - 3
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -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 }} />
     )
 

+ 2 - 0
packages/client/ui-primitives/src/markdown/JsonBlock.tsx

@@ -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)

+ 3 - 1
packages/client/ui-primitives/src/markdown/MarkdownText.tsx

@@ -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)

+ 5 - 0
packages/client/ui-question/src/client/QuestionComposer.tsx

@@ -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
 

+ 3 - 3
packages/client/ui-question/tests/question-composer.spec.tsx

@@ -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()

+ 1 - 1
packages/client/ui-settings-general/tests/components.spec.tsx

@@ -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)
   })
 

+ 3 - 3
packages/client/ui-slots/tests/type-chain.spec.tsx

@@ -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)

+ 6 - 6
packages/client/ui-trajectory/src/client/TrajectoryCell.tsx

@@ -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}>

+ 1 - 1
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -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()
   })
 })
 

+ 6 - 3
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -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}
             >

+ 4 - 4
packages/client/ui-workspace/src/client/WorkspacePicker.tsx

@@ -161,8 +161,8 @@ export function WorkspaceCreateFlow({
         title={folderConflict ? 'A workspace with this name already exists' : 'Couldn’t 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}
             >

+ 5 - 5
packages/client/ui-workspace/tests/workspace-browser.spec.tsx

@@ -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' }))

+ 3 - 3
packages/client/ui-workspace/tests/workspace-picker.spec.tsx

@@ -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
packages/client/web-react/src/scoped-slots.tsx

@@ -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)

+ 3 - 0
packages/client/web-react/src/session-provider.tsx

@@ -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
 }
 
 /**

+ 4 - 1
packages/client/web-react/tests/scoped-slots.spec.tsx

@@ -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: () => {

+ 7 - 7
packages/client/web-react/tests/use-invoke.spec.tsx

@@ -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() })

+ 0 - 1
packages/client/web/src/app.tsx

@@ -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.