Files
deepseek-harness/packages/experimental/client-ui-forge/src/client/ThreadList.tsx
T
Yichen Jiang 4a6e0e3c52 feat(forge-panel): step through a file's unresolved threads from its header count
The unresolved count on a file header opened the whole thread list scrolled to that file, which covered the code in
a narrow view and did not tell the file's threads apart. Each click now goes to the file's next unresolved thread in
the order its section shows them: threads on the whole file, threads on lines by the row of their last line,
outdated threads, then threads the chosen commits do not hold; after the last it starts again from the first. The
thread list orders each file's threads the same way, and the Threads toolbar button alone opens it.
2026-11-07 17:46:31 +08:00

166 lines
8.2 KiB
TypeScript

/**
* The thread list of a change request beside its changes: every thread grouped by file in file tree order, narrowed
* by whether it is resolved, each opening the place it shows at. A wide changes view docks the list beside the files;
* a narrower one floats it over them, and the narrowest gives it their whole width.
*/
import { useLayoutEffect, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react'
import { IconChevronLeftOutlineRegular, IconCloseOutlineRegular, IconRightUpOutlineRegular, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ForgeReviewThread } from '@deepseek-ai/dsh-experimental-forge/types'
import type { ForgeDiffFile } from '../types.ts'
import { FileLabel } from './FileLabel.tsx'
import { SegmentedChoice } from './ReviewControls.tsx'
import { Avatar } from './ReviewThreads.tsx'
import { anchorRange, inFileOrder, passesFilter, THREAD_FILTERS, type ThreadFilter, type ThreadPlace } from './review-threads.ts'
import type { NS } from './locales.ts'
import type { PlacedThread } from './thread-state.ts'
import css from './ThreadList.module.css'
type Translate = PropsLocale<typeof NS>['t']
/** Props of the thread list. */
export interface ThreadListProps extends PropsLocale<typeof NS> {
/** Every thread read, in host order, with where the current changes show it. */
readonly entries: readonly PlacedThread[]
/** Changed files in file tree order; threads on other paths follow them. */
readonly files: readonly ForgeDiffFile[]
/** Whether the host has more threads than were read. */
readonly truncated: boolean
/** Web page of the change request. */
readonly url: string
/** The active filter. */
readonly filter: ThreadFilter
/** Choose a filter. */
readonly choose: (filter: ThreadFilter) => void
/** Whether the reader just opened the list, so a list that covers the files takes the focus. */
readonly claim: boolean
/** Report that the list handled `claim`. */
readonly claimed: () => void
/** Open the place of a thread; `covering` says the list covers the files, so the owner closes it first. */
readonly open: (entry: PlacedThread, covering: boolean) => void
/** Open a URL outside the app. */
readonly openLink: (url: string) => void
/** Close the list at the reader's request. */
readonly close: () => void
}
function meta(thread: ForgeReviewThread, place: ThreadPlace, t: Translate): string {
const replies = thread.comments.length - 1
const parts: string[] = []
if (thread.subject === 'file') parts.push(t('thread.fileLevel'))
else if (place.kind === 'line') parts.push(anchorRange(place.side, place))
else if (thread.original !== null) {
parts.push(t('thread.originally', { range: anchorRange(thread.side, thread.original) }), t('thread.onCommit', { commit: thread.original.commit.slice(0, 7) }))
}
if (replies > 0) parts.push(t('thread.replies', { count: replies }))
return parts.join(' · ')
}
/** Whether the list floats over the files or takes their width, which its narrow-view styles decide. */
function covers(pane: HTMLElement | null): pane is HTMLElement {
return pane !== null && getComputedStyle(pane).position === 'absolute'
}
/**
* The first comment as one line of plain text: without raw HTML, strong, strikethrough, and code marks, quote or
* heading marks, and link targets.
*/
function plainExcerpt(body: string): string {
return body.replace(/<[^>]*>/g, ' ').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s*(?:#{1,6}|>)\s*/gm, '')
.replace(/\*\*|__|~~|`/g, '').replace(/\s+/g, ' ').trim()
}
function Item({ entry, open, t }: {
readonly entry: PlacedThread
readonly open: (entry: PlacedThread) => void
readonly t: Translate
}): ReactNode {
const { thread, place } = entry
const first = thread.comments[0]
return <button type="button" className={css.item} data-forge-thread-item={thread.id} onClick={() => { open(entry) }}>
<Avatar author={first?.author ?? null} size={22} />
<span className={css.itemMain}>
<span className={css.meta}>{meta(thread, place, t)}</span>
<span className={css.excerpt}>{plainExcerpt(first?.body ?? '')}</span>
<span className={css.tags}>
{place.kind === 'outdated' && <Tag tone="warning">{t('thread.outdatedTag')}</Tag>}
{place.kind === 'elsewhere' && <Tag tone="neutral">{t('thread.elsewhereTag')}</Tag>}
{thread.resolved && <Tag tone="success">{t('thread.filter.resolved')}</Tag>}
{first?.author?.bot === true && <Tag tone="outline">{t('thread.bot')}</Tag>}
</span>
</span>
</button>
}
/**
* Render the thread list.
* @param props - threads, their places, the file order, the filter, and the list's actions.
* @returns the list pane and the scrim behind it where it floats over the files.
*/
export function ThreadList(props: ThreadListProps): ReactNode {
const { entries, files, filter, t } = props
const threads = useMemo(() => entries.map(entry => entry.thread), [entries])
const counts = useMemo(() => Object.fromEntries(THREAD_FILTERS.map(item => [item,
threads.filter(thread => passesFilter(thread, item)).length])) as Record<ThreadFilter, number>, [threads])
const groups = useMemo(() => {
const known = new Map(files.map((file, index) => [file.path, { file, index }]))
const byPath = new Map<string, PlacedThread[]>()
for (const entry of entries) {
if (!passesFilter(entry.thread, filter)) continue
const list = byPath.get(entry.thread.path)
if (list === undefined) byPath.set(entry.thread.path, [entry])
else list.push(entry)
}
const position = (path: string): number => known.get(path)?.index ?? files.length
return [...byPath].sort(([a], [b]) => position(a) - position(b) || a.localeCompare(b))
.map(([path, list]) => [path, inFileOrder(list, known.get(path)?.file)] as const)
}, [entries, files, filter])
const pane = useRef<HTMLElement>(null)
useLayoutEffect(() => {
if (!props.claim) return
props.claimed()
if (covers(pane.current)) pane.current.focus({ preventScroll: true })
}, [props.claim])
const escape = (event: KeyboardEvent<HTMLElement>): void => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
props.close()
}
const open = (entry: PlacedThread): void => { props.open(entry, covers(pane.current)) }
return <>
<div className={css.scrim} aria-hidden="true" onClick={props.close} />
<aside ref={pane} className={css.pane} tabIndex={-1} aria-label={t('thread.listAria', { count: threads.length })} data-forge-thread-list=""
onKeyDown={escape}>
<div className={css.header}>
<button type="button" className={css.back} onClick={props.close}><IconChevronLeftOutlineRegular size={14} />{t('thread.back')}</button>
<span className={css.title}>{t('thread.list')}</span>
<span className={css.total}>{threads.length}</span>
<span className={css.spacer} />
<Tooltip label={t('thread.close')} side="bottom">
<button type="button" className={css.close} aria-label={t('thread.close')} onClick={props.close}><IconCloseOutlineRegular size={14} /></button>
</Tooltip>
</div>
<div className={css.filters}>
<SegmentedChoice label={t('thread.filterAria')} value={filter} choose={props.choose}
segments={THREAD_FILTERS.map(item => ({ value: item, label: t(`thread.filter.${item}`), count: counts[item], disabledTip: null }))} />
</div>
<div className={css.rows}>
{threads.length === 0 ? <div className={css.note}>{t('thread.empty')}</div>
: groups.length === 0 ? <div className={css.note}>{t('thread.none')}</div>
: groups.map(([path, list]) => <section key={path} className={css.group} data-forge-thread-group={path}>
<h3 className={css.path}><FileLabel path={path} /></h3>
{list.map(entry => <Item key={entry.thread.id} entry={entry} open={open} t={t} />)}
</section>)}
</div>
<div className={css.footer}>
{props.truncated && <>{t('thread.truncated', { count: threads.length })}{' · '}</>}
<button type="button" className={css.link} onClick={() => { props.openLink(props.url) }}>
<IconRightUpOutlineRegular size={14} />{t('thread.openAll')}
</button>
</div>
</aside>
</>
}