Files
deepseek-harness/packages/experimental/client-ui-forge/src/client/thread-state.ts
T
Yichen Jiang 9f875cf44d Merge remote-tracking branch 'origin/feat/forge-review-threads' into feat/forge-review-writes
# Conflicts:
#	apps/web/tests/forge-panel.e2e.ts
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/subsystems/forge.i18n.yaml
#	docs/subsystems/forge.md
#	docs/subsystems/forge.zh.md
#	packages/experimental/client-ui-forge/README.i18n.yaml
#	packages/experimental/client-ui-forge/README.md
#	packages/experimental/client-ui-forge/README.zh.md
#	packages/experimental/client-ui-forge/src/client/ChangesTab.tsx
#	packages/experimental/client-ui-forge/src/client/mount.ts
#	packages/experimental/client-ui-forge/src/client/thread-state.ts
#	packages/experimental/client-ui-forge/src/index.ts
#	packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx
#	packages/experimental/client-ui-forge/tests/mount.client.spec.ts
#	packages/experimental/client-ui-forge/tests/review-cache.host.spec.ts
#	packages/experimental/client-ui-forge/tests/review.host.spec.ts
#	packages/experimental/forge-github/README.i18n.yaml
#	packages/experimental/forge-github/src/threads.ts
#	packages/experimental/forge-github/tests/threads.spec.ts
#	packages/experimental/forge/src/types.ts
#	packages/experimental/forge/tests/registry.spec.ts
#	packages/extensions/tool-cordis/src/api-catalog.ts
2026-11-09 13:03:22 +08:00

128 lines
5.9 KiB
TypeScript

/** Reads the review threads of the change request a changes view shows and places them in its files. */
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ForgeReviewData, ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { LineThread } from './ReviewThreads.tsx'
import { placeThread, type ThreadPlace, type ThreadView } from './review-threads.ts'
/** What the changes view knows about its change request when it reads threads. */
export interface ThreadReadInput {
/** Whether the view is visible; a hidden view reads nothing. */
readonly visible: boolean
/** Binding URL of the change request, or null for local changes. */
readonly url: string | null
/** Head commit of the latest status, or null before one. */
readonly head: string | null
/** Update time of the latest status, or null before one. */
readonly updatedAt: string | null
/** Epoch milliseconds of the latest status, so every status asks the Host again; the Host answers from memory until the threads age. */
readonly statusAt: number | null
/** Changes on each refresh. */
readonly attempt: number
/** Changes after each write that may have changed the threads, which the Host no longer answers from memory. */
readonly revision: number
/** Read the threads at a head commit; `fresh` skips the threads the host keeps. */
readonly read: (url: string, headSha: string, fresh: boolean, signal: AbortSignal) => Promise<RemoteResult<ForgeReviewData>>
}
/** The last threads read and why the latest read failed. */
export interface ThreadReadState {
/** Threads of the last successful read for the URL, at any head, or null. */
readonly review: ForgeReviewData | null
/** Message of the latest failed read, or null, also when the provider cannot read reviews. */
readonly failure: string | null
}
/**
* Read the threads at the head of each status while the view is visible. A read refused because the head moved
* keeps the previous threads; the next status names the new head and reads again. The first read after a refresh
* asks the host to skip the threads it keeps, which a resolved thread leaves unchanged.
* @param input - the change request and the reader.
* @returns the last threads and the latest failure.
*/
export function useThreadRead(input: ThreadReadInput): ThreadReadState {
const { visible, url, head, updatedAt, statusAt, attempt, revision, read } = input
const [state, setState] = useState<ThreadReadState & { readonly url: string | null }>({ url: null, review: null, failure: null })
const readAttempt = useRef(attempt)
useEffect(() => {
if (!visible || url === null || head === null) return
const fresh = readAttempt.current !== attempt
readAttempt.current = attempt
const controller = new AbortController()
void read(url, head, fresh, controller.signal).then((result) => {
if (controller.signal.aborted) return
if (result.ok) {
setState({ url, review: result.value, failure: null })
return
}
const { code, message } = result.error
if (code === 'forgePanel/stale-head') return
setState(previous => ({ url, review: previous.url === url ? previous.review : null,
failure: code === 'forgePanel/review-unsupported' ? null : message }))
})
return () => { controller.abort() }
}, [visible, url, head, updatedAt, statusAt, attempt, revision, read])
return state.url === url ? state : { review: null, failure: null }
}
/** Threads of one file grouped by where they show, and how many of them are unresolved. */
export interface PlacedFileThreads {
readonly lines: readonly LineThread[]
readonly file: readonly ForgeReviewThread[]
readonly outdated: readonly ForgeReviewThread[]
readonly unresolved: number
}
/** One thread and where the shown changes place it. */
export interface PlacedThread {
readonly thread: ForgeReviewThread
readonly place: ThreadPlace
}
/** Every thread with its place and the threads of each file. */
export interface PlacedThreads {
/** Every thread in host order with its place. */
readonly entries: readonly PlacedThread[]
/** Threads of each path. */
readonly files: ReadonlyMap<string, PlacedFileThreads>
/** Unresolved threads by path, for the file tree. */
readonly unresolved: ReadonlyMap<string, number>
}
const NONE: PlacedThreads = { entries: [], files: new Map(), unresolved: new Map() }
/**
* Place the threads of a review in the shown changes.
* @param review - threads read at the head of the latest status, or null.
* @param view - the shown changes, or null before they load.
* @returns places, per-file groups, and unresolved counts.
*/
export function usePlacedThreads(review: ForgeReviewData | null, view: ThreadView | null): PlacedThreads {
const commits = view?.commits ?? null
const end = view?.end ?? null
return useMemo(() => {
if (review === null || end === null) return NONE
const entries: PlacedThread[] = []
const files = new Map<string, { lines: LineThread[]; file: ForgeReviewThread[]; outdated: ForgeReviewThread[]; unresolved: number }>()
const unresolved = new Map<string, number>()
for (const thread of review.threads) {
const place = placeThread(thread, { head: review.headSha, end, commits })
entries.push({ thread, place })
let group = files.get(thread.path)
if (group === undefined) {
group = { lines: [], file: [], outdated: [], unresolved: 0 }
files.set(thread.path, group)
}
if (place.kind === 'line') group.lines.push({ thread, place })
else if (place.kind === 'file') group.file.push(thread)
else if (place.kind === 'outdated') group.outdated.push(thread)
if (!thread.resolved) {
group.unresolved += 1
unresolved.set(thread.path, group.unresolved)
}
}
return { entries, files, unresolved }
}, [review, end, commits])
}