@chatcode/cco-market 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
@@ -0,0 +1,907 @@
1
+ /**
2
+ * Diagnostics tab — issue #98: renders the profile composition check report
3
+ * served by the host route /dsh-market/check (see src/check.ts). Below the
4
+ * report sit the phase 2/3 action panels: a community-bundle ordering block
5
+ * (reorder locally with ↑/↓, POST to /dsh-market/bundle-order) and a
6
+ * snapshots & rollback panel (snapshot-panel.tsx) — collapsible, default
7
+ * collapsed, and lazy-fetching on first expand. The plugin presets panel
8
+ * ships in a later stacked PR.
9
+ *
10
+ * Read-only view of the loading-layer stack and the conflict surface: bundle
11
+ * order (official vs community), duplicate loader entry ids, peer dependency
12
+ * mismatches, multi-version core packages, overrides and orphan patches. The
13
+ * report
14
+ * shape mirrors the CheckReport interface in src/check.ts; it is re-declared
15
+ * here because the client bundle is built independently of the host tree.
16
+ */
17
+ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
18
+ import { Button, IconChevronDownOutline14, IconChevronRightOutline14, IconLoadingOutline16, IconRefreshOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
19
+ import css from './Market.module.css'
20
+ import { api } from './market-data.ts'
21
+ import type { Translate } from './market-data.ts'
22
+ import { PresetPanel } from './preset-panel.tsx'
23
+ import { SnapshotPanel } from './snapshot-panel.tsx'
24
+
25
+ /** Mirrors BundleLayer in src/check.ts. */
26
+ interface BundleLayer {
27
+ name: string
28
+ source: string
29
+ kind: 'official' | 'community'
30
+ directory: string | null
31
+ patchPath: string | null
32
+ error: string | null
33
+ entries: string[]
34
+ parseError: string | null
35
+ }
36
+
37
+ /** Mirrors DuplicateId in src/check.ts. */
38
+ interface DuplicateId {
39
+ id: string
40
+ layers: string[]
41
+ count: number
42
+ }
43
+
44
+ /** Mirrors OverrideRow in src/check.ts. */
45
+ interface OverrideRow {
46
+ id: string
47
+ layer: string
48
+ overriddenLayers: string[]
49
+ }
50
+
51
+ /** Mirrors OrphanRow in src/check.ts. */
52
+ interface OrphanRow {
53
+ id: string
54
+ layer: string
55
+ reason: string
56
+ }
57
+
58
+ /** Mirrors PeerVerdict in src/check.ts. */
59
+ type PeerVerdict =
60
+ | {
61
+ kind: 'risk'
62
+ risk: { plugin: string; peer: string; range: string; resolved: string; direction: 'belowMin' | 'aboveMax' }
63
+ }
64
+ | {
65
+ kind: 'warning'
66
+ warning: { plugin: string; peer: string; range: string; resolved: string; reason: 'aboveMax' | 'optional' }
67
+ }
68
+ | { kind: 'none' }
69
+
70
+ /** Mirrors PeerMismatch in src/check.ts. */
71
+ interface PeerMismatch {
72
+ plugin: string
73
+ name: string
74
+ range: string
75
+ resolved: string | null
76
+ satisfied: boolean | null
77
+ optional?: boolean
78
+ verdict?: PeerVerdict
79
+ }
80
+
81
+ /** Mirrors MultiVersion in src/check.ts. */
82
+ interface MultiVersion {
83
+ name: string
84
+ versions: string[]
85
+ hoisted: string | null
86
+ }
87
+
88
+ /** Mirrors CheckSummary in src/check.ts. */
89
+ interface CheckSummary {
90
+ ok: boolean
91
+ errors: string[]
92
+ warnings: string[]
93
+ }
94
+
95
+ /** Mirrors OrderConflict in src/order.ts (top-level orderConflicts in CheckReport). */
96
+ interface OrderConflict {
97
+ name: string
98
+ reason: string
99
+ }
100
+
101
+ /** Mirrors CheckReport in src/check.ts. */
102
+ interface CheckReport {
103
+ profile: string
104
+ scannedAt: number
105
+ bundles: BundleLayer[]
106
+ duplicates: DuplicateId[]
107
+ overrides: OverrideRow[]
108
+ orphans: OrphanRow[]
109
+ peerMismatches: PeerMismatch[]
110
+ multiVersion: MultiVersion[]
111
+ summary: CheckSummary
112
+ /** #98 phase 2: validateOrder result for the CURRENT bundle order, when the host emits it. */
113
+ orderConflicts?: OrderConflict[]
114
+ /** #98 opt: loader rows sharing one name — informational display only, never a conflict (review #109). */
115
+ duplicateNames?: Array<{ name: string; layers: string[]; count: number }>
116
+ /** #98 opt: LOOT-style suggested community order satisfying every rule. */
117
+ suggestedOrder?: { ok: true; order: string[] } | { ok: false; cycle: string[] } | null
118
+ }
119
+
120
+ /**
121
+ * A collapsible report section: header shows title + count + chevron; the
122
+ * body stays mounted (hidden via CSS when collapsed) so every block keeps
123
+ * its state. ALL blocks are collapsed by default — the summary strip above
124
+ * gives the overview, and a problem block's title is highlighted and its
125
+ * collapsed `overview` line shows the first issue, so nothing important is
126
+ * hidden. Expand a block to see its full content.
127
+ */
128
+ function Section(props: {
129
+ title: string
130
+ count: number
131
+ empty: string
132
+ defaultOpen?: boolean
133
+ problem?: boolean
134
+ overview?: ReactNode
135
+ /** Render `children` even at count 0 (e.g. a zero-mismatch peer block whose
136
+ * informational disclosure must stay reachable). */
137
+ alwaysShowBody?: boolean
138
+ children: ReactNode
139
+ }) {
140
+ const { title, count, empty, defaultOpen, problem = true, overview, alwaysShowBody = false, children } = props
141
+ const [open, setOpen] = useState(defaultOpen ?? false)
142
+ const alert = problem && count > 0
143
+ return (
144
+ <section className={css.diagSection}>
145
+ <button type="button" className={css.collapseHead} onClick={() => setOpen(o => !o)} aria-expanded={open}>
146
+ <span className={css.collapseIcon}>
147
+ {open ? <IconChevronDownOutline14 size={14} /> : <IconChevronRightOutline14 size={14} />}
148
+ </span>
149
+ {alert && <span className={css.diagAlert}>⚠</span>}
150
+ <span className={`${css.collapseTitle}${alert ? ` ${css.diagAlert}` : ''}`}>{title}</span>
151
+ <span className={css.diagCount}>({count})</span>
152
+ <span className={css.grow} />
153
+ </button>
154
+ {!open && overview !== undefined && <div className={css.sectionOverview}>{overview}</div>}
155
+ <div className={css.collapseBody} style={open ? undefined : { display: 'none' }}>
156
+ {count === 0 && !alwaysShowBody ? <div className={css.diagEmpty}>{empty}</div> : children}
157
+ </div>
158
+ </section>
159
+ )
160
+ }
161
+
162
+ /** A collapsible section that KEEPS its children mounted (hidden via CSS when
163
+ * collapsed) so the phase 3 panels below retain their loaded data and
164
+ * in-progress edits across collapses. Children mount from the start, but the
165
+ * panels only fetch when `open` first becomes true.
166
+ */
167
+ function CollapsibleSection(props: { title: string; count?: number; open: boolean; onToggle: () => void; children: ReactNode }) {
168
+ const { title, count, open, onToggle, children } = props
169
+ return (
170
+ <section className={css.diagSection}>
171
+ <button type="button" className={css.collapseHead} onClick={onToggle} aria-expanded={open}>
172
+ <span className={css.collapseIcon}>
173
+ {open ? <IconChevronDownOutline14 size={14} /> : <IconChevronRightOutline14 size={14} />}
174
+ </span>
175
+ <span className={css.collapseTitle}>{title}</span>
176
+ {count !== undefined && <span className={css.diagCount}>({count})</span>}
177
+ <span className={css.grow} />
178
+ </button>
179
+ <div className={css.collapseBody} style={open ? undefined : { display: 'none' }}>
180
+ {children}
181
+ </div>
182
+ </section>
183
+ )
184
+ }
185
+ /** Map an orphan patch reason (src/check.ts) to a locale key for its badge. */
186
+ function orphanKindLabel(reason: string): string {
187
+ if (reason === 'insert is not an array') return 'orphanInsertNotArray'
188
+ if (reason === 'insert target not found') return 'orphanInsertTargetMissing'
189
+ if (reason === 'insert target is not a group') return 'orphanInsertTargetNotGroup'
190
+ if (reason === 'id required for non-insert patch') return 'orphanIdRequired'
191
+ if (reason === 'patch target not found') return 'orphanPatchTargetMissing'
192
+ if (reason.startsWith('name mismatch')) return 'orphanNameMismatch'
193
+ return 'orphanReasonOther'
194
+ }
195
+
196
+ /** Badge label for one risk-tier peer row (#201). */
197
+ function peerRiskLabel(peer: PeerMismatch, t: Translate): string {
198
+ return peer.verdict?.kind === 'risk' && peer.verdict.risk.direction === 'aboveMax'
199
+ ? t('peerRiskAboveMax')
200
+ : t('peerRiskBelowMin')
201
+ }
202
+
203
+ /** Badge label for one warning-tier peer row (optional / aboveMax / legacy). */
204
+ function peerWarningLabel(peer: PeerMismatch, t: Translate): string {
205
+ if (peer.verdict?.kind !== 'warning') return t('checkUnsatisfied')
206
+ return peer.verdict.warning.reason === 'optional' ? t('peerWarnOptional') : t('peerWarnAboveMax')
207
+ }
208
+
209
+ /**
210
+ * Fetch and render the profile check report. Refetches on every mount, so
211
+ * switching tabs away and back re-runs the (cheap, read-only) analysis; the
212
+ * phase 3 panels below call `refresh()` after applying changes.
213
+ */
214
+ export function Diagnostics(props: { t: Translate }) {
215
+ const { t } = props
216
+ const [report, setReport] = useState<CheckReport | null>(null)
217
+ const [error, setError] = useState<string | null>(null)
218
+ const [orderOpen, setOrderOpen] = useState(false)
219
+ const [explainOpen, setExplainOpen] = useState(false)
220
+ const [snapOpen, setSnapOpen] = useState(false)
221
+ const [presetOpen, setPresetOpen] = useState(false)
222
+ const [fixMsg, setFixMsg] = useState<string | null>(null)
223
+ /** The built AI-fix prompt when the clipboard path failed — rendered as a
224
+ * selectable text block so the user can still copy it manually. */
225
+ const [fixFallback, setFixFallback] = useState<string | null>(null)
226
+ /** Bump to re-run the /dsh-market/check fetch after an order/snapshot-restore apply. */
227
+ const [version, setVersion] = useState(0)
228
+ const refresh = useCallback(() => setVersion(v => v + 1), [])
229
+
230
+ // --- issue #98 phase 2 (step 1): community-bundle ordering ---------------
231
+ /** Community bundle names from the report, in declared order. */
232
+ const communityNames = useMemo(
233
+ () => report === null ? [] : report.bundles.filter(bundle => bundle.kind === 'community').map(bundle => bundle.name),
234
+ [report],
235
+ )
236
+ /** Local editing state: re-synced whenever the report (re)loads. */
237
+ const [order, setOrder] = useState<string[]>(communityNames)
238
+ const [orderMsg, setOrderMsg] = useState<string | null>(null)
239
+ const [orderErr, setOrderErr] = useState<string | null>(null)
240
+ const [orderBusy, setOrderBusy] = useState(false)
241
+ /** Current-vs-candidate composition diff from a rejected static-composition
242
+ * validation (#125 review): what the candidate would change, shown as a hint. */
243
+ const [orderDiff, setOrderDiff] = useState<{ overrides: number; orphans: number; duplicates: number } | null>(null)
244
+ /**
245
+ * Content identity of the last community order this draft synced to. A
246
+ * refresh() refetch returns a NEW array even when the order is unchanged,
247
+ * so a naive `setOrder(communityNames)` effect would wipe the user's
248
+ * in-progress drag/↑↓ edits on every unrelated re-check. Only resync when
249
+ * the report's community order actually CHANGED (apply order / snapshot
250
+ * restore) — an identical refetch keeps the draft (review M2).
251
+ */
252
+ const syncedOrderRef = useRef<string[] | null>(null)
253
+ useEffect(() => {
254
+ const synced = syncedOrderRef.current
255
+ const same = synced !== null
256
+ && synced.length === communityNames.length
257
+ && communityNames.every((name, i) => name === synced[i])
258
+ if (same) return
259
+ syncedOrderRef.current = communityNames
260
+ setOrder(communityNames)
261
+ }, [communityNames])
262
+
263
+ /** Swap one community bundle with its neighbour (-1 up, +1 down). */
264
+ const moveBundle = (index: number, delta: -1 | 1) => {
265
+ setOrder(prev => {
266
+ const next = [...prev]
267
+ const target = index + delta
268
+ if (target < 0 || target >= next.length) return prev
269
+ ;[next[index], next[target]] = [next[target]!, next[index]!]
270
+ return next
271
+ })
272
+ }
273
+
274
+ // --- drag & drop reordering (draft only — saved by 应用顺序 / Apply order) ---
275
+ /** Row being dragged (index into the local `order` draft). */
276
+ const [dragIndex, setDragIndex] = useState<number | null>(null)
277
+ /** Row currently under the pointer, highlighted as the drop target. */
278
+ const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
279
+
280
+ const onRowDragStart = (index: number) => (event: DragEvent<HTMLDivElement>) => {
281
+ if (orderBusy) {
282
+ event.preventDefault()
283
+ return
284
+ }
285
+ setDragIndex(index)
286
+ event.dataTransfer?.setData?.('text/plain', order[index] ?? '')
287
+ if (event.dataTransfer !== undefined) event.dataTransfer.effectAllowed = 'move'
288
+ }
289
+
290
+ const onRowDragOver = (index: number) => (event: DragEvent<HTMLDivElement>) => {
291
+ if (dragIndex === null || dragIndex === index) return
292
+ // preventDefault marks the row as a valid drop target (no auto-scroll).
293
+ event.preventDefault()
294
+ if (event.dataTransfer !== undefined) event.dataTransfer.dropEffect = 'move'
295
+ setDragOverIndex(index)
296
+ }
297
+
298
+ const onRowDragLeave = (index: number) => () => {
299
+ setDragOverIndex(prev => prev === index ? null : prev)
300
+ }
301
+
302
+ const onRowDrop = (index: number) => (event: DragEvent<HTMLDivElement>) => {
303
+ event.preventDefault()
304
+ const from = dragIndex
305
+ setDragIndex(null)
306
+ setDragOverIndex(null)
307
+ if (from === null || from === index) return
308
+ // Reorder the LOCAL draft only; the host is told via 应用顺序 / Apply order.
309
+ setOrder(prev => {
310
+ const next = [...prev]
311
+ const [moved] = next.splice(from, 1)
312
+ next.splice(index, 0, moved!)
313
+ return next
314
+ })
315
+ }
316
+
317
+ const onRowDragEnd = () => {
318
+ setDragIndex(null)
319
+ setDragOverIndex(null)
320
+ }
321
+
322
+ /** POST the current community order; the host statically validates the
323
+ * candidate composition (dry-run replay) before writing. */
324
+ const applyOrder = (target?: string[]) => {
325
+ if (orderBusy) return
326
+ setOrderBusy(true)
327
+ setOrderMsg(null)
328
+ setOrderErr(null)
329
+ setOrderDiff(null)
330
+ fetch(api('/dsh-market/bundle-order'), {
331
+ method: 'POST',
332
+ headers: { 'content-type': 'application/json' },
333
+ body: JSON.stringify({ order: target ?? order }),
334
+ })
335
+ .then(async (res) => {
336
+ const body = (await res.json().catch(() => null)) as {
337
+ ok?: unknown
338
+ error?: unknown
339
+ trial?: {
340
+ errors?: Array<{ layer?: unknown; message?: unknown }>
341
+ warnings?: unknown[]
342
+ diff?: { overrides?: unknown[]; orphans?: unknown[]; duplicates?: unknown[] }
343
+ }
344
+ } | null
345
+ if (!res.ok || body?.ok !== true) {
346
+ // A rejected static-composition validation (422) carries the
347
+ // current-vs-candidate diff — surface what the candidate would
348
+ // change as an informational hint (issue #125 review).
349
+ const diff = body?.trial?.diff
350
+ const overrides = diff?.overrides?.length ?? 0
351
+ const orphans = diff?.orphans?.length ?? 0
352
+ const duplicates = diff?.duplicates?.length ?? 0
353
+ setOrderDiff(overrides + orphans + duplicates > 0 ? { overrides, orphans, duplicates } : null)
354
+ const firstMessage = body?.trial?.errors?.[0]?.message
355
+ setOrderErr(body?.trial !== undefined
356
+ ? t('orderTrialFail').replace('{0}', firstMessage !== undefined ? String(firstMessage) : '')
357
+ : String(body?.error ?? `HTTP ${String(res.status)}`))
358
+ return
359
+ }
360
+ setOrderDiff(null)
361
+ setOrderMsg(t('orderApplied'))
362
+ // Refetch the report so communityNames / the ordering draft reflect
363
+ // the applied order.
364
+ refresh()
365
+ })
366
+ .catch((err: unknown) => setOrderErr(err instanceof Error ? err.message : String(err)))
367
+ .finally(() => setOrderBusy(false))
368
+ }
369
+
370
+ useEffect(() => {
371
+ let live = true
372
+ // Do NOT null the report here: a refresh() (manual re-check, or after an
373
+ // order/snapshot-restore apply) must keep the previous data visible and
374
+ // must not clobber the in-progress ordering draft, which re-syncs from
375
+ // communityNames only when the report actually changes (review M2).
376
+ setError(null)
377
+ fetch(api('/dsh-market/check'), { cache: 'no-store' })
378
+ .then(async (res) => {
379
+ if (!res.ok) throw new Error(`HTTP ${String(res.status)}`)
380
+ const body = (await res.json()) as CheckReport
381
+ if (live) setReport(body)
382
+ })
383
+ .catch((err: unknown) => {
384
+ if (live) setError(err instanceof Error ? err.message : String(err))
385
+ })
386
+ return () => { live = false }
387
+ }, [version])
388
+
389
+ if (error !== null) {
390
+ return <div className={css.err}>{t('checkLoadFail')}{error}</div>
391
+ }
392
+ if (report === null) {
393
+ return (
394
+ <div className={css.loading}>
395
+ <span className={css.spin}><IconLoadingOutline16 size={22} /></span>
396
+ {t('checkLoading')}
397
+ </div>
398
+ )
399
+ }
400
+
401
+ const summary = report.summary
402
+ const suggested = report.suggestedOrder ?? null
403
+ // #201: confirmed mismatches are tiered by the server-side classifyPeer
404
+ // verdict instead of the old binary `satisfied === false` split. A missing
405
+ // verdict (older host) keeps the legacy behavior: confirmed = warning tier.
406
+ const peerRisk = report.peerMismatches.filter(peer => peer.satisfied === false && peer.verdict?.kind === 'risk')
407
+ const peerWarning = report.peerMismatches.filter(peer => peer.satisfied === false && (peer.verdict === undefined || peer.verdict.kind === 'warning'))
408
+ // Classifier `none` (unparseable / * prerelease artifacts) and unevaluable
409
+ // rows share the informational display tier; true rows stay satisfied.
410
+ const peerInfo = report.peerMismatches.filter(peer =>
411
+ (peer.satisfied === false && peer.verdict?.kind === 'none') || peer.satisfied === null)
412
+ const peerSatisfied = report.peerMismatches.filter(peer => peer.satisfied === true)
413
+ // Category counts for the overview strip: conflicts / peer tiers / order.
414
+ // Conflicts = HARD duplicate loader entries only; same-name rows are
415
+ // informational and stay out of the conflict count (review #109).
416
+ const catConflict = report.duplicates.length
417
+ const catRisk = peerRisk.length
418
+ const catWarn = peerWarning.length
419
+ const catInfo = peerInfo.length
420
+ const catMulti = report.multiVersion.length
421
+ const catOrder = report.orderConflicts?.length ?? 0
422
+ const anyIssue = catConflict + catRisk + catWarn + catInfo + catMulti + catOrder > 0
423
+ // AI-fix only shows for HARD issues — boot errors, duplicate entries, or a
424
+ // directional peer RISK. Optional peers, aboveMax without an explicit
425
+ // bound, and unknown rows are warning/info and never nudge an agent into
426
+ // risky changes without a clear problem (#201, conservative UX).
427
+ const hasHardIssues = summary.errors.length > 0
428
+ || report.duplicates.length > 0
429
+ || catRisk > 0
430
+
431
+ /**
432
+ * Build the AI-fix prompt (structured errors / duplicates / peer risks /
433
+ * order conflicts + scope) and copy it to the clipboard. The user pastes it
434
+ * into a new conversation and decides whether to send — the agent never runs
435
+ * automatically.
436
+ * (A previous auto-open/prefill attempt was dropped: it was unreliable
437
+ * across host versions, so plain copy + toast is the contract.)
438
+ */
439
+ const startAgentFix = () => {
440
+ const lines: string[] = []
441
+ lines.push(t('aiFixIntro').replace('{0}', report.profile))
442
+ lines.push('')
443
+ // Self-identification guard FIRST: the prompt must make the receiving
444
+ // agent detect whether it is itself the harness running this profile.
445
+ // If it is, mutating the live composition — or upgrading/restarting the
446
+ // harness or core packages, or reinstalling deps — would kill the very
447
+ // session doing the fix, so those actions are forbidden and only a plan
448
+ // to run externally is produced. When the agent is NOT the target harness
449
+ // (a separate process), the conservative in-place scope below applies.
450
+ lines.push(t('aiFixDetect'))
451
+ lines.push('')
452
+ lines.push(t('aiFixIfSelf'))
453
+ lines.push('')
454
+ // Structured prompt only (#201): boot errors, duplicate loader ids and
455
+ // directional peer RISK rows. summary.warnings is no longer dumped — its
456
+ // peer lines contain the whole 30+ warning/info noise this issue untangles.
457
+ const errorLines = summary.errors.filter(line => !line.startsWith('duplicate loader entry id'))
458
+ if (errorLines.length > 0) {
459
+ lines.push(`${t('checkErrors')}:`)
460
+ for (const e of errorLines) lines.push(`- ${e}`)
461
+ lines.push('')
462
+ }
463
+ if (report.duplicates.length > 0) {
464
+ lines.push(`${t('checkDuplicates')}:`)
465
+ for (const dup of report.duplicates) lines.push(`- ${dup.id} × ${dup.count} (${dup.layers.join(' / ')})`)
466
+ lines.push('')
467
+ }
468
+ if (peerRisk.length > 0) {
469
+ lines.push(`${t('checkPeerRisk')}:`)
470
+ for (const peer of peerRisk) {
471
+ const direction = peer.verdict?.kind === 'risk' && peer.verdict.risk.direction === 'aboveMax'
472
+ ? t('peerRiskAboveMax')
473
+ : t('peerRiskBelowMin')
474
+ lines.push(`- ${peer.plugin} → ${peer.name}@${peer.range} (${t('checkResolved')}: ${peer.resolved ?? '—'}, ${direction})`)
475
+ }
476
+ lines.push('')
477
+ }
478
+ if ((report.orderConflicts ?? []).length > 0) {
479
+ lines.push(`${t('catOrder')}:`)
480
+ for (const c of report.orderConflicts ?? []) lines.push(`- ${c.name}: ${c.reason}`)
481
+ lines.push('')
482
+ }
483
+ lines.push(t('aiFixScope'))
484
+ lines.push('')
485
+ lines.push(t('aiFixConservative'))
486
+ const prompt = lines.join('\n')
487
+
488
+ // Clipboard-first; on any failure (missing API or a rejected promise) show
489
+ // the prompt in a selectable block so the user can still copy it by hand —
490
+ // a bare "clipboard unavailable" message left nothing to copy.
491
+ setFixMsg(null)
492
+ setFixFallback(null)
493
+ const fallback = () => setFixFallback(prompt)
494
+ if (typeof navigator.clipboard?.writeText === 'function') {
495
+ navigator.clipboard.writeText(prompt)
496
+ .then(() => setFixMsg(t('aiFixCopied')))
497
+ .catch(fallback)
498
+ } else {
499
+ fallback()
500
+ }
501
+ }
502
+
503
+ return (
504
+ <div className={css.diagPage}>
505
+ <div className={css.diagSummary}>
506
+ <span className={summary.ok ? css.okState : css.err}>
507
+ <StateDot state={summary.ok ? 'done' : 'error'} size={8} />
508
+ {summary.ok ? (anyIssue ? t('checkIssues') : t('diagOkAll')) : t('checkIssues')}
509
+ </span>
510
+ <span className={css.diagSummaryItem} title={t('checkDuplicates')}>
511
+ <StateDot state="error" size={8} />{t('catConflict')}: {catConflict}
512
+ </span>
513
+ <span className={css.diagSummaryItem} title={t('checkPeerRisk')}>
514
+ <StateDot state="error" size={8} />{t('catRisk')}: {catRisk}
515
+ </span>
516
+ <span className={css.diagSummaryItem} title={t('checkPeerWarning')}>
517
+ <StateDot state="warning" size={8} />{t('catWarn')}: {catWarn}
518
+ </span>
519
+ <span className={css.diagSummaryItem} title={t('checkPeerInfoTier')}>
520
+ <StateDot state="ongoing" size={8} />{t('catInfo')}: {catInfo}
521
+ </span>
522
+ <span className={css.diagSummaryItem} title={t('checkMultiVersion')}>
523
+ <StateDot state="warning" size={8} />{t('checkMultiVersion')}: {catMulti}
524
+ </span>
525
+ <span className={css.diagSummaryItem} title={t('checkOrderTip')}>
526
+ <StateDot state="warning" size={8} />{t('catOrder')}: {catOrder}
527
+ </span>
528
+ <span className={css.grow} />
529
+ {hasHardIssues && (
530
+ <Button variant="outline" size="sm" onClick={startAgentFix} title={t('aiFixHint')}>
531
+ {t('aiFix')}
532
+ </Button>
533
+ )}
534
+ <Button variant="ghost" size="sm" aria-label={t('checkRefresh')} onClick={refresh}>
535
+ <IconRefreshOutline14 size={14} />
536
+ </Button>
537
+ <span className={css.diagSummaryMeta} title={report.profile}>{t('checkProfile')}: {report.profile}</span>
538
+ <span className={css.diagSummaryMeta}>{new Date(report.scannedAt).toLocaleString()}</span>
539
+ </div>
540
+ {fixMsg !== null && <div className={css.okState}>{fixMsg}</div>}
541
+ {fixFallback !== null && (
542
+ <div className={css.fixFallback}>
543
+ <p className={css.panelNote}>{t('aiFixFail')}</p>
544
+ <textarea
545
+ readOnly
546
+ rows={10}
547
+ className={css.fixFallbackText}
548
+ value={fixFallback}
549
+ onFocus={e => e.currentTarget.select()}
550
+ />
551
+ </div>
552
+ )}
553
+
554
+ <CollapsibleSection title={t('diagExplain')} open={explainOpen} onToggle={() => setExplainOpen(o => !o)}>
555
+ <p className={css.panelNote}>{t('diagExplainText')}</p>
556
+ <div className={css.diagList}>
557
+ <div className={css.spec}>{t('diagTermBundle')}</div>
558
+ <div className={css.spec}>{t('diagTermEntry')}</div>
559
+ <div className={css.spec}>{t('diagTermPeer')}</div>
560
+ <div className={css.spec}>{t('diagTermShadow')}</div>
561
+ <div className={css.spec}>{t('diagTermOrphan')}</div>
562
+ <div className={css.spec}>{t('diagTermOrder')}</div>
563
+ </div>
564
+ </CollapsibleSection>
565
+
566
+ <Section
567
+ title={t('checkErrors')}
568
+ count={summary.errors.length}
569
+ empty={t('checkErrorsEmpty')}
570
+ overview={summary.errors.length > 0 ? summary.errors[0] : undefined}
571
+ >
572
+ <div className={css.diagList}>
573
+ {summary.errors.map((line, i) => (
574
+ <div key={i} className={css.err}>{line}</div>
575
+ ))}
576
+ </div>
577
+ </Section>
578
+
579
+ <Section
580
+ title={t('checkWarnings')}
581
+ count={summary.warnings.length}
582
+ empty={t('checkWarningsEmpty')}
583
+ overview={summary.warnings.length > 0 ? summary.warnings[0] : undefined}
584
+ >
585
+ <div className={css.diagList}>
586
+ {summary.warnings.map((line, i) => (
587
+ <div key={i} className={css.warnLine}><span>{line}</span></div>
588
+ ))}
589
+ </div>
590
+ </Section>
591
+
592
+ <Section
593
+ title={t('checkBundles')}
594
+ count={report.bundles.length}
595
+ empty={t('checkBundlesEmpty')}
596
+ problem={false}
597
+ overview={
598
+ <span>
599
+ {t('checkOfficial')} × {report.bundles.filter(b => b.kind === 'official').length}
600
+ {' · '}
601
+ {t('checkCommunity')} × {report.bundles.filter(b => b.kind === 'community').length}
602
+ </span>
603
+ }
604
+ >
605
+ {report.bundles.map((bundle, i) => (
606
+ <div key={bundle.name} className={css.diagBundle}>
607
+ <div className={css.diagRow}>
608
+ <span className={css.diagIndex}>{i + 1}</span>
609
+ <span className={css.diagArrow}>→</span>
610
+ <span className={css.nm}>{bundle.name}</span>
611
+ <span className={bundle.kind === 'official' ? css.diagBadgeOfficial : css.diagBadgeCommunity}>
612
+ {bundle.kind === 'official' ? t('checkOfficial') : t('checkCommunity')}
613
+ </span>
614
+ {bundle.error !== null && <span className={css.err}>{bundle.error}</span>}
615
+ {bundle.parseError !== null && <span className={css.err}>{t('checkPatch')}: {bundle.parseError}</span>}
616
+ </div>
617
+ <div className={css.diagMeta}>
618
+ <span className={css.diagKey}>{t('checkSource')}</span>
619
+ <code className={css.spec}>{bundle.source}</code>
620
+ </div>
621
+ <div className={css.diagMeta}>
622
+ <span className={css.diagKey}>{t('checkEntries')}</span>
623
+ <code className={css.spec}>{bundle.entries.length > 0 ? bundle.entries.join(', ') : '—'}</code>
624
+ </div>
625
+ {bundle.directory !== null && (
626
+ <div className={css.diagMeta}>
627
+ <span className={css.diagKey}>{t('checkDir')}</span>
628
+ <code className={css.spec}>{bundle.directory}</code>
629
+ </div>
630
+ )}
631
+ {bundle.patchPath !== null && (
632
+ <div className={css.diagMeta}>
633
+ <span className={css.diagKey}>{t('checkPatch')}</span>
634
+ <code className={css.spec}>{bundle.patchPath}</code>
635
+ </div>
636
+ )}
637
+ </div>
638
+ ))}
639
+ </Section>
640
+
641
+ <Section
642
+ title={t('checkDuplicates')}
643
+ count={report.duplicates.length}
644
+ empty={t('checkDuplicatesEmpty')}
645
+ overview={report.duplicates.length > 0 ? `${report.duplicates[0]?.id} × ${report.duplicates[0]?.count}` : undefined}
646
+ >
647
+ <div className={css.diagList}>
648
+ {report.duplicates.map(dup => (
649
+ <div key={dup.id} className={css.diagRow}>
650
+ <code className={css.diagVal}>{dup.id}</code>
651
+ <span className={css.err}>× {dup.count}</span>
652
+ <span className={css.spec}>{dup.layers.join(' / ')}</span>
653
+ </div>
654
+ ))}
655
+ </div>
656
+ </Section>
657
+
658
+ <Section
659
+ title={t('checkPeerRisk')}
660
+ count={peerRisk.length}
661
+ empty={t('checkPeerRiskEmpty')}
662
+ overview={peerRisk.length > 0
663
+ ? `${peerRisk[0].plugin} → ${peerRisk[0].name}@${peerRisk[0].range}(${t('checkResolved')}: ${peerRisk[0].resolved ?? '—'})`
664
+ : undefined}
665
+ >
666
+ <div className={css.diagList}>
667
+ {peerRisk.map((peer, i) => (
668
+ <div key={i} className={css.diagRow}>
669
+ <code className={css.diagVal}>{peer.name}</code>
670
+ <span className={css.nm}>{peer.plugin}</span>
671
+ <span className={css.spec}>{t('checkRange')}: {peer.range}</span>
672
+ <span className={css.spec}>{t('checkResolved')}: {peer.resolved ?? '—'}</span>
673
+ <span className={css.diagBadgeShadow}>{peerRiskLabel(peer, t)}</span>
674
+ </div>
675
+ ))}
676
+ </div>
677
+ </Section>
678
+
679
+ <Section
680
+ title={t('checkPeerWarning')}
681
+ count={peerWarning.length}
682
+ empty={t('checkPeerWarningEmpty')}
683
+ overview={peerWarning.length > 0
684
+ ? `${peerWarning[0].plugin} → ${peerWarning[0].name}@${peerWarning[0].range}(${peerWarningLabel(peerWarning[0], t)})`
685
+ : undefined}
686
+ >
687
+ <div className={css.diagList}>
688
+ {peerWarning.map((peer, i) => (
689
+ <div key={i} className={css.diagRow}>
690
+ <code className={css.diagVal}>{peer.name}</code>
691
+ <span className={css.nm}>{peer.plugin}</span>
692
+ <span className={css.spec}>{t('checkRange')}: {peer.range}</span>
693
+ <span className={css.spec}>{t('checkResolved')}: {peer.resolved ?? '—'}</span>
694
+ <span className={css.diagBadgeWarn}>{peerWarningLabel(peer, t)}</span>
695
+ </div>
696
+ ))}
697
+ </div>
698
+ </Section>
699
+
700
+ <Section
701
+ title={t('checkPeerInfoTier')}
702
+ count={peerInfo.length}
703
+ empty={t('checkPeerInfoTierEmpty')}
704
+ problem={false}
705
+ overview={peerInfo.length > 0
706
+ ? `${peerInfo[0].plugin} → ${peerInfo[0].name}@${peerInfo[0].range}`
707
+ : undefined}
708
+ >
709
+ <div className={css.diagList}>
710
+ {peerInfo.map((peer, i) => (
711
+ <div key={i} className={css.diagRow}>
712
+ <code className={css.diagVal}>{peer.name}</code>
713
+ <span className={css.nm}>{peer.plugin}</span>
714
+ <span className={css.spec}>{t('checkRange')}: {peer.range}</span>
715
+ <span className={css.spec}>{t('checkResolved')}: {peer.resolved ?? '—'}</span>
716
+ {peer.satisfied === null
717
+ ? <span className={css.diagBadgeInfo}>{t('checkUnknown')}</span>
718
+ : <span className={css.diagBadgeInfo}>{t('peerInfoUnverified')}</span>}
719
+ </div>
720
+ ))}
721
+ </div>
722
+ </Section>
723
+
724
+ <Section
725
+ title={t('checkPeerSatisfied')}
726
+ count={peerSatisfied.length}
727
+ empty={t('checkPeerSatisfiedEmpty')}
728
+ problem={false}
729
+ overview={peerSatisfied.length > 0
730
+ ? `${peerSatisfied[0].plugin} → ${peerSatisfied[0].name}@${peerSatisfied[0].range}`
731
+ : undefined}
732
+ >
733
+ <div className={css.diagList}>
734
+ {peerSatisfied.map((peer, i) => (
735
+ <div key={i} className={css.diagRow}>
736
+ <code className={css.diagVal}>{peer.name}</code>
737
+ <span className={css.nm}>{peer.plugin}</span>
738
+ <span className={css.spec}>{t('checkRange')}: {peer.range}</span>
739
+ <span className={css.spec}>{t('checkResolved')}: {peer.resolved ?? '—'}</span>
740
+ <span className={css.okState}>{t('checkSatisfied')}</span>
741
+ </div>
742
+ ))}
743
+ </div>
744
+ </Section>
745
+
746
+ <Section
747
+ title={t('checkMultiVersion')}
748
+ count={report.multiVersion.length}
749
+ empty={t('checkMultiEmpty')}
750
+ overview={report.multiVersion.length > 0 ? `${report.multiVersion[0]?.name}: ${report.multiVersion[0]?.versions.join(' / ')}` : undefined}
751
+ >
752
+ <div className={css.diagList}>
753
+ {report.multiVersion.map(mv => (
754
+ <div key={mv.name} className={css.diagRow}>
755
+ <code className={css.diagVal}>{mv.name}</code>
756
+ <span className={css.spec}>{mv.versions.join(' / ')}</span>
757
+ {mv.hoisted !== null && <span className={css.spec}>{t('checkHoisted')}: {mv.hoisted}</span>}
758
+ </div>
759
+ ))}
760
+ </div>
761
+ </Section>
762
+
763
+ <Section
764
+ title={t('checkOverrides')}
765
+ count={report.overrides.length}
766
+ empty={t('checkOverridesEmpty')}
767
+ overview={report.overrides.length > 0 ? `${report.overrides[0]?.id} ← ${report.overrides[0]?.layer}` : undefined}
768
+ >
769
+ <div className={css.diagList}>
770
+ {report.overrides.map((ov, i) => (
771
+ <div key={i} className={css.ovRow}>
772
+ <code className={css.diagVal}>{ov.id}</code>
773
+ <span className={css.ovArrow}>←</span>
774
+ <span className={css.ovByTag}>{ov.layer}</span>
775
+ <span className={css.spec}>{t('checkOverridden')}</span>
776
+ <span className={css.ovFrom}>{ov.overriddenLayers.join(', ')}</span>
777
+ </div>
778
+ ))}
779
+ </div>
780
+ </Section>
781
+
782
+ <Section
783
+ title={t('checkOrphans')}
784
+ count={report.orphans.length}
785
+ empty={t('checkOrphansEmpty')}
786
+ overview={report.orphans.length > 0 ? `${report.orphans[0]?.id}(${t(orphanKindLabel(report.orphans[0]?.reason ?? ''))})` : undefined}
787
+ >
788
+ <div className={css.diagList}>
789
+ {report.orphans.map((orphan, i) => (
790
+ <div key={i} className={css.orphRow}>
791
+ <span className={css.orphBadge}>{t(orphanKindLabel(orphan.reason))}</span>
792
+ <code className={css.diagVal}>{orphan.id}</code>
793
+ <span className={css.nm}>{orphan.layer}</span>
794
+ <span className={css.spec}>{orphan.reason}</span>
795
+ </div>
796
+ ))}
797
+ </div>
798
+ </Section>
799
+
800
+ {/* issue #98 phase 2 (step 1): community-bundle ordering */}
801
+ <CollapsibleSection title={t('orderSection')} count={order.length} open={orderOpen} onToggle={() => setOrderOpen(o => !o)}>
802
+ <p className={css.panelNote}>{t('orderDragHint')}</p>
803
+ {report.orderConflicts !== undefined && report.orderConflicts.length > 0 && (
804
+ <div className={css.diagList}>
805
+ <span className={css.diagKey}>{t('orderConflicts')}</span>
806
+ {report.orderConflicts.map((conflict, i) => (
807
+ <div key={i} className={css.warnLine}>{conflict.name} — {conflict.reason}</div>
808
+ ))}
809
+ </div>
810
+ )}
811
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
812
+ <Button variant="primary" size="sm" disabled={order.length === 0 || orderBusy} onClick={() => applyOrder()}>
813
+ {orderBusy ? '…' : t('orderApply')}
814
+ </Button>
815
+ {suggested !== null && suggested.ok === true
816
+ && suggested.order.join('\u0000') !== communityNames.join('\u0000')
817
+ && (
818
+ <Button variant="outline" size="sm" disabled={orderBusy} onClick={() => applyOrder(suggested.order)}>
819
+ {t('orderSuggestApply')}
820
+ </Button>
821
+ )}
822
+ {suggested !== null && suggested.ok === true
823
+ && suggested.order.join('\u0000') === communityNames.join('\u0000')
824
+ && <span className={css.okState}>{t('orderAlreadyOptimal')}</span>}
825
+ {order.join('\u0000') !== communityNames.join('\u0000') && (
826
+ <Button variant="ghost" size="sm" disabled={orderBusy} onClick={() => setOrder(communityNames)}>
827
+ {t('orderReset')}
828
+ </Button>
829
+ )}
830
+ {orderMsg !== null && <span className={css.okState}>{orderMsg}</span>}
831
+ {orderErr !== null && <span className={css.err}>{orderErr}</span>}
832
+ </div>
833
+ {orderDiff !== null && (
834
+ <div className={css.panelNote}>
835
+ {t('orderDiffHint').replace('{0}', String(orderDiff.overrides)).replace('{1}', String(orderDiff.orphans)).replace('{2}', String(orderDiff.duplicates))}
836
+ </div>
837
+ )}
838
+ {suggested !== null && suggested.ok === false && (
839
+ <div className={css.warnLine}>{t('orderSuggestHint')} ⚠ {suggested.cycle.join(' → ')}</div>
840
+ )}
841
+ {report.duplicateNames !== undefined && report.duplicateNames.length > 0 && (
842
+ <div className={css.diagList}>
843
+ <span className={css.diagKey}>{t('duplicateNames')}</span>
844
+ {report.duplicateNames.map((dup, i) => (
845
+ <div key={i} className={css.panelNote}>{dup.name} × {dup.count} — {dup.layers.join(' / ')}</div>
846
+ ))}
847
+ </div>
848
+ )}
849
+ {order.length === 0
850
+ ? <div className={css.diagEmpty}>—</div>
851
+ : (
852
+ <div className={css.diagList}>
853
+ {order.map((name, i) => (
854
+ <div
855
+ key={name}
856
+ draggable={!orderBusy}
857
+ className={[
858
+ css.diagRow,
859
+ dragIndex === i ? css.dragging : '',
860
+ dragOverIndex === i ? css.dragOver : '',
861
+ ].filter(Boolean).join(' ')}
862
+ onDragStart={onRowDragStart(i)}
863
+ onDragOver={onRowDragOver(i)}
864
+ onDragLeave={onRowDragLeave(i)}
865
+ onDrop={onRowDrop(i)}
866
+ onDragEnd={onRowDragEnd}
867
+ >
868
+ <span className={css.dragHandle} aria-label={t('orderDrag')} title={t('orderDrag')}>⠿</span>
869
+ <span className={css.diagIndex}>{i + 1}</span>
870
+ <span className={css.nm}>{name}</span>
871
+ <span className={css.grow} />
872
+ <Button
873
+ variant="ghost"
874
+ size="sm"
875
+ draggable={false}
876
+ aria-label={t('orderUp')}
877
+ disabled={i === 0 || orderBusy}
878
+ onClick={() => moveBundle(i, -1)}
879
+ >{t('orderUp')}</Button>
880
+ <Button
881
+ variant="ghost"
882
+ size="sm"
883
+ draggable={false}
884
+ aria-label={t('orderDown')}
885
+ disabled={i >= order.length - 1 || orderBusy}
886
+ onClick={() => moveBundle(i, 1)}
887
+ >{t('orderDown')}</Button>
888
+ </div>
889
+ ))}
890
+ </div>
891
+ )}
892
+ </CollapsibleSection>
893
+
894
+ {/* issue #98 phase 3: snapshots & rollback */}
895
+ <CollapsibleSection title={t('presetSection')} open={presetOpen} onToggle={() => setPresetOpen(o => !o)}>
896
+ {/* The ordering DRAFT, not the last-reported order: a preset must
897
+ capture the arrangement on screen, or saving right after a reorder
898
+ stores the composition the user just moved away from. */}
899
+ <PresetPanel t={t} open={presetOpen} bundleOrder={order} onRefresh={refresh} />
900
+ </CollapsibleSection>
901
+
902
+ <CollapsibleSection title={t('snapSection')} open={snapOpen} onToggle={() => setSnapOpen(o => !o)}>
903
+ <SnapshotPanel t={t} open={snapOpen} onRefresh={refresh} />
904
+ </CollapsibleSection>
905
+ </div>
906
+ )
907
+ }