/** * Diagnostics tab — issue #98: renders the profile composition check report * served by the host route /dsh-market/check (see src/check.ts). Below the * report sit the phase 2/3 action panels: a community-bundle ordering block * (reorder locally with ↑/↓, POST to /dsh-market/bundle-order) and a * snapshots & rollback panel (snapshot-panel.tsx) — collapsible, default * collapsed, and lazy-fetching on first expand. The plugin presets panel * ships in a later stacked PR. * * Read-only view of the loading-layer stack and the conflict surface: bundle * order (official vs community), duplicate loader entry ids, peer dependency * mismatches, multi-version core packages, overrides and orphan patches. The * report * shape mirrors the CheckReport interface in src/check.ts; it is re-declared * here because the client bundle is built independently of the host tree. */ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react' import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDownOutline14, IconChevronRightOutline14, IconLoadingOutline16, IconRefreshOutline14, } from './icons.ts' import css from './Market.module.css' import { api } from './market-data.ts' import type { Translate } from './market-data.ts' import { PresetPanel } from './preset-panel.tsx' import { SnapshotPanel } from './snapshot-panel.tsx' /** Mirrors BundleLayer in src/check.ts. */ interface BundleLayer { name: string source: string kind: 'official' | 'community' directory: string | null patchPath: string | null error: string | null entries: string[] parseError: string | null } /** Mirrors DuplicateId in src/check.ts. */ interface DuplicateId { id: string layers: string[] count: number } /** Mirrors OverrideRow in src/check.ts. */ interface OverrideRow { id: string layer: string overriddenLayers: string[] } /** Mirrors OrphanRow in src/check.ts. */ interface OrphanRow { id: string layer: string reason: string } /** Mirrors PeerVerdict in src/check.ts. */ type PeerVerdict = | { kind: 'risk' risk: { plugin: string; peer: string; range: string; resolved: string; direction: 'belowMin' | 'aboveMax' } } | { kind: 'warning' warning: { plugin: string; peer: string; range: string; resolved: string; reason: 'aboveMax' | 'optional' } } | { kind: 'none' } /** Mirrors PeerMismatch in src/check.ts. */ interface PeerMismatch { plugin: string name: string range: string resolved: string | null satisfied: boolean | null optional?: boolean verdict?: PeerVerdict } /** Mirrors MultiVersion in src/check.ts. */ interface MultiVersion { name: string versions: string[] hoisted: string | null } /** Mirrors CheckSummary in src/check.ts. */ interface CheckSummary { ok: boolean errors: string[] warnings: string[] } /** Mirrors OrderConflict in src/order.ts (top-level orderConflicts in CheckReport). */ interface OrderConflict { name: string reason: string } /** Mirrors ResidualDirectory in src/check.ts. */ interface ResidualDirectory { name: string path: string kind: 'incomplete-package' | 'tmp-directory' declared: boolean } /** Mirrors CheckReport in src/check.ts. */ interface CheckReport { profile: string scannedAt: number bundles: BundleLayer[] duplicates: DuplicateId[] overrides: OverrideRow[] orphans: OrphanRow[] peerMismatches: PeerMismatch[] multiVersion: MultiVersion[] summary: CheckSummary /** #98 phase 2: validateOrder result for the CURRENT bundle order, when the host emits it. */ orderConflicts?: OrderConflict[] /** #98 opt: loader rows sharing one name — informational display only, never a conflict (review #109). */ duplicateNames?: Array<{ name: string; layers: string[]; count: number }> /** #98 opt: LOOT-style suggested community order satisfying every rule. */ suggestedOrder?: { ok: true; order: string[] } | { ok: false; cycle: string[] } | null /** * Leftover directories: an incomplete package (no readable `package.json`) * and pnpm's `_tmp__` staging directories (#663). Not a boot * failure — informational, and the only place that names what is on disk. */ residuals?: ResidualDirectory[] } /** * A collapsible report section: header shows title + count + chevron; the * body stays mounted (hidden via CSS when collapsed) so every block keeps * its state. ALL blocks are collapsed by default — the summary strip above * gives the overview, and a problem block's title is highlighted and its * collapsed `overview` line shows the first issue, so nothing important is * hidden. Expand a block to see its full content. */ function Section(props: { title: string count: number empty: string defaultOpen?: boolean problem?: boolean overview?: ReactNode /** Render `children` even at count 0 (e.g. a zero-mismatch peer block whose * informational disclosure must stay reachable). */ alwaysShowBody?: boolean children: ReactNode }) { const { title, count, empty, defaultOpen, problem = true, overview, alwaysShowBody = false, children } = props const [open, setOpen] = useState(defaultOpen ?? false) const alert = problem && count > 0 return (
{!open && overview !== undefined &&
{overview}
}
{count === 0 && !alwaysShowBody ?
{empty}
: children}
) } /** A collapsible section that KEEPS its children mounted (hidden via CSS when * collapsed) so the phase 3 panels below retain their loaded data and * in-progress edits across collapses. Children mount from the start, but the * panels only fetch when `open` first becomes true. */ function CollapsibleSection(props: { title: string; count?: number; open: boolean; onToggle: () => void; children: ReactNode }) { const { title, count, open, onToggle, children } = props return (
{children}
) } /** Map an orphan patch reason (src/check.ts) to a locale key for its badge. */ function orphanKindLabel(reason: string): string { if (reason === 'insert is not an array') return 'orphanInsertNotArray' if (reason === 'insert target not found') return 'orphanInsertTargetMissing' if (reason === 'insert target is not a group') return 'orphanInsertTargetNotGroup' if (reason === 'id required for non-insert patch') return 'orphanIdRequired' if (reason === 'patch target not found') return 'orphanPatchTargetMissing' if (reason.startsWith('name mismatch')) return 'orphanNameMismatch' return 'orphanReasonOther' } /** Badge label for one risk-tier peer row (#201). */ function peerRiskLabel(peer: PeerMismatch, t: Translate): string { return peer.verdict?.kind === 'risk' && peer.verdict.risk.direction === 'aboveMax' ? t('peerRiskAboveMax') : t('peerRiskBelowMin') } /** Badge label for one warning-tier peer row (optional / aboveMax / legacy). */ function peerWarningLabel(peer: PeerMismatch, t: Translate): string { if (peer.verdict?.kind !== 'warning') return t('checkUnsatisfied') return peer.verdict.warning.reason === 'optional' ? t('peerWarnOptional') : t('peerWarnAboveMax') } /** * Fetch and render the profile check report. Refetches on every mount, so * switching tabs away and back re-runs the (cheap, read-only) analysis; the * phase 3 panels below call `refresh()` after applying changes. */ export function Diagnostics(props: { t: Translate }) { const { t } = props const [report, setReport] = useState(null) const [error, setError] = useState(null) const [orderOpen, setOrderOpen] = useState(false) const [explainOpen, setExplainOpen] = useState(false) const [snapOpen, setSnapOpen] = useState(false) const [presetOpen, setPresetOpen] = useState(false) const [fixMsg, setFixMsg] = useState(null) /** The built AI-fix prompt when the clipboard path failed — rendered as a * selectable text block so the user can still copy it manually. */ const [fixFallback, setFixFallback] = useState(null) /** Bump to re-run the /dsh-market/check fetch after an order/snapshot-restore apply. */ const [version, setVersion] = useState(0) const refresh = useCallback(() => setVersion(v => v + 1), []) // --- issue #98 phase 2 (step 1): community-bundle ordering --------------- /** Community bundle names from the report, in declared order. */ const communityNames = useMemo( () => report === null ? [] : report.bundles.filter(bundle => bundle.kind === 'community').map(bundle => bundle.name), [report], ) /** Local editing state: re-synced whenever the report (re)loads. */ const [order, setOrder] = useState(communityNames) const [orderMsg, setOrderMsg] = useState(null) const [orderErr, setOrderErr] = useState(null) const [orderBusy, setOrderBusy] = useState(false) /** Current-vs-candidate composition diff from a rejected static-composition * validation (#125 review): what the candidate would change, shown as a hint. */ const [orderDiff, setOrderDiff] = useState<{ overrides: number; orphans: number; duplicates: number } | null>(null) /** * Content identity of the last community order this draft synced to. A * refresh() refetch returns a NEW array even when the order is unchanged, * so a naive `setOrder(communityNames)` effect would wipe the user's * in-progress drag/↑↓ edits on every unrelated re-check. Only resync when * the report's community order actually CHANGED (apply order / snapshot * restore) — an identical refetch keeps the draft (review M2). */ const syncedOrderRef = useRef(null) useEffect(() => { const synced = syncedOrderRef.current const same = synced !== null && synced.length === communityNames.length && communityNames.every((name, i) => name === synced[i]) if (same) return syncedOrderRef.current = communityNames setOrder(communityNames) }, [communityNames]) /** Swap one community bundle with its neighbour (-1 up, +1 down). */ const moveBundle = (index: number, delta: -1 | 1) => { setOrder(prev => { const next = [...prev] const target = index + delta if (target < 0 || target >= next.length) return prev ;[next[index], next[target]] = [next[target]!, next[index]!] return next }) } // --- drag & drop reordering (draft only — saved by 应用顺序 / Apply order) --- /** Row being dragged (index into the local `order` draft). */ const [dragIndex, setDragIndex] = useState(null) /** Row currently under the pointer, highlighted as the drop target. */ const [dragOverIndex, setDragOverIndex] = useState(null) const onRowDragStart = (index: number) => (event: DragEvent) => { if (orderBusy) { event.preventDefault() return } setDragIndex(index) event.dataTransfer?.setData?.('text/plain', order[index] ?? '') if (event.dataTransfer !== undefined) event.dataTransfer.effectAllowed = 'move' } const onRowDragOver = (index: number) => (event: DragEvent) => { if (dragIndex === null || dragIndex === index) return // preventDefault marks the row as a valid drop target (no auto-scroll). event.preventDefault() if (event.dataTransfer !== undefined) event.dataTransfer.dropEffect = 'move' setDragOverIndex(index) } const onRowDragLeave = (index: number) => () => { setDragOverIndex(prev => prev === index ? null : prev) } const onRowDrop = (index: number) => (event: DragEvent) => { event.preventDefault() const from = dragIndex setDragIndex(null) setDragOverIndex(null) if (from === null || from === index) return // Reorder the LOCAL draft only; the host is told via 应用顺序 / Apply order. setOrder(prev => { const next = [...prev] const [moved] = next.splice(from, 1) next.splice(index, 0, moved!) return next }) } const onRowDragEnd = () => { setDragIndex(null) setDragOverIndex(null) } /** POST the current community order; the host statically validates the * candidate composition (dry-run replay) before writing. */ const applyOrder = (target?: string[]) => { if (orderBusy) return setOrderBusy(true) setOrderMsg(null) setOrderErr(null) setOrderDiff(null) fetch(api('/dsh-market/bundle-order'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ order: target ?? order }), }) .then(async (res) => { const body = (await res.json().catch(() => null)) as { ok?: unknown error?: unknown trial?: { errors?: Array<{ layer?: unknown; message?: unknown }> warnings?: unknown[] diff?: { overrides?: unknown[]; orphans?: unknown[]; duplicates?: unknown[] } } } | null if (!res.ok || body?.ok !== true) { // A rejected static-composition validation (422) carries the // current-vs-candidate diff — surface what the candidate would // change as an informational hint (issue #125 review). const diff = body?.trial?.diff const overrides = diff?.overrides?.length ?? 0 const orphans = diff?.orphans?.length ?? 0 const duplicates = diff?.duplicates?.length ?? 0 setOrderDiff(overrides + orphans + duplicates > 0 ? { overrides, orphans, duplicates } : null) const firstMessage = body?.trial?.errors?.[0]?.message setOrderErr(body?.trial !== undefined ? t('orderTrialFail').replace('{0}', firstMessage !== undefined ? String(firstMessage) : '') : String(body?.error ?? `HTTP ${String(res.status)}`)) return } setOrderDiff(null) setOrderMsg(t('orderApplied')) // Refetch the report so communityNames / the ordering draft reflect // the applied order. refresh() }) .catch((err: unknown) => setOrderErr(err instanceof Error ? err.message : String(err))) .finally(() => setOrderBusy(false)) } useEffect(() => { let live = true // Do NOT null the report here: a refresh() (manual re-check, or after an // order/snapshot-restore apply) must keep the previous data visible and // must not clobber the in-progress ordering draft, which re-syncs from // communityNames only when the report actually changes (review M2). setError(null) fetch(api('/dsh-market/check'), { cache: 'no-store' }) .then(async (res) => { if (!res.ok) throw new Error(`HTTP ${String(res.status)}`) const body = (await res.json()) as CheckReport if (live) setReport(body) }) .catch((err: unknown) => { if (live) setError(err instanceof Error ? err.message : String(err)) }) return () => { live = false } }, [version]) if (error !== null) { return
{t('checkLoadFail')}{error}
} if (report === null) { return (
{t('checkLoading')}
) } const summary = report.summary const suggested = report.suggestedOrder ?? null // #201: confirmed mismatches are tiered by the server-side classifyPeer // verdict instead of the old binary `satisfied === false` split. A missing // verdict (older host) keeps the legacy behavior: confirmed = warning tier. const peerRisk = report.peerMismatches.filter(peer => peer.satisfied === false && peer.verdict?.kind === 'risk') const peerWarning = report.peerMismatches.filter(peer => peer.satisfied === false && (peer.verdict === undefined || peer.verdict.kind === 'warning')) // Classifier `none` (unparseable / * prerelease artifacts) and unevaluable // rows share the informational display tier; true rows stay satisfied. const peerInfo = report.peerMismatches.filter(peer => (peer.satisfied === false && peer.verdict?.kind === 'none') || peer.satisfied === null) const peerSatisfied = report.peerMismatches.filter(peer => peer.satisfied === true) // Category counts for the overview strip: conflicts / peer tiers / order. // Conflicts = HARD duplicate loader entries only; same-name rows are // informational and stay out of the conflict count (review #109). const catConflict = report.duplicates.length const catRisk = peerRisk.length const catWarn = peerWarning.length const catInfo = peerInfo.length const catMulti = report.multiVersion.length const catOrder = report.orderConflicts?.length ?? 0 const anyIssue = catConflict + catRisk + catWarn + catInfo + catMulti + catOrder > 0 // AI-fix only shows for HARD issues — boot errors, duplicate entries, or a // directional peer RISK. Optional peers, aboveMax without an explicit // bound, and unknown rows are warning/info and never nudge an agent into // risky changes without a clear problem (#201, conservative UX). const hasHardIssues = summary.errors.length > 0 || report.duplicates.length > 0 || catRisk > 0 /** * Build the AI-fix prompt (structured errors / duplicates / peer risks / * order conflicts + scope) and copy it to the clipboard. The user pastes it * into a new conversation and decides whether to send — the agent never runs * automatically. * (A previous auto-open/prefill attempt was dropped: it was unreliable * across host versions, so plain copy + toast is the contract.) */ const startAgentFix = () => { const lines: string[] = [] lines.push(t('aiFixIntro').replace('{0}', report.profile)) lines.push('') // Self-identification guard FIRST: the prompt must make the receiving // agent detect whether it is itself the harness running this profile. // If it is, mutating the live composition — or upgrading/restarting the // harness or core packages, or reinstalling deps — would kill the very // session doing the fix, so those actions are forbidden and only a plan // to run externally is produced. When the agent is NOT the target harness // (a separate process), the conservative in-place scope below applies. lines.push(t('aiFixDetect')) lines.push('') lines.push(t('aiFixIfSelf')) lines.push('') // Structured prompt only (#201): boot errors, duplicate loader ids and // directional peer RISK rows. summary.warnings is no longer dumped — its // peer lines contain the whole 30+ warning/info noise this issue untangles. const errorLines = summary.errors.filter(line => !line.startsWith('duplicate loader entry id')) if (errorLines.length > 0) { lines.push(`${t('checkErrors')}:`) for (const e of errorLines) lines.push(`- ${e}`) lines.push('') } if (report.duplicates.length > 0) { lines.push(`${t('checkDuplicates')}:`) for (const dup of report.duplicates) lines.push(`- ${dup.id} × ${dup.count} (${dup.layers.join(' / ')})`) lines.push('') } if (peerRisk.length > 0) { lines.push(`${t('checkPeerRisk')}:`) for (const peer of peerRisk) { const direction = peer.verdict?.kind === 'risk' && peer.verdict.risk.direction === 'aboveMax' ? t('peerRiskAboveMax') : t('peerRiskBelowMin') lines.push(`- ${peer.plugin} → ${peer.name}@${peer.range} (${t('checkResolved')}: ${peer.resolved ?? '—'}, ${direction})`) } lines.push('') } if ((report.orderConflicts ?? []).length > 0) { lines.push(`${t('catOrder')}:`) for (const c of report.orderConflicts ?? []) lines.push(`- ${c.name}: ${c.reason}`) lines.push('') } lines.push(t('aiFixScope')) lines.push('') lines.push(t('aiFixConservative')) const prompt = lines.join('\n') // Clipboard-first; on any failure (missing API or a rejected promise) show // the prompt in a selectable block so the user can still copy it by hand — // a bare "clipboard unavailable" message left nothing to copy. setFixMsg(null) setFixFallback(null) const fallback = () => setFixFallback(prompt) if (typeof navigator.clipboard?.writeText === 'function') { navigator.clipboard.writeText(prompt) .then(() => setFixMsg(t('aiFixCopied'))) .catch(fallback) } else { fallback() } } return (
{summary.ok ? (anyIssue ? t('checkIssues') : t('diagOkAll')) : t('checkIssues')} {t('catConflict')}: {catConflict} {t('catRisk')}: {catRisk} {t('catWarn')}: {catWarn} {t('catInfo')}: {catInfo} {t('checkMultiVersion')}: {catMulti} {t('catOrder')}: {catOrder} {hasHardIssues && ( )} {t('checkProfile')}: {report.profile} {new Date(report.scannedAt).toLocaleString()}
{fixMsg !== null &&
{fixMsg}
} {fixFallback !== null && (

{t('aiFixFail')}