Files
deepseek-harness/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx
T
Yichen Jiang e07b170d77 fix(forge): address review of pinned diffs and thread counting
- forge-github: repeat a compare read without --allow-escape-sequences when
  a gh older than 2.97 rejects the option; such a gh prints escape sequences
  unchanged, so every version reads every diff (checked with gh 2.96.0 and
  2.100.0 on a pull request whose patch holds an ESC byte).
- client-ui-forge: a stale refusal now re-runs the read effect, so a status
  that arrived during the read, including a failed poll that keeps the
  earlier head, starts the next read at once instead of leaving the changes
  view loading.
- forge: ctx.forge.diff polls at once only for the first stale read at a head
  commit; later stale reads at it wait for the regular poll, which bounds the
  read-poll loop while GitHub's answers briefly disagree.
- forge-github: review thread pages share the byte budget of a complete
  check read.
- client-ui-forge: a truncated count shows its "+" even when it is zero.
- READMEs: document the batch deadline and rewritten-base limitations.
2026-11-06 18:17:04 +08:00

543 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* One change request as a card: its title links to the host, a one-line
* summary shows while collapsed, and while expanded one row per fact (checks,
* review, merge state, and the changed lines, which open its changes) above
* the ready-for-review or merge action.
*/
import { useEffect, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
Button, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseOutlineRegular, IconCopyOutlineRegular,
IconListPenOutlineRegular, IconRightUpOutlineRegular, IconTriangleRightFillRegular, IconUserOutlineRegular, Menu, MenuItemButton,
StateDot, Tooltip,
type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type {
ChangeRequestCheck, ChangeRequestSnapshot, CheckState, ChecksRollup, ForgeFailure, MergeMethod, MergeRequest,
} from '@deepseek-ai/dsh-experimental-forge/types'
import type { ForgePanelChangeRequest } from '../types.ts'
import { formatAgo, formatDuration } from './format.ts'
import { ChangeRequestGlyph, CheckGlyph } from './glyphs.tsx'
import type { ForgePanelKey, NS } from './locales.ts'
import { checkCounts, checkDuration, ciSummary, flagOf, numberOf, TONE_LABEL, toneOf } from './status.ts'
import css from './ForgeTab.module.css'
type Translate = PropsLocale<typeof NS>['t']
/** What a card can do; a surface without a Session binding omits `remove`, `markReady`, `merge`, and `viewChanges`. */
export interface ChangeRequestActions {
/** Open a URL outside the app. */
readonly openLink: (url: string) => void
/** Copy a URL to the clipboard. */
readonly copyLink: (url: string) => void
/** Read the status again after a failed read. */
readonly retry: () => void
/** Remove the change request from its Session. */
readonly remove?: ((entry: ForgePanelChangeRequest) => void) | undefined
/** Mark a draft ready for review; settles once the outcome is reported. */
readonly markReady?: ((entry: ForgePanelChangeRequest) => Promise<void>) | undefined
/** Merge the change request as `request` describes; settles once the outcome is reported. */
readonly merge?: ((entry: ForgePanelChangeRequest, request: MergeRequest) => Promise<void>) | undefined
/** Load complete check details independently of polling; cancellation follows the open list. */
readonly readChecks?: ((url: string, headSha: string, signal: AbortSignal) => Promise<ChangeRequestSnapshot>) | undefined
/** Open the changes view of the change request. */
readonly viewChanges?: ((entry: ForgePanelChangeRequest) => void) | undefined
}
/** Props of one change request card. */
export interface ChangeRequestCardProps {
/** Change request with its latest snapshot and polling result. */
readonly entry: ForgePanelChangeRequest
/** Whether the details show. */
readonly expanded: boolean
/** Show or hide the details. */
readonly onToggle: () => void
/** Current epoch milliseconds for relative times. */
readonly now: number
/** Card actions. */
readonly actions: ChangeRequestActions
/** Active UI language for numeric formatting. */
readonly locale: string
/** Forge panel copy. */
readonly t: Translate
}
const REVIEW: Record<ChangeRequestSnapshot['reviewDecision'], ForgePanelKey> = {
'approved': 'change.review.approved',
'changes-requested': 'change.review.changesRequested',
'review-required': 'change.review.required',
'none': 'change.review.none',
}
const FAILURE: Record<ForgeFailure['code'], ForgePanelKey> = {
'not-found': 'change.failure.notFound',
'unauthenticated': 'change.failure.unauthenticated',
'too-large': 'change.failure.unavailable',
'stale': 'change.failure.unavailable',
'unavailable': 'change.failure.unavailable',
}
const ROLLUP: Record<ChecksRollup, CheckState> = {
none: 'neutral',
pending: 'pending',
success: 'success',
failure: 'failure',
}
const CHECK_RANK: Record<CheckState, number> = {
failure: 0, pending: 1, success: 2, neutral: 3, skipped: 3, cancelled: 3,
}
/** The unresolved thread count, with `+` when the provider counted only some threads. */
function threadCount(snapshot: ChangeRequestSnapshot, locale: string): string {
const count = snapshot.unresolvedThreads.toLocaleString(locale)
return snapshot.threadsTruncated ? `${count}+` : count
}
function failureOf(entry: ForgePanelChangeRequest): ForgeFailure | undefined {
return entry.update !== null && 'failure' in entry.update ? entry.update.failure : undefined
}
/** Failed checks first, then running ones, then the rest, each group in host order. */
function orderedChecks(checks: readonly ChangeRequestCheck[]): ChangeRequestCheck[] {
return checks.toSorted((left, right) => CHECK_RANK[left.state] - CHECK_RANK[right.state])
}
function checkTime(check: ChangeRequestCheck, t: Translate): string {
if (check.state === 'pending') return t(check.startedAt === null ? 'change.check.queued' : 'change.check.running')
const elapsed = checkDuration(check)
return elapsed === undefined ? '' : formatDuration(elapsed, t)
}
function DiffSize({ snapshot, locale }: { readonly snapshot: ChangeRequestSnapshot; readonly locale: string }): ReactNode {
return (
<span className={css.diff}>
<span className={css.added}>{`+${snapshot.additions.toLocaleString(locale)}`}</span>
<span className={css.deleted}>{`−${snapshot.deletions.toLocaleString(locale)}`}</span>
</span>
)
}
/** The changed lines, as a row that opens the changes view when the provider serves the change request. */
function ChangesRow({ entry, snapshot, name, actions, locale, t }: {
readonly entry: ForgePanelChangeRequest
readonly snapshot: ChangeRequestSnapshot
readonly name: string
readonly actions: ChangeRequestActions
readonly locale: string
readonly t: Translate
}): ReactNode {
const { viewChanges } = actions
const parts = (
<>
<span className={css.rowIcon}><IconListPenOutlineRegular size={16} /></span>
<span className={css.rowLabel}>{t('change.row.changes')}</span>
<span className={css.rowValue}><DiffSize snapshot={snapshot} locale={locale} /></span>
</>
)
if (viewChanges === undefined || entry.ref === null) return <div className={css.row}>{parts}</div>
return (
<button
type="button"
className={clsx(css.row, css.rowButton)}
aria-label={t('change.viewChangesAria', {
name,
added: snapshot.additions.toLocaleString(locale),
deleted: snapshot.deletions.toLocaleString(locale),
})}
onClick={() => { viewChanges(entry) }}
>
{parts}
<IconChevronRightOutlineRegular size={12} className={css.rowChevron} />
</button>
)
}
/** The merged or closed fact with its time, such as 已合并到 main · 2 小时前. */
function finished(snapshot: ChangeRequestSnapshot, now: number, t: Translate): string {
const state = snapshot.state === 'merged' ? t('change.mergedTo', { base: snapshot.baseBranch }) : t('change.state.closed')
const ago = snapshot.closedAt === null ? undefined : formatAgo(snapshot.closedAt, now, t)
return ago === undefined ? state : t('time.finished', { state, time: ago })
}
/** Collapsed status after the number: checks and the most urgent flag, the merge or close time, or a read in progress. */
function Summary({ snapshot, now, t }: {
readonly snapshot: ChangeRequestSnapshot | null
readonly now: number
readonly t: Translate
}): ReactNode {
if (snapshot === null) return <StateDot state="ongoing" size={11} />
if (snapshot.state !== 'open') {
return <span className={css.finished} data-tone={snapshot.state}>{finished(snapshot, now, t)}</span>
}
const ci = ciSummary(snapshot)
const flag = flagOf(snapshot)
return (
<>
<span className={css.ci} data-tone={ci.kind === 'failed' ? 'error' : undefined}>
{ci.kind !== 'none' && <CheckGlyph state={ci.kind === 'cancelled' ? 'cancelled' : ROLLUP[snapshot.checksRollup]} size={12} />}
{ci.kind === 'failed'
? ci.count > 0 ? t('change.ci.failed', { count: ci.count }) : t('change.ci.failedUnlisted')
: ci.kind === 'cancelled' ? t('change.ci.cancelled', { count: ci.count }) : t(`change.ci.${ci.kind}`)}
</span>
{flag !== undefined && <span className={css.flag} data-flag={flag}>{t(`change.flag.${flag}`)}</span>}
</>
)
}
/** Expanded identity after the number: draft state and the head and base branches. */
function Target({ snapshot, t }: { readonly snapshot: ChangeRequestSnapshot; readonly t: Translate }): ReactNode {
return (
<>
{snapshot.state === 'open' && snapshot.draft && <span>{t('change.state.draft')}</span>}
<span className={css.ellipsis}>{`${snapshot.headBranch} → ${snapshot.baseBranch}`}</span>
</>
)
}
type CheckRead = { readonly kind: 'idle' | 'loading' }
| { readonly kind: 'ready'; readonly snapshot: ChangeRequestSnapshot }
| { readonly kind: 'failed'; readonly message: string }
function ChecksRow({ snapshot, openLink, readChecks, t }: {
readonly snapshot: ChangeRequestSnapshot
readonly openLink: (url: string) => void
readonly readChecks: ChangeRequestActions['readChecks']
readonly t: Translate
}): ReactNode {
const [open, setOpen] = useState(false)
const [read, setRead] = useState<CheckRead>({ kind: 'idle' })
const [attempt, setAttempt] = useState(0)
const { url, headSha, checksTruncated } = snapshot
useEffect(() => {
if (!open || !checksTruncated || readChecks === undefined) { setRead({ kind: 'idle' }); return }
const controller = new AbortController()
setRead({ kind: 'loading' })
void (async () => {
try {
const result = await readChecks(url, headSha, controller.signal)
if (!controller.signal.aborted) setRead({ kind: 'ready', snapshot: result })
} catch (error) {
if (!controller.signal.aborted) setRead({ kind: 'failed', message: error instanceof Error ? error.message : String(error) })
}
})()
return () => { controller.abort() }
}, [open, url, headSha, checksTruncated, readChecks, attempt])
const displayed = read.kind === 'ready' && read.snapshot.headSha === headSha ? read.snapshot : snapshot
if (displayed.checks.length === 0 && !displayed.checksTruncated) {
return (
<div className={css.row}>
<span className={css.rowIcon}><CheckGlyph state="neutral" /></span>
<span className={css.rowLabel}>{t('change.row.checks')}</span>
<span className={css.rowValue}>{t('change.ci.none')}</span>
</div>
)
}
const counts = checkCounts(displayed.checks)
const summary = displayed.checksTruncated ? t('change.checks.truncated', { count: displayed.checks.length }) : [
...counts.failed > 0 ? [t('change.checks.failed', { count: counts.failed })] : [],
...counts.running > 0 ? [t('change.checks.running', { count: counts.running })] : [],
...counts.passed > 0 ? [t('change.checks.passed', { count: counts.passed })] : [],
...counts.cancelled > 0 ? [t('change.checks.cancelled', { count: counts.cancelled })] : [],
...counts.other > 0 ? [t('change.checks.other', { count: counts.other })] : [],
].join(' · ')
const close = (): void => { setOpen(false) }
const footer: MenuEntry[] = displayed.checksTruncated
? [{ type: 'label', id: 'truncated', text: t('change.checks.truncated', { count: displayed.checks.length }) }] : []
if (read.kind === 'loading') footer.push({ id: 'loading', label: t('change.checks.loading'), disabled: true, icon: <StateDot state="ongoing" size={14} /> })
if (read.kind === 'failed') {
footer.push({ type: 'label', id: 'failed', text: t('change.checks.loadFailed', { message: read.message }) },
{ id: 'retry', label: t('change.checks.retry') })
}
return (
<Menu
open={open}
onClose={close}
portal
dense
className={css.checksAnchor}
listClassName={css.checksList}
anchor={(
<button type="button" className={clsx(css.row, css.rowButton)} aria-haspopup="menu" aria-expanded={open} onClick={() => { setOpen(!open) }}>
<span className={css.rowIcon}><CheckGlyph state={ROLLUP[displayed.checksRollup]} /></span>
<span className={css.rowLabel}>{t('change.row.checks')}</span>
<span className={css.rowValue} data-tone={!displayed.checksTruncated && counts.failed > 0 ? 'error' : undefined}>
<span className={css.ellipsis}>{summary}</span>
</span>
<IconChevronRightOutlineRegular size={12} className={css.rowChevron} />
</button>
)}
footer={footer}
onSelect={() => { setAttempt(value => value + 1) }}
>
{orderedChecks(displayed.checks).map((check, index) => {
const { url } = check
return (
<MenuItemButton
// The list is replaced as a whole with each snapshot, and check names can repeat.
key={index}
icon={<CheckGlyph state={check.state} />}
disabled={url === null}
onSelect={url === null ? close : () => {
close()
openLink(url)
}}
>
<span className={css.checkItem}>
<span className={css.ellipsis}>{check.name}</span>
<span className={css.checkTime}>{checkTime(check, t)}</span>
</span>
</MenuItemButton>
)
})}
</Menu>
)
}
/** The merge button and its menu: a title row, one row per method with the chosen one checked, and the confirmation. */
function MergeMenu({ snapshot, name, usual, busy, onMerge, t }: {
readonly snapshot: ChangeRequestSnapshot
readonly name: string
readonly usual: MergeMethod
readonly busy: boolean
readonly onMerge: (method: MergeMethod, headSha: string) => void
readonly t: Translate
}): ReactNode {
const [open, setOpen] = useState(false)
const [confirmedHead, setConfirmedHead] = useState(snapshot.headSha)
const [chosen, setChosen] = useState<string>(usual)
const stale = confirmedHead !== snapshot.headSha
// The methods can change between polls; a choice the repository no longer allows falls back to the usual one.
const method = snapshot.mergeMethods.find(entry => entry === chosen) ?? usual
const close = (): void => { setOpen(false) }
const base = snapshot.baseBranch
const items: MenuEntry[] = [
{ type: 'label', id: 'title', text: t('change.mergeTitle', { name, base }) },
...snapshot.mergeMethods.map(entry => ({
id: entry,
label: (
<span className={css.method}>
<span>{t(`change.mergeMethod.${entry}`)}</span>
<span className={css.methodNote}>{t(`change.mergeNote.${entry}`, { base })}</span>
</span>
),
})),
]
return (
<Menu
open={open}
onClose={close}
portal
align="end"
anchor={(
<Button
size="sm"
variant="primary"
className={clsx(css.primaryAction, css.mergeAction)}
aria-haspopup="menu"
aria-expanded={open}
disabled={busy}
onClick={() => {
if (!open) setConfirmedHead(snapshot.headSha)
setOpen(!open)
}}
>
{t('change.merge')}
<IconTriangleRightFillRegular size={10} className={clsx(css.mergeCaret, open && css.mergeCaretOpen)} />
</Button>
)}
items={items}
selectedId={method}
onSelect={setChosen}
>
<MenuItemButton
separatorBefore
disabled={stale}
onSelect={() => {
close()
onMerge(method, confirmedHead)
}}
>
{t(stale ? 'change.mergeChanged' : 'change.mergeConfirm')}
</MenuItemButton>
</Menu>
)
}
/** The footer with the action of an open change request: mark a draft ready, or merge one the host would merge now. */
function Primary({ entry, snapshot, name, actions, t }: {
readonly entry: ForgePanelChangeRequest
readonly snapshot: ChangeRequestSnapshot
readonly name: string
readonly actions: ChangeRequestActions
readonly t: Translate
}): ReactNode {
// One request at a time: the action stays disabled until the previous one settles.
const [busy, setBusy] = useState(false)
const run = (action: () => Promise<void>): void => {
setBusy(true)
void action().finally(() => { setBusy(false) })
}
const { markReady, merge } = actions
if (snapshot.state !== 'open') return null
if (snapshot.draft) {
if (markReady === undefined) return null
return (
<div className={css.footer}>
<Button size="sm" variant="outline" className={css.primaryAction} disabled={busy} onClick={() => { run(() => markReady(entry)) }}>
{t('change.markReady')}
</Button>
</div>
)
}
const [usual] = snapshot.mergeMethods
if (!snapshot.mergeReady || usual === undefined || merge === undefined) return null
return (
<div className={css.footer}>
<MergeMenu
snapshot={snapshot}
name={name}
usual={usual}
busy={busy}
onMerge={(method, headSha) => { run(() => merge(entry, { method, headSha })) }}
t={t}
/>
</div>
)
}
function Details({ entry, snapshot, name, now, actions, locale, t }: {
readonly entry: ForgePanelChangeRequest
readonly snapshot: ChangeRequestSnapshot
readonly name: string
readonly now: number
readonly actions: ChangeRequestActions
readonly locale: string
readonly t: Translate
}): ReactNode {
const changes = <ChangesRow entry={entry} snapshot={snapshot} name={name} actions={actions} locale={locale} t={t} />
if (snapshot.state !== 'open') {
return (
<div className={css.rows}>
<div className={css.row}>
<span className={css.rowIcon}><ChangeRequestGlyph tone={snapshot.state} tinted={false} size={16} /></span>
<span className={clsx(css.rowValue, css.rowWide)} data-tone={snapshot.state}>{finished(snapshot, now, t)}</span>
</div>
{changes}
</div>
)
}
const review = t(REVIEW[snapshot.reviewDecision])
const reviewTone = snapshot.reviewDecision === 'changes-requested' ? 'error' : snapshot.reviewDecision === 'approved' ? 'success' : undefined
const merge = snapshot.mergeability === 'mergeable'
? t('change.merge.mergeable')
: snapshot.mergeability === 'conflicting' ? t('change.merge.conflicting', { base: snapshot.baseBranch }) : t('change.merge.unknown')
return (
<div className={css.rows}>
<ChecksRow key={snapshot.headSha} snapshot={snapshot} openLink={actions.openLink} readChecks={actions.readChecks} t={t} />
<div className={css.row}>
<span className={css.rowIcon}><IconUserOutlineRegular size={16} /></span>
<span className={css.rowLabel}>{t('change.row.review')}</span>
<span className={css.rowValue} data-tone={reviewTone}>
{snapshot.unresolvedThreads > 0 || snapshot.threadsTruncated
? t('change.review.threads', { review, count: threadCount(snapshot, locale) }) : review}
</span>
</div>
<div className={css.row}>
<span className={css.rowIcon}><ChangeRequestGlyph tone="merged" tinted={false} size={16} /></span>
<span className={css.rowLabel}>{t('change.row.merge')}</span>
<span className={css.rowValue} data-tone={snapshot.mergeability === 'conflicting' ? 'error' : undefined}>{merge}</span>
</div>
{changes}
</div>
)
}
/**
* Render one change request.
* @param props - change request, expansion, current time, actions, and copy.
* @returns the card.
*/
export function ChangeRequestCard({ entry, expanded, onToggle, now, actions, locale, t }: ChangeRequestCardProps): ReactNode {
const snapshot = entry.snapshot
const number = numberOf(entry)
const url = snapshot?.url ?? entry.url
const failure = failureOf(entry)
const notice = entry.ref === null ? 'change.unserved' : failure === undefined ? undefined : FAILURE[failure.code]
const tone = toneOf(snapshot)
const name = number === null ? snapshot?.title ?? url : t('change.number', { number })
// A notice replaces the collapsed summary, so a failed read never shows its stale status beside it.
const meta = expanded
? snapshot === null ? null : <Target snapshot={snapshot} t={t} />
: notice === undefined ? <Summary snapshot={snapshot} now={now} t={t} /> : null
const { remove } = actions
return (
<li className={css.change} data-forge-change={entry.url} data-expanded={expanded ? '' : undefined}>
<div className={css.changeHead}>
<ChangeRequestGlyph tone={tone} size={16} label={t('change.stateAria', { state: t(TONE_LABEL[tone]) })} />
<div className={css.headText}>
<a
className={css.changeTitle}
href={url}
target="_blank"
rel="noreferrer"
onClick={(event) => {
event.preventDefault()
actions.openLink(url)
}}
>
{snapshot?.title ?? url.replace(/^https?:\/\//i, '')}
</a>
{(number !== null || meta !== null) && (
<div className={css.meta}>
{number !== null && <span>{t('change.number', { number })}</span>}
{number !== null && meta !== null && <span aria-hidden="true">·</span>}
{meta}
</div>
)}
</div>
<Tooltip label={t('change.view')} side="top">
<button type="button" className={css.iconButton} aria-label={t('change.view')} onClick={() => { actions.openLink(url) }}>
<IconRightUpOutlineRegular size={14} />
</button>
</Tooltip>
<Tooltip label={t('change.copy')} side="top">
<button type="button" className={css.iconButton} aria-label={t('change.copy')} onClick={() => { actions.copyLink(url) }}>
<IconCopyOutlineRegular size={14} />
</button>
</Tooltip>
{remove !== undefined && (
<Tooltip label={t('change.remove')} side="top">
<button type="button" className={css.iconButton} aria-label={t('change.removeAria', { name })} onClick={() => { remove(entry) }}>
<IconCloseOutlineRegular size={14} />
</button>
</Tooltip>
)}
<button
type="button"
className={css.iconButton}
aria-expanded={expanded}
aria-label={t(expanded ? 'change.collapse' : 'change.expand', { name })}
onClick={onToggle}
>
<IconChevronDownOutlineRegular size={12} className={clsx(css.chevron, expanded && css.chevronOpen)} />
</button>
</div>
{notice !== undefined && (
<div className={css.notice}>
<span>{t(notice)}</span>
{entry.ref === null
? <Button size="sm" variant="outline" onClick={() => { actions.openLink(url) }}>{t('change.view')}</Button>
: <Button size="sm" variant="outline" onClick={actions.retry}>{t('retry')}</Button>}
</div>
)}
{expanded && snapshot !== null && (
<Details entry={entry} snapshot={snapshot} name={name} now={now} actions={actions} locale={locale} t={t} />
)}
{expanded && snapshot !== null && <Primary entry={entry} snapshot={snapshot} name={name} actions={actions} t={t} />}
</li>
)
}