use-agentz 0.1.0
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.
- package/README.md +308 -0
- package/dist/authoring/define.js +132 -0
- package/dist/cli.js +26581 -0
- package/dist/config/define.js +36 -0
- package/dist/ui/web/index.html +16 -0
- package/dist/ui/web/logo.png +0 -0
- package/dist/ui/web/main.tsx +2406 -0
- package/dist/ui/web/styles.css +2338 -0
- package/package.json +54 -0
- package/registry/r/agent.analyst.json +44 -0
- package/registry/r/agent.designer.json +34 -0
- package/registry/r/agent.developer.json +31 -0
- package/registry/r/agent.expert-orchestrator.json +36 -0
- package/registry/r/agent.expert.json +33 -0
- package/registry/r/agent.orchestrator.json +39 -0
- package/registry/r/agent.quick.json +28 -0
- package/registry/r/agent.reviewer-anthropic.json +34 -0
- package/registry/r/agent.reviewer-gpt.json +34 -0
- package/registry/r/agent.scout.json +34 -0
- package/registry/r/command.caveman.json +20 -0
- package/registry/r/command.grill-me.json +20 -0
- package/registry/r/group.cli-bun.json +13 -0
- package/registry/r/group.cli-go.json +13 -0
- package/registry/r/group.database-design.json +12 -0
- package/registry/r/group.design.json +14 -0
- package/registry/r/group.documents.json +13 -0
- package/registry/r/group.engineering-process.json +18 -0
- package/registry/r/group.go.json +21 -0
- package/registry/r/group.python.json +10 -0
- package/registry/r/group.react.json +11 -0
- package/registry/r/group.typescript.json +10 -0
- package/registry/r/lsp.proto.json +41 -0
- package/registry/r/mcp.context7.json +13 -0
- package/registry/r/mcp.playwright.json +50 -0
- package/registry/r/pack.basic.json +78 -0
- package/registry/r/pack.gpt-5-6.json +78 -0
- package/registry/r/pack.optimized.json +78 -0
- package/registry/r/pack.opus-fable.json +78 -0
- package/registry/r/plugin.caveman-compress.json +20 -0
- package/registry/r/preset.caveman-only.json +13 -0
- package/registry/r/preset.cost-aware-trio.json +41 -0
- package/registry/r/preset.cross-model-review.json +34 -0
- package/registry/r/preset.design.json +39 -0
- package/registry/r/preset.fable-pack.json +41 -0
- package/registry/r/preset.full.json +67 -0
- package/registry/r/rule.always-cross-review.json +25 -0
- package/registry/r/rule.always-design.json +25 -0
- package/registry/r/rule.always-qa.json +25 -0
- package/registry/r/rule.always-test.json +25 -0
- package/registry/r/rule.caveman-ultra.json +23 -0
- package/registry/r/rule.never-cross-review.json +25 -0
- package/registry/r/rule.never-design.json +25 -0
- package/registry/r/rule.never-qa.json +25 -0
- package/registry/r/rule.never-test.json +25 -0
- package/registry/r/rule.routing.json +26 -0
- package/registry/r/rule.use-question-tool.json +20 -0
- package/registry/r/skill.bun-cli.json +20 -0
- package/registry/r/skill.bun-tui.json +20 -0
- package/registry/r/skill.caveman-compress.json +17 -0
- package/registry/r/skill.caveman.json +20 -0
- package/registry/r/skill.cli-design.json +15 -0
- package/registry/r/skill.code-review.json +18 -0
- package/registry/r/skill.cost-aware-orchestration.json +14 -0
- package/registry/r/skill.cross-model-review.json +14 -0
- package/registry/r/skill.db-schema-design.json +15 -0
- package/registry/r/skill.design-system.json +19 -0
- package/registry/r/skill.design-taste.json +116 -0
- package/registry/r/skill.diagnosing-bugs.json +23 -0
- package/registry/r/skill.diagrams.json +21 -0
- package/registry/r/skill.docx-create.json +20 -0
- package/registry/r/skill.domain-modeling.json +18 -0
- package/registry/r/skill.frontend-qa.json +19 -0
- package/registry/r/skill.go-architecture.json +21 -0
- package/registry/r/skill.go-ci.json +21 -0
- package/registry/r/skill.go-cli.json +20 -0
- package/registry/r/skill.go-data-structures.json +21 -0
- package/registry/r/skill.go-dependencies.json +21 -0
- package/registry/r/skill.go-dev.json +20 -0
- package/registry/r/skill.go-documentation.json +21 -0
- package/registry/r/skill.go-ecosystem.json +21 -0
- package/registry/r/skill.go-qa.json +21 -0
- package/registry/r/skill.go-quality.json +21 -0
- package/registry/r/skill.go-refactoring.json +21 -0
- package/registry/r/skill.go-tooling.json +21 -0
- package/registry/r/skill.go-tui.json +20 -0
- package/registry/r/skill.go-types.json +21 -0
- package/registry/r/skill.grilling.json +18 -0
- package/registry/r/skill.improve-architecture.json +18 -0
- package/registry/r/skill.pdf-create.json +23 -0
- package/registry/r/skill.pptx-create.json +23 -0
- package/registry/r/skill.product-ui.json +22 -0
- package/registry/r/skill.python-dev.json +20 -0
- package/registry/r/skill.react-dev.json +20 -0
- package/registry/r/skill.resolving-merge-conflicts.json +18 -0
- package/registry/r/skill.scratchpad.json +17 -0
- package/registry/r/skill.skill-authoring.json +17 -0
- package/registry/r/skill.software-design.json +28 -0
- package/registry/r/skill.sql-migrations.json +15 -0
- package/registry/r/skill.tdd.json +28 -0
- package/registry/r/skill.typescript-dev.json +20 -0
- package/registry/r/skill.visual-assets.json +17 -0
- package/registry/r/skill.wayfinder.json +18 -0
- package/registry/r/skill.xlsx-create.json +20 -0
- package/registry/r/tool.a11y-quick.json +24 -0
- package/registry/r/tool.contrast-check.json +24 -0
- package/registry/r/tool.design-lint.json +24 -0
- package/registry/r/tool.design-search.json +23 -0
- package/registry/r/tool.pdf-create.json +23 -0
- package/registry/r/tool.pptx-create.json +23 -0
- package/registry/r/tool.scratchpad.json +68 -0
- package/registry/r/tool.token-validate.json +24 -0
- package/registry/registry.json +676 -0
- package/schema/agentz.schema.json +330 -0
|
@@ -0,0 +1,2406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentZ UI — React SPA (single file, no router/state lib per the brief).
|
|
3
|
+
*
|
|
4
|
+
* Screens: (1) setup picker (preset cards + pack selector), (2) review as an
|
|
5
|
+
* app shell: main column of section cards (targets/agents/skills/tools/
|
|
6
|
+
* plugins/commands/MCPs) + a sticky right rail with the setup summary, config
|
|
7
|
+
* preview buttons (right-side sheet) and the Install CTA, (3) install
|
|
8
|
+
* (confirm -> progress -> result). If a lockfile/config exists we first show
|
|
9
|
+
* the current-install summary with a "Modify" path preloading /api/state.
|
|
10
|
+
*
|
|
11
|
+
* Inputs are Base UI primitives (@base-ui-components/react): Switch for every
|
|
12
|
+
* item toggle, Combobox for the model picker, ToggleGroup for segmented
|
|
13
|
+
* controls, Popover for the per-section "+ Add" pickers, Dialog (styled as a
|
|
14
|
+
* right drawer) for config previews, Collapsible for the experimental-targets
|
|
15
|
+
* group, Tooltip on the reset-to-default control. All unstyled, themed via
|
|
16
|
+
* the --as-* tokens in styles.css.
|
|
17
|
+
*
|
|
18
|
+
* strict TS throughout (tsx). No snapshot logic — plain fetch + useReducer.
|
|
19
|
+
*/
|
|
20
|
+
import { Fragment, StrictMode, useEffect, useMemo, useReducer, useRef, useState } from "react"
|
|
21
|
+
import type * as React from "react"
|
|
22
|
+
import { createRoot } from "react-dom/client"
|
|
23
|
+
import { Collapsible } from "@base-ui-components/react/collapsible"
|
|
24
|
+
import { Combobox } from "@base-ui-components/react/combobox"
|
|
25
|
+
import { Dialog } from "@base-ui-components/react/dialog"
|
|
26
|
+
import { Popover } from "@base-ui-components/react/popover"
|
|
27
|
+
import { Switch } from "@base-ui-components/react/switch"
|
|
28
|
+
import { Toggle } from "@base-ui-components/react/toggle"
|
|
29
|
+
import { ToggleGroup } from "@base-ui-components/react/toggle-group"
|
|
30
|
+
import { Tooltip } from "@base-ui-components/react/tooltip"
|
|
31
|
+
import type {
|
|
32
|
+
CatalogPackTier,
|
|
33
|
+
CatalogResponse,
|
|
34
|
+
GraphResponse,
|
|
35
|
+
InstallResponse,
|
|
36
|
+
ModelsResponse,
|
|
37
|
+
PreviewFiles,
|
|
38
|
+
PreviewModel,
|
|
39
|
+
PreviewResponse,
|
|
40
|
+
ProjectInfo,
|
|
41
|
+
ProjectsResponse,
|
|
42
|
+
SessionStateResponse,
|
|
43
|
+
StateResponse,
|
|
44
|
+
ShutdownResponse,
|
|
45
|
+
WizardState,
|
|
46
|
+
} from "../api.ts"
|
|
47
|
+
import { SHUTDOWN_TOKEN_HEADER } from "../api.ts"
|
|
48
|
+
|
|
49
|
+
/* ================================================================== */
|
|
50
|
+
/* API client (project-scoped) */
|
|
51
|
+
/* ================================================================== */
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The currently selected project (root-relative path; "" = root). Every API
|
|
55
|
+
* call appends it as `?project=` so state/preview/install are scoped. Kept as a
|
|
56
|
+
* module singleton so the debounced preview hook and one-off fetches agree
|
|
57
|
+
* without prop-drilling it into the API layer.
|
|
58
|
+
*/
|
|
59
|
+
let currentProject = ""
|
|
60
|
+
let shutdownToken: string | null = null
|
|
61
|
+
function setCurrentProject(p: string): void {
|
|
62
|
+
currentProject = p
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Append the active `?project=` scope (and merge any existing query). */
|
|
66
|
+
function scoped(path: string): string {
|
|
67
|
+
if (!currentProject) return path
|
|
68
|
+
const sep = path.includes("?") ? "&" : "?"
|
|
69
|
+
return `${path}${sep}project=${encodeURIComponent(currentProject)}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function apiGet<T>(path: string): Promise<T> {
|
|
73
|
+
const res = await fetch(scoped(path))
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const data = (await res.json().catch(() => ({}))) as { error?: string }
|
|
76
|
+
throw new Error(data.error ?? `GET ${path} failed`)
|
|
77
|
+
}
|
|
78
|
+
return res.json() as Promise<T>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
|
82
|
+
const res = await fetch(scoped(path), {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "content-type": "application/json" },
|
|
85
|
+
body: JSON.stringify(body),
|
|
86
|
+
})
|
|
87
|
+
const data = await res.json().catch(() => ({}))
|
|
88
|
+
if (!res.ok) throw new Error((data as { error?: string }).error ?? `POST ${path} failed`)
|
|
89
|
+
return data as T
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Load current state and retain the launch-only shutdown token in memory. */
|
|
93
|
+
async function loadState(): Promise<StateResponse> {
|
|
94
|
+
const { shutdownToken: token, ...state } = await apiGet<SessionStateResponse>("/api/state")
|
|
95
|
+
shutdownToken = token
|
|
96
|
+
return state
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Close requires a non-simple token header; never include it in other requests. */
|
|
100
|
+
async function shutdown(): Promise<ShutdownResponse> {
|
|
101
|
+
if (!shutdownToken) throw new Error("shutdown unavailable")
|
|
102
|
+
const res = await fetch("/api/shutdown", {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { [SHUTDOWN_TOKEN_HEADER]: shutdownToken },
|
|
105
|
+
})
|
|
106
|
+
const data = await res.json().catch(() => ({}))
|
|
107
|
+
if (!res.ok) throw new Error((data as { error?: string }).error ?? "shutdown failed")
|
|
108
|
+
return data as ShutdownResponse
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ================================================================== */
|
|
112
|
+
/* model badges (deterministic rainbow hue per model id) */
|
|
113
|
+
/* ================================================================== */
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Deterministic hue (0–359) for a model id. The hue is derived from the model
|
|
117
|
+
* NAME AFTER the last '/' (`github-copilot/claude-fable-5` → `claude-fable-5`),
|
|
118
|
+
* so the SAME model shares a hue across providers, and a plain FNV-1a hash of a
|
|
119
|
+
* fixed string makes it stable across reloads (no index/iteration-order/random
|
|
120
|
+
* salt involved). `>>> 0` keeps the 32-bit accumulator unsigned so `% 360` is
|
|
121
|
+
* always the same non-negative value.
|
|
122
|
+
*/
|
|
123
|
+
export function modelHue(id: string): number {
|
|
124
|
+
const name = id.split("/").pop() ?? id
|
|
125
|
+
let h = 0x811c9dc5
|
|
126
|
+
for (let i = 0; i < name.length; i++) {
|
|
127
|
+
h ^= name.charCodeAt(i)
|
|
128
|
+
h = Math.imul(h, 0x01000193)
|
|
129
|
+
}
|
|
130
|
+
return (h >>> 0) % 360
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A colored model chip: hue derived from the model name, via --model-hue.
|
|
135
|
+
* Optionally renders the reasoning effort as a `: effort` suffix so an agent
|
|
136
|
+
* row reads `provider/model : high` in one mono badge.
|
|
137
|
+
*/
|
|
138
|
+
function ModelBadge(props: { model: string; effort?: string | undefined }): React.JSX.Element {
|
|
139
|
+
const hue = modelHue(props.model)
|
|
140
|
+
return (
|
|
141
|
+
<span
|
|
142
|
+
className="as-model-badge"
|
|
143
|
+
style={{ ["--model-hue" as string]: String(hue) }}
|
|
144
|
+
title={props.effort ? `${props.model} : ${props.effort}` : props.model}
|
|
145
|
+
>
|
|
146
|
+
{props.model}
|
|
147
|
+
{props.effort ? ` : ${props.effort}` : ""}
|
|
148
|
+
</span>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ================================================================== */
|
|
153
|
+
/* catalog dependency graph (read-only hints, degrade silently) */
|
|
154
|
+
/* ================================================================== */
|
|
155
|
+
|
|
156
|
+
/** Lookup maps derived once from GET /api/graph. All UI hints built on top of
|
|
157
|
+
* these degrade to nothing when the fetch fails (GraphInfo stays null). */
|
|
158
|
+
interface GraphInfo {
|
|
159
|
+
/** node id -> human label */
|
|
160
|
+
labels: Map<string, string>
|
|
161
|
+
/** node id -> dependency target ids (manifest `dependencies` edges) */
|
|
162
|
+
requires: Map<string, string[]>
|
|
163
|
+
/** node id -> number of dependency edges pointing AT it */
|
|
164
|
+
requiredBy: Map<string, number>
|
|
165
|
+
/** preset/pack id -> member ids (includes + extra edges, groups expanded) */
|
|
166
|
+
members: Map<string, string[]>
|
|
167
|
+
/** group node id (`group:x`) -> its member skill ids (`skill:y`), raw. */
|
|
168
|
+
groups: Map<string, string[]>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildGraphInfo(g: GraphResponse): GraphInfo {
|
|
172
|
+
const labels = new Map<string, string>()
|
|
173
|
+
for (const n of g.nodes) labels.set(n.id, n.label)
|
|
174
|
+
const requires = new Map<string, string[]>()
|
|
175
|
+
const requiredBy = new Map<string, number>()
|
|
176
|
+
const direct = new Map<string, string[]>()
|
|
177
|
+
const groups = new Map<string, string[]>()
|
|
178
|
+
for (const e of g.edges) {
|
|
179
|
+
if (e.kind === "dependency") {
|
|
180
|
+
requires.set(e.from, [...(requires.get(e.from) ?? []), e.to])
|
|
181
|
+
requiredBy.set(e.to, (requiredBy.get(e.to) ?? 0) + 1)
|
|
182
|
+
} else if (e.kind === "group") {
|
|
183
|
+
groups.set(e.from, [...(groups.get(e.from) ?? []), e.to])
|
|
184
|
+
} else {
|
|
185
|
+
// includes (preset -> items) and extra (pack -> bundled items)
|
|
186
|
+
direct.set(e.from, [...(direct.get(e.from) ?? []), e.to])
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Expand group members into the owning preset's member list (group node
|
|
190
|
+
// itself dropped from counts; its skills are what actually gets installed).
|
|
191
|
+
const members = new Map<string, string[]>()
|
|
192
|
+
for (const [owner, ids] of direct) {
|
|
193
|
+
const out: string[] = []
|
|
194
|
+
for (const id of ids) {
|
|
195
|
+
if (id.startsWith("group:")) out.push(...(groups.get(id) ?? []))
|
|
196
|
+
else out.push(id)
|
|
197
|
+
}
|
|
198
|
+
members.set(owner, [...new Set(out)])
|
|
199
|
+
}
|
|
200
|
+
return { labels, requires, requiredBy, members, groups }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** "3 agents · 4 skills" style composition summary for a preset/pack card. */
|
|
204
|
+
function memberCounts(info: GraphInfo, ownerId: string): string | null {
|
|
205
|
+
const ids = info.members.get(ownerId)
|
|
206
|
+
if (!ids || ids.length === 0) return null
|
|
207
|
+
const counts = new Map<string, number>()
|
|
208
|
+
for (const id of ids) {
|
|
209
|
+
const type = id.split(":")[0] ?? "item"
|
|
210
|
+
counts.set(type, (counts.get(type) ?? 0) + 1)
|
|
211
|
+
}
|
|
212
|
+
return [...counts.entries()]
|
|
213
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
214
|
+
.map(([type, n]) => `${n} ${REF_TYPE_LABELS[type] ?? `${type}s`}`.replace(/s$/, n === 1 ? "" : "s"))
|
|
215
|
+
.join(" · ")
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Dependency-lock map for the review screen: every item id that is required
|
|
220
|
+
* (directly or transitively) by a currently-enabled item, mapped to the human
|
|
221
|
+
* labels of its DIRECT enabled requirers. Locked items render a locked-ON
|
|
222
|
+
* Switch with an inline "required by X, Y" note; graph==null -> empty map ->
|
|
223
|
+
* nothing locks and the UI behaves like before.
|
|
224
|
+
*/
|
|
225
|
+
function computeLocks(info: GraphInfo | null, enabled: Set<string>): Map<string, string[]> {
|
|
226
|
+
const locked = new Map<string, string[]>()
|
|
227
|
+
if (!info) return locked
|
|
228
|
+
const queue = [...enabled]
|
|
229
|
+
const seen = new Set(queue)
|
|
230
|
+
while (queue.length > 0) {
|
|
231
|
+
const id = queue.shift()!
|
|
232
|
+
const fromLabel = info.labels.get(id) ?? id.split(":").pop() ?? id
|
|
233
|
+
for (const dep of info.requires.get(id) ?? []) {
|
|
234
|
+
const list = locked.get(dep) ?? []
|
|
235
|
+
if (!list.includes(fromLabel)) list.push(fromLabel)
|
|
236
|
+
locked.set(dep, list)
|
|
237
|
+
if (!seen.has(dep)) {
|
|
238
|
+
seen.add(dep)
|
|
239
|
+
queue.push(dep) // transitive: a locked dep drags in ITS deps too
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return locked
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Compact dependency hint under an item row: up to 3 "requires" chips with a
|
|
248
|
+
* "+n more" expander, plus a "needed by N" chip. Renders nothing without graph
|
|
249
|
+
* data, so a failed /api/graph fetch leaves rows untouched.
|
|
250
|
+
*/
|
|
251
|
+
function DepChips(props: { info: GraphInfo | null; kind: string; name: string }): React.JSX.Element | null {
|
|
252
|
+
const [expanded, setExpanded] = useState(false)
|
|
253
|
+
if (!props.info) return null
|
|
254
|
+
const id = `${props.kind}:${props.name}`
|
|
255
|
+
const req = props.info.requires.get(id) ?? []
|
|
256
|
+
const by = props.info.requiredBy.get(id) ?? 0
|
|
257
|
+
if (req.length === 0 && by === 0) return null
|
|
258
|
+
const shown = expanded ? req : req.slice(0, 3)
|
|
259
|
+
const hidden = req.length - shown.length
|
|
260
|
+
return (
|
|
261
|
+
<span className="as-dep-chips">
|
|
262
|
+
{req.length > 0 && <span className="as-dep-label">requires</span>}
|
|
263
|
+
{shown.map((dep) => (
|
|
264
|
+
<span className="as-dep-chip" key={dep} title={dep}>
|
|
265
|
+
{props.info!.labels.get(dep) ?? dep.split(":").pop()}
|
|
266
|
+
</span>
|
|
267
|
+
))}
|
|
268
|
+
{hidden > 0 && (
|
|
269
|
+
<button type="button" className="as-dep-more" onClick={() => setExpanded(true)}>
|
|
270
|
+
+{hidden} more
|
|
271
|
+
</button>
|
|
272
|
+
)}
|
|
273
|
+
{by > 0 && (
|
|
274
|
+
<span className="as-dep-chip" title={`${by} other item(s) depend on this`}>
|
|
275
|
+
needed by {by}
|
|
276
|
+
</span>
|
|
277
|
+
)}
|
|
278
|
+
</span>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* ================================================================== */
|
|
283
|
+
/* wizard state (mirrors src/wizard/shared.ts WizardState) */
|
|
284
|
+
/* ================================================================== */
|
|
285
|
+
|
|
286
|
+
type Screen = "loading" | "current" | "setup" | "review" | "installing" | "done" | "error"
|
|
287
|
+
|
|
288
|
+
function defaultState(catalog: CatalogResponse): WizardState {
|
|
289
|
+
return {
|
|
290
|
+
presetNames: [catalog.defaultPreset],
|
|
291
|
+
modelPack: catalog.defaultPack,
|
|
292
|
+
targets: (catalog.targets.filter((t) => t.detected).map((t) => t.id) as WizardState["targets"]) ?? [],
|
|
293
|
+
scope: "project",
|
|
294
|
+
vars: { scratchpad: true },
|
|
295
|
+
agents: {},
|
|
296
|
+
tierThinking: {},
|
|
297
|
+
excluded: { skill: [], tool: [], plugin: [], mcp: [], rule: [], lsp: [] },
|
|
298
|
+
mcps: {},
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
type Action =
|
|
303
|
+
| { t: "preset"; name: string }
|
|
304
|
+
| { t: "pack"; pack: string }
|
|
305
|
+
| { t: "targets"; targets: string[] }
|
|
306
|
+
| { t: "scope"; scope: "project" | "user" }
|
|
307
|
+
| { t: "agentsMd"; mode: string }
|
|
308
|
+
| { t: "agentModel"; agentId: string; model: string }
|
|
309
|
+
| { t: "agentEffort"; agentId: string; effort: string }
|
|
310
|
+
| { t: "tierModel"; tier: "cheap" | "normal" | "expert"; model: string }
|
|
311
|
+
| { t: "tierEffort"; tier: "cheap" | "normal" | "expert"; effort: string }
|
|
312
|
+
| { t: "toggle"; kind: keyof WizardState["excluded"]; name: string; on: boolean }
|
|
313
|
+
| { t: "mcpOption"; mcp: string; option: string; on: boolean }
|
|
314
|
+
| { t: "extra"; kind: ExtraKind; name: string; on: boolean }
|
|
315
|
+
| { t: "replace"; state: WizardState }
|
|
316
|
+
|
|
317
|
+
/** Catalog kinds bulk-addable via the review "+ Add" popovers. */
|
|
318
|
+
type ExtraKind = "agent" | "skill" | "tool" | "plugin" | "mcp" | "command" | "rule" | "lsp"
|
|
319
|
+
|
|
320
|
+
function reducer(state: WizardState, a: Action): WizardState {
|
|
321
|
+
switch (a.t) {
|
|
322
|
+
case "replace":
|
|
323
|
+
return a.state
|
|
324
|
+
case "preset": {
|
|
325
|
+
// Multi-select: toggle membership (keep ≥0; Review button guards ≥1).
|
|
326
|
+
const has = state.presetNames.includes(a.name)
|
|
327
|
+
const presetNames = has
|
|
328
|
+
? state.presetNames.filter((n) => n !== a.name)
|
|
329
|
+
: [...state.presetNames, a.name]
|
|
330
|
+
return { ...state, presetNames }
|
|
331
|
+
}
|
|
332
|
+
case "pack":
|
|
333
|
+
return { ...state, modelPack: a.pack }
|
|
334
|
+
case "targets":
|
|
335
|
+
return { ...state, targets: a.targets as WizardState["targets"] }
|
|
336
|
+
case "scope":
|
|
337
|
+
return { ...state, scope: a.scope }
|
|
338
|
+
case "agentsMd":
|
|
339
|
+
return { ...state, agentsMd: a.mode === "off" ? false : (a.mode as "lean" | "full") }
|
|
340
|
+
case "agentModel": {
|
|
341
|
+
const agents = { ...state.agents }
|
|
342
|
+
const cur = { ...(agents[a.agentId] ?? {}) }
|
|
343
|
+
if (a.model) cur.model = a.model
|
|
344
|
+
else delete cur.model
|
|
345
|
+
if (Object.keys(cur).length) agents[a.agentId] = cur
|
|
346
|
+
else delete agents[a.agentId]
|
|
347
|
+
return { ...state, agents }
|
|
348
|
+
}
|
|
349
|
+
case "tierModel": {
|
|
350
|
+
// Per-tier model override, stored as vars["model.<tier>"] — the same key
|
|
351
|
+
// the planner reads at the top of the tier-default precedence. Empty =
|
|
352
|
+
// clear (fall back to the selected pack's tier model).
|
|
353
|
+
const key = `model.${a.tier}`
|
|
354
|
+
const vars = { ...state.vars }
|
|
355
|
+
if (a.model) vars[key] = a.model
|
|
356
|
+
else delete vars[key]
|
|
357
|
+
return { ...state, vars }
|
|
358
|
+
}
|
|
359
|
+
case "tierEffort": {
|
|
360
|
+
// Per-tier reasoning-effort default, stored under tierThinking[tier].
|
|
361
|
+
// Empty = clear the tier entry (fall back to the pack's tier effort); the
|
|
362
|
+
// reducer never writes an empty block.
|
|
363
|
+
const tierThinking = { ...state.tierThinking }
|
|
364
|
+
const cur = { ...(tierThinking[a.tier] ?? {}) }
|
|
365
|
+
if (a.effort) cur.reasoningEffort = a.effort as never
|
|
366
|
+
else delete cur.reasoningEffort
|
|
367
|
+
if (Object.keys(cur).length) tierThinking[a.tier] = cur
|
|
368
|
+
else delete tierThinking[a.tier]
|
|
369
|
+
return { ...state, tierThinking }
|
|
370
|
+
}
|
|
371
|
+
case "agentEffort": {
|
|
372
|
+
const agents = { ...state.agents }
|
|
373
|
+
const cur = { ...(agents[a.agentId] ?? {}) }
|
|
374
|
+
if (a.effort) cur.thinking = { ...(cur.thinking ?? {}), reasoningEffort: a.effort as never }
|
|
375
|
+
else if (cur.thinking) {
|
|
376
|
+
const { reasoningEffort: _drop, ...rest } = cur.thinking
|
|
377
|
+
if (Object.keys(rest).length) cur.thinking = rest
|
|
378
|
+
else delete cur.thinking
|
|
379
|
+
}
|
|
380
|
+
if (Object.keys(cur).length) agents[a.agentId] = cur
|
|
381
|
+
else delete agents[a.agentId]
|
|
382
|
+
return { ...state, agents }
|
|
383
|
+
}
|
|
384
|
+
case "toggle": {
|
|
385
|
+
const list = state.excluded[a.kind]
|
|
386
|
+
const has = list.includes(a.name)
|
|
387
|
+
const next = a.on
|
|
388
|
+
? has
|
|
389
|
+
? list.filter((n) => n !== a.name)
|
|
390
|
+
: list
|
|
391
|
+
: has
|
|
392
|
+
? list
|
|
393
|
+
: [...list, a.name]
|
|
394
|
+
return { ...state, excluded: { ...state.excluded, [a.kind]: next } }
|
|
395
|
+
}
|
|
396
|
+
case "mcpOption": {
|
|
397
|
+
const mcps = { ...state.mcps }
|
|
398
|
+
const bucket = { ...(mcps[a.mcp] ?? {}) }
|
|
399
|
+
const options = { ...(bucket.options ?? {}) }
|
|
400
|
+
options[a.option] = a.on
|
|
401
|
+
bucket.options = options
|
|
402
|
+
mcps[a.mcp] = bucket
|
|
403
|
+
return { ...state, mcps }
|
|
404
|
+
}
|
|
405
|
+
case "extra": {
|
|
406
|
+
const extra = { ...(state.extra ?? {}) }
|
|
407
|
+
const list = extra[a.kind] ?? []
|
|
408
|
+
const has = list.includes(a.name)
|
|
409
|
+
const next = a.on ? (has ? list : [...list, a.name]) : list.filter((n) => n !== a.name)
|
|
410
|
+
if (next.length) extra[a.kind] = next
|
|
411
|
+
else delete extra[a.kind]
|
|
412
|
+
// An extra item is never simultaneously "excluded": adding or removing an
|
|
413
|
+
// extra clears any stale exclusion for the same name so it can never fall
|
|
414
|
+
// into the excluded∧extra limbo (dropped from the preview by filterExcluded
|
|
415
|
+
// → no row, yet still in `extra` → filtered out of the Add popover). Only
|
|
416
|
+
// exclusion buckets exist (agent/command have none) so guard the lookup.
|
|
417
|
+
const exKind = a.kind as keyof WizardState["excluded"]
|
|
418
|
+
const exList = state.excluded[exKind]
|
|
419
|
+
const excluded =
|
|
420
|
+
exList && exList.includes(a.name)
|
|
421
|
+
? { ...state.excluded, [exKind]: exList.filter((n) => n !== a.name) }
|
|
422
|
+
: state.excluded
|
|
423
|
+
return {
|
|
424
|
+
...state,
|
|
425
|
+
excluded,
|
|
426
|
+
extra: Object.keys(extra).length ? extra : undefined,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* ================================================================== */
|
|
433
|
+
/* preview hook (debounced refetch on every state change) */
|
|
434
|
+
/* ================================================================== */
|
|
435
|
+
|
|
436
|
+
function usePreview(state: WizardState, active: boolean): {
|
|
437
|
+
preview: PreviewModel | null
|
|
438
|
+
files: PreviewFiles | null
|
|
439
|
+
deletions: PreviewResponse["deletions"]
|
|
440
|
+
loading: boolean
|
|
441
|
+
error: string | null
|
|
442
|
+
} {
|
|
443
|
+
const [preview, setPreview] = useState<PreviewModel | null>(null)
|
|
444
|
+
const [files, setFiles] = useState<PreviewFiles | null>(null)
|
|
445
|
+
const [deletions, setDeletions] = useState<PreviewResponse["deletions"]>([])
|
|
446
|
+
const [loading, setLoading] = useState(false)
|
|
447
|
+
const [error, setError] = useState<string | null>(null)
|
|
448
|
+
const seq = useRef(0)
|
|
449
|
+
|
|
450
|
+
useEffect(() => {
|
|
451
|
+
if (!active) return
|
|
452
|
+
const id = ++seq.current
|
|
453
|
+
setLoading(true)
|
|
454
|
+
const timer = setTimeout(() => {
|
|
455
|
+
apiPost<PreviewResponse>("/api/preview", { state })
|
|
456
|
+
.then((r) => {
|
|
457
|
+
if (seq.current === id) {
|
|
458
|
+
setPreview(r.preview)
|
|
459
|
+
setFiles(r.files)
|
|
460
|
+
setDeletions(r.deletions ?? [])
|
|
461
|
+
setError(null)
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
.catch((e: Error) => {
|
|
465
|
+
if (seq.current === id) setError(e.message)
|
|
466
|
+
})
|
|
467
|
+
.finally(() => {
|
|
468
|
+
if (seq.current === id) setLoading(false)
|
|
469
|
+
})
|
|
470
|
+
}, 180)
|
|
471
|
+
return () => clearTimeout(timer)
|
|
472
|
+
}, [state, active])
|
|
473
|
+
|
|
474
|
+
return { preview, files, deletions, loading, error }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/* ================================================================== */
|
|
478
|
+
/* Base UI wrappers (styled with --as-* tokens) */
|
|
479
|
+
/* ================================================================== */
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Item toggle: Base UI Switch (zinc track, cobalt when checked). `locked`
|
|
483
|
+
* renders it ON, read-only and dimmed, with a lock glyph; the inline
|
|
484
|
+
* "required by ..." note is rendered by the caller so clicking never toggles
|
|
485
|
+
* a dependency another enabled item still needs.
|
|
486
|
+
*/
|
|
487
|
+
function ItemSwitch(props: {
|
|
488
|
+
checked: boolean
|
|
489
|
+
onChange?: (on: boolean) => void
|
|
490
|
+
label: string
|
|
491
|
+
locked?: boolean
|
|
492
|
+
disabled?: boolean
|
|
493
|
+
describedById?: string
|
|
494
|
+
}): React.JSX.Element {
|
|
495
|
+
return (
|
|
496
|
+
<span className="as-switch-wrap">
|
|
497
|
+
{props.locked && <LockGlyph />}
|
|
498
|
+
<Switch.Root
|
|
499
|
+
className="as-switch"
|
|
500
|
+
checked={props.locked ? true : props.checked}
|
|
501
|
+
readOnly={props.locked === true}
|
|
502
|
+
disabled={props.disabled === true}
|
|
503
|
+
aria-label={props.label}
|
|
504
|
+
aria-describedby={props.locked && props.describedById ? props.describedById : undefined}
|
|
505
|
+
onCheckedChange={(on) => {
|
|
506
|
+
if (!props.locked && props.onChange) props.onChange(on)
|
|
507
|
+
}}
|
|
508
|
+
>
|
|
509
|
+
<Switch.Thumb className="as-switch-thumb" />
|
|
510
|
+
</Switch.Root>
|
|
511
|
+
</span>
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Small lock glyph shown next to a dependency-locked switch. */
|
|
516
|
+
function LockGlyph(): React.JSX.Element {
|
|
517
|
+
return (
|
|
518
|
+
<svg className="as-lock" width="11" height="11" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
|
|
519
|
+
<rect x="2" y="5" width="8" height="6" rx="1.5" fill="currentColor" />
|
|
520
|
+
<path d="M4 5V3.5a2 2 0 0 1 4 0V5" stroke="currentColor" strokeWidth="1.4" fill="none" />
|
|
521
|
+
</svg>
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Inline "required by X, Y" note under a locked row. */
|
|
526
|
+
function LockNote(props: { requirers: string[]; id?: string }): React.JSX.Element {
|
|
527
|
+
return (
|
|
528
|
+
<span className="as-lock-note" id={props.id}>
|
|
529
|
+
required by {props.requirers.slice(0, 3).join(", ")}
|
|
530
|
+
{props.requirers.length > 3 ? ` +${props.requirers.length - 3} more` : ""}
|
|
531
|
+
</span>
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Segmented control (Base UI ToggleGroup, single-select). Empty selections
|
|
537
|
+
* are ignored so one segment is always active.
|
|
538
|
+
*/
|
|
539
|
+
function Seg(props: {
|
|
540
|
+
value: string
|
|
541
|
+
options: { value: string; label: string }[]
|
|
542
|
+
onChange: (v: string) => void
|
|
543
|
+
ariaLabel: string
|
|
544
|
+
}): React.JSX.Element {
|
|
545
|
+
return (
|
|
546
|
+
<ToggleGroup
|
|
547
|
+
className="as-seg"
|
|
548
|
+
value={[props.value]}
|
|
549
|
+
aria-label={props.ariaLabel}
|
|
550
|
+
onValueChange={(vals) => {
|
|
551
|
+
const v = vals[0]
|
|
552
|
+
if (typeof v === "string" && v !== props.value) props.onChange(v)
|
|
553
|
+
}}
|
|
554
|
+
>
|
|
555
|
+
{props.options.map((o) => (
|
|
556
|
+
<Toggle key={o.value} value={o.value} className="as-seg-item">
|
|
557
|
+
{o.label}
|
|
558
|
+
</Toggle>
|
|
559
|
+
))}
|
|
560
|
+
</ToggleGroup>
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Compact accessible dropdown (native `<select>`, styled to match `.as-input`).
|
|
566
|
+
* Used where a full segmented control would not fit — e.g. the three tier
|
|
567
|
+
* columns of reasoning-effort defaults at 1024px. `value === ""` selects the
|
|
568
|
+
* first (placeholder) option. Native semantics give full keyboard + screen
|
|
569
|
+
* reader support for free.
|
|
570
|
+
*/
|
|
571
|
+
function NativeSelect(props: {
|
|
572
|
+
id: string
|
|
573
|
+
value: string
|
|
574
|
+
options: { value: string; label: string }[]
|
|
575
|
+
onChange: (v: string) => void
|
|
576
|
+
ariaLabel: string
|
|
577
|
+
}): React.JSX.Element {
|
|
578
|
+
return (
|
|
579
|
+
<select
|
|
580
|
+
id={props.id}
|
|
581
|
+
className="as-select"
|
|
582
|
+
aria-label={props.ariaLabel}
|
|
583
|
+
value={props.value}
|
|
584
|
+
onChange={(e) => props.onChange(e.target.value)}
|
|
585
|
+
>
|
|
586
|
+
{props.options.map((o) => (
|
|
587
|
+
<option key={o.value} value={o.value}>
|
|
588
|
+
{o.label}
|
|
589
|
+
</option>
|
|
590
|
+
))}
|
|
591
|
+
</select>
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Model override picker. With a model list: a searchable Base UI Combobox
|
|
597
|
+
* grouped by provider (mono, keyboard navigable, clearable). Without one:
|
|
598
|
+
* a plain free-text input committing on blur (same as the wizard).
|
|
599
|
+
*/
|
|
600
|
+
function ModelPicker(props: {
|
|
601
|
+
id: string
|
|
602
|
+
models: ModelsResponse | null
|
|
603
|
+
placeholder: string
|
|
604
|
+
value: string
|
|
605
|
+
onChange: (v: string) => void
|
|
606
|
+
}): React.JSX.Element {
|
|
607
|
+
const available = props.models?.available ?? false
|
|
608
|
+
const hasModels = (props.models?.models.length ?? 0) > 0
|
|
609
|
+
// Local draft for the free-text fallback so typing is smooth; synced back to
|
|
610
|
+
// props.value whenever it changes (reset / state replace), so it never goes
|
|
611
|
+
// stale after the parent swaps state under it.
|
|
612
|
+
const [draft, setDraft] = useState(props.value)
|
|
613
|
+
useEffect(() => {
|
|
614
|
+
setDraft(props.value)
|
|
615
|
+
}, [props.value])
|
|
616
|
+
const groups = useMemo(() => {
|
|
617
|
+
if (!props.models) return []
|
|
618
|
+
const by = new Map<string, string[]>()
|
|
619
|
+
for (const m of props.models.models) {
|
|
620
|
+
const provider = m.id.includes("/") ? m.id.split("/")[0]! : "other"
|
|
621
|
+
by.set(provider, [...(by.get(provider) ?? []), m.id])
|
|
622
|
+
}
|
|
623
|
+
return [...by.entries()]
|
|
624
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
625
|
+
.map(([value, items]) => ({ value, items: items.sort() }))
|
|
626
|
+
}, [props.models])
|
|
627
|
+
|
|
628
|
+
if (!available || !hasModels) {
|
|
629
|
+
return (
|
|
630
|
+
<input
|
|
631
|
+
id={props.id}
|
|
632
|
+
className="as-input"
|
|
633
|
+
placeholder={props.placeholder}
|
|
634
|
+
value={draft}
|
|
635
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
636
|
+
onBlur={(e) => props.onChange(e.target.value.trim())}
|
|
637
|
+
/>
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
return (
|
|
641
|
+
<Combobox.Root
|
|
642
|
+
items={groups}
|
|
643
|
+
value={props.value || null}
|
|
644
|
+
onValueChange={(v) => props.onChange(typeof v === "string" ? v : "")}
|
|
645
|
+
>
|
|
646
|
+
<div className="as-combo">
|
|
647
|
+
<Combobox.Input id={props.id} className="as-input" placeholder={props.placeholder} />
|
|
648
|
+
{props.value && (
|
|
649
|
+
<Combobox.Clear className="as-combo-clear" aria-label="clear model override">
|
|
650
|
+
×
|
|
651
|
+
</Combobox.Clear>
|
|
652
|
+
)}
|
|
653
|
+
</div>
|
|
654
|
+
<Combobox.Portal>
|
|
655
|
+
<Combobox.Positioner className="as-positioner" sideOffset={4}>
|
|
656
|
+
<Combobox.Popup className="as-popover as-combo-popup">
|
|
657
|
+
<Combobox.Empty className="as-combo-empty">no matching models</Combobox.Empty>
|
|
658
|
+
<Combobox.List className="as-combo-list">
|
|
659
|
+
{(group: { value: string; items: string[] }) => (
|
|
660
|
+
<Combobox.Group key={group.value} items={group.items} className="as-combo-group">
|
|
661
|
+
<Combobox.GroupLabel className="as-combo-group-label">{group.value}</Combobox.GroupLabel>
|
|
662
|
+
<Combobox.Collection>
|
|
663
|
+
{(item: string) => (
|
|
664
|
+
<Combobox.Item key={item} value={item} className="as-combo-item">
|
|
665
|
+
<Combobox.ItemIndicator className="as-combo-indicator">
|
|
666
|
+
<CheckIcon />
|
|
667
|
+
</Combobox.ItemIndicator>
|
|
668
|
+
<span className="as-combo-item-text">{item}</span>
|
|
669
|
+
</Combobox.Item>
|
|
670
|
+
)}
|
|
671
|
+
</Combobox.Collection>
|
|
672
|
+
</Combobox.Group>
|
|
673
|
+
)}
|
|
674
|
+
</Combobox.List>
|
|
675
|
+
</Combobox.Popup>
|
|
676
|
+
</Combobox.Positioner>
|
|
677
|
+
</Combobox.Portal>
|
|
678
|
+
</Combobox.Root>
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const TIER_ORDER = ["cheap", "normal", "expert"] as const
|
|
683
|
+
type Tier = (typeof TIER_ORDER)[number]
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Per-tier model overrides — the PRIMARY override path. One compact ModelPicker
|
|
687
|
+
* per tier (cheap/normal/expert) writing `state.vars['model.<tier>']` (the same
|
|
688
|
+
* key the planner reads at the top of the tier-default precedence), so a value
|
|
689
|
+
* here applies to every agent resolved to that tier, plus a compact reasoning
|
|
690
|
+
* effort dropdown writing `state.tierThinking[tier]`. Per-agent overrides (the
|
|
691
|
+
* Agents section, behind the "Override" switch) take priority over
|
|
692
|
+
* both. Each picker's placeholder is the selected pack's tier model, or a
|
|
693
|
+
* plain "keep pack default" fallback when the pack does not define that tier.
|
|
694
|
+
* The effort dropdown defaults to "pack default" (shows the pack's tier effort
|
|
695
|
+
* as its label); clearing it deletes the tier entry (reducer drops the block).
|
|
696
|
+
*
|
|
697
|
+
* The "Model overrides" title is owned by the parent nested-collapsible trigger,
|
|
698
|
+
* so this renders only the description + the three tier fields.
|
|
699
|
+
*/
|
|
700
|
+
function TierOverrides(props: {
|
|
701
|
+
catalog: CatalogResponse
|
|
702
|
+
models: ModelsResponse | null
|
|
703
|
+
state: WizardState
|
|
704
|
+
dispatch: React.Dispatch<Action>
|
|
705
|
+
}): React.JSX.Element {
|
|
706
|
+
const { catalog, models, state, dispatch } = props
|
|
707
|
+
const pack = catalog.packs.find((p) => p.id === state.modelPack)
|
|
708
|
+
const packTier = (tier: Tier): CatalogPackTier | undefined =>
|
|
709
|
+
pack?.tiers.find((t) => t.tier === tier)
|
|
710
|
+
const tierModel = (tier: Tier): string | undefined => packTier(tier)?.model
|
|
711
|
+
return (
|
|
712
|
+
<div className="as-row as-tier-overrides">
|
|
713
|
+
<div className="as-row-main">
|
|
714
|
+
<div className="as-row-desc">
|
|
715
|
+
Applies to every agent in a tier. Per-agent overrides take priority.
|
|
716
|
+
</div>
|
|
717
|
+
</div>
|
|
718
|
+
<div className="as-tier-fields">
|
|
719
|
+
{TIER_ORDER.map((tier) => {
|
|
720
|
+
const id = `as-tier-${tier}`
|
|
721
|
+
const effortId = `as-tier-effort-${tier}`
|
|
722
|
+
const raw = state.vars[`model.${tier}`]
|
|
723
|
+
const value = typeof raw === "string" ? raw : ""
|
|
724
|
+
const fallback = tierModel(tier)
|
|
725
|
+
const packEffort = packTier(tier)?.effort
|
|
726
|
+
const effort = state.tierThinking[tier]?.reasoningEffort ?? ""
|
|
727
|
+
const packLabel = packEffort ? `pack default (${packEffort})` : "pack default"
|
|
728
|
+
return (
|
|
729
|
+
<div className="as-tier-field" key={tier}>
|
|
730
|
+
<label className="as-label" htmlFor={id}>
|
|
731
|
+
{tier} <span className="as-tier-hint">tier</span>
|
|
732
|
+
</label>
|
|
733
|
+
<ModelPicker
|
|
734
|
+
id={id}
|
|
735
|
+
models={models}
|
|
736
|
+
placeholder={fallback ?? "keep pack default"}
|
|
737
|
+
value={value}
|
|
738
|
+
onChange={(v) => dispatch({ t: "tierModel", tier, model: v })}
|
|
739
|
+
/>
|
|
740
|
+
<label className="as-tier-sublabel" htmlFor={effortId}>
|
|
741
|
+
Reasoning effort
|
|
742
|
+
</label>
|
|
743
|
+
<NativeSelect
|
|
744
|
+
id={effortId}
|
|
745
|
+
ariaLabel={`${tier} tier reasoning effort`}
|
|
746
|
+
value={effort}
|
|
747
|
+
onChange={(v) => dispatch({ t: "tierEffort", tier, effort: v })}
|
|
748
|
+
options={[
|
|
749
|
+
{ value: "", label: packLabel },
|
|
750
|
+
...catalog.reasoningEfforts.map((r) => ({ value: r, label: r })),
|
|
751
|
+
]}
|
|
752
|
+
/>
|
|
753
|
+
</div>
|
|
754
|
+
)
|
|
755
|
+
})}
|
|
756
|
+
</div>
|
|
757
|
+
</div>
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function CheckIcon(): React.JSX.Element {
|
|
762
|
+
return (
|
|
763
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false">
|
|
764
|
+
<path
|
|
765
|
+
d="M1.5 5.5 L4 8 L8.5 2.5"
|
|
766
|
+
stroke="currentColor"
|
|
767
|
+
strokeWidth="1.8"
|
|
768
|
+
strokeLinecap="round"
|
|
769
|
+
strokeLinejoin="round"
|
|
770
|
+
/>
|
|
771
|
+
</svg>
|
|
772
|
+
)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
type AddItem = { name: string; label: string; description: string }
|
|
776
|
+
|
|
777
|
+
/** One selectable row inside the add popover. */
|
|
778
|
+
function AddRow(props: {
|
|
779
|
+
item: AddItem
|
|
780
|
+
on: boolean
|
|
781
|
+
onToggle: () => void
|
|
782
|
+
}): React.JSX.Element {
|
|
783
|
+
return (
|
|
784
|
+
<button type="button" className="as-add-item" aria-pressed={props.on} onClick={props.onToggle} title={props.item.description || undefined}>
|
|
785
|
+
<span className="as-add-check" data-on={props.on}>
|
|
786
|
+
{props.on && <CheckIcon />}
|
|
787
|
+
</span>
|
|
788
|
+
<span className="as-add-main">
|
|
789
|
+
<span className="as-row-name">{props.item.label}</span>
|
|
790
|
+
{props.item.description && <span className="as-row-desc">{props.item.description}</span>}
|
|
791
|
+
</span>
|
|
792
|
+
</button>
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Per-section "+ Add" popover: search input on top, keyboard-navigable
|
|
798
|
+
* multiselect checklist below. Items already shown in the section (present in
|
|
799
|
+
* the preview list, or already added as extras) are filtered out — you can
|
|
800
|
+
* only add things not there yet; when nothing remains a note says so. Stays
|
|
801
|
+
* open across picks so several items can be added in one visit.
|
|
802
|
+
*
|
|
803
|
+
* For skills WITH graph data the list is grouped: each skills group renders a
|
|
804
|
+
* header (label + member count badge + "Add all" that adds every not-yet-shown
|
|
805
|
+
* member), its members underneath, and any ungrouped skills follow under an
|
|
806
|
+
* "other" header. Without graph data (or for other kinds) it is a flat list.
|
|
807
|
+
*/
|
|
808
|
+
function AddPopover(props: {
|
|
809
|
+
kind: ExtraKind
|
|
810
|
+
label: string
|
|
811
|
+
items: AddItem[]
|
|
812
|
+
/** Names already added as extras (rendered as rows, toggle off on re-click). */
|
|
813
|
+
added: string[]
|
|
814
|
+
/** Names already present in the section (preview list) — filtered out. */
|
|
815
|
+
present?: string[]
|
|
816
|
+
graph: GraphInfo | null
|
|
817
|
+
dispatch: React.Dispatch<Action>
|
|
818
|
+
}): React.JSX.Element | null {
|
|
819
|
+
const [query, setQuery] = useState("")
|
|
820
|
+
const listRef = useRef<HTMLDivElement | null>(null)
|
|
821
|
+
if (props.items.length === 0) return null
|
|
822
|
+
|
|
823
|
+
// Hide anything already visible in the section: present in the preview list,
|
|
824
|
+
// or already added as an extra. What remains is genuinely addable.
|
|
825
|
+
const shown = new Set([...(props.present ?? []), ...props.added])
|
|
826
|
+
const available = props.items.filter((i) => !shown.has(i.name))
|
|
827
|
+
const nothingLeft = available.length === 0
|
|
828
|
+
|
|
829
|
+
const q = query.trim().toLowerCase()
|
|
830
|
+
const matches = (i: AddItem): boolean =>
|
|
831
|
+
!q ||
|
|
832
|
+
i.name.toLowerCase().includes(q) ||
|
|
833
|
+
i.label.toLowerCase().includes(q) ||
|
|
834
|
+
i.description.toLowerCase().includes(q)
|
|
835
|
+
const filtered = available.filter(matches)
|
|
836
|
+
|
|
837
|
+
// Group-aware layout for skills when graph data exists: group headers with an
|
|
838
|
+
// "Add all", members under each, ungrouped skills under an "other" header.
|
|
839
|
+
const grouped = props.kind === "skill" && props.graph !== null
|
|
840
|
+
const byName = new Map(filtered.map((i) => [i.name, i]))
|
|
841
|
+
const sections: { key: string; label: string; members: AddItem[] }[] = []
|
|
842
|
+
if (grouped) {
|
|
843
|
+
const claimed = new Set<string>()
|
|
844
|
+
for (const [gid, memberIds] of props.graph!.groups) {
|
|
845
|
+
const members: AddItem[] = []
|
|
846
|
+
for (const mid of memberIds) {
|
|
847
|
+
const name = mid.startsWith("skill:") ? mid.slice("skill:".length) : mid
|
|
848
|
+
const item = byName.get(name)
|
|
849
|
+
if (item) {
|
|
850
|
+
members.push(item)
|
|
851
|
+
claimed.add(name)
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (members.length > 0) {
|
|
855
|
+
sections.push({ key: gid, label: props.graph!.labels.get(gid) ?? gid.split(":").pop() ?? gid, members })
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
sections.sort((a, b) => a.label.localeCompare(b.label))
|
|
859
|
+
const other = filtered.filter((i) => !claimed.has(i.name))
|
|
860
|
+
if (other.length > 0) sections.push({ key: "__other", label: "other", members: other })
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function onListKeyDown(e: React.KeyboardEvent): void {
|
|
864
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
|
|
865
|
+
e.preventDefault()
|
|
866
|
+
const options = [...(listRef.current?.querySelectorAll<HTMLButtonElement>(".as-add-item") ?? [])]
|
|
867
|
+
if (options.length === 0) return
|
|
868
|
+
const idx = options.indexOf(document.activeElement as HTMLButtonElement)
|
|
869
|
+
// From the search input (idx === -1): ArrowDown → first, ArrowUp → last.
|
|
870
|
+
// Within the list: step and wrap at both ends.
|
|
871
|
+
let next: number
|
|
872
|
+
if (idx === -1) {
|
|
873
|
+
next = e.key === "ArrowDown" ? 0 : options.length - 1
|
|
874
|
+
} else if (e.key === "ArrowDown") {
|
|
875
|
+
next = (idx + 1) % options.length
|
|
876
|
+
} else {
|
|
877
|
+
next = (idx - 1 + options.length) % options.length
|
|
878
|
+
}
|
|
879
|
+
options[next]?.focus()
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const toggle = (name: string, on: boolean): void =>
|
|
883
|
+
props.dispatch({ t: "extra", kind: props.kind, name, on })
|
|
884
|
+
const addAll = (members: AddItem[]): void => {
|
|
885
|
+
// Respect the present/added filter: `members` already excludes shown items.
|
|
886
|
+
for (const m of members) toggle(m.name, true)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return (
|
|
890
|
+
<Popover.Root onOpenChange={(open) => !open && setQuery("")}>
|
|
891
|
+
<Popover.Trigger className="as-btn" data-size="sm" aria-label={`Add ${props.label}`}>
|
|
892
|
+
+ Add
|
|
893
|
+
</Popover.Trigger>
|
|
894
|
+
<Popover.Portal>
|
|
895
|
+
<Popover.Positioner className="as-positioner" sideOffset={6} align="end">
|
|
896
|
+
<Popover.Popup className="as-popover as-add-popup" aria-label={`add ${props.label}`}>
|
|
897
|
+
{nothingLeft ? (
|
|
898
|
+
<p className="as-combo-empty">everything already added</p>
|
|
899
|
+
) : (
|
|
900
|
+
<>
|
|
901
|
+
<input
|
|
902
|
+
className="as-input as-add-search"
|
|
903
|
+
placeholder={`search ${props.label.toLowerCase()}`}
|
|
904
|
+
value={query}
|
|
905
|
+
aria-label={`search ${props.label}`}
|
|
906
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
907
|
+
onKeyDown={onListKeyDown}
|
|
908
|
+
/>
|
|
909
|
+
<div className="as-add-list" ref={listRef} onKeyDown={onListKeyDown}>
|
|
910
|
+
{grouped
|
|
911
|
+
? sections.map((sec) => (
|
|
912
|
+
<div className="as-add-group" key={sec.key}>
|
|
913
|
+
<div className="as-add-group-head">
|
|
914
|
+
<span className="as-add-group-label">{sec.label}</span>
|
|
915
|
+
<span className="as-count as-add-group-count">{sec.members.length}</span>
|
|
916
|
+
{sec.key !== "__other" && (
|
|
917
|
+
<button
|
|
918
|
+
type="button"
|
|
919
|
+
className="as-btn"
|
|
920
|
+
data-size="sm"
|
|
921
|
+
onClick={() => addAll(sec.members)}
|
|
922
|
+
>
|
|
923
|
+
Add all
|
|
924
|
+
</button>
|
|
925
|
+
)}
|
|
926
|
+
</div>
|
|
927
|
+
{sec.members.map((item) => (
|
|
928
|
+
<AddRow
|
|
929
|
+
key={item.name}
|
|
930
|
+
item={item}
|
|
931
|
+
on={props.added.includes(item.name)}
|
|
932
|
+
onToggle={() => toggle(item.name, !props.added.includes(item.name))}
|
|
933
|
+
/>
|
|
934
|
+
))}
|
|
935
|
+
</div>
|
|
936
|
+
))
|
|
937
|
+
: filtered.map((item) => (
|
|
938
|
+
<AddRow
|
|
939
|
+
key={item.name}
|
|
940
|
+
item={item}
|
|
941
|
+
on={props.added.includes(item.name)}
|
|
942
|
+
onToggle={() => toggle(item.name, !props.added.includes(item.name))}
|
|
943
|
+
/>
|
|
944
|
+
))}
|
|
945
|
+
{filtered.length === 0 && <p className="as-combo-empty">no matches</p>}
|
|
946
|
+
</div>
|
|
947
|
+
</>
|
|
948
|
+
)}
|
|
949
|
+
</Popover.Popup>
|
|
950
|
+
</Popover.Positioner>
|
|
951
|
+
</Popover.Portal>
|
|
952
|
+
</Popover.Root>
|
|
953
|
+
)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Config preview sheet: Base UI Dialog styled as a right-side drawer with an
|
|
958
|
+
* opencode.json / AGENTS.md tab switch. The debounced preview keeps its
|
|
959
|
+
* fixed-width "updating…" slot and the content dims while stale — zero
|
|
960
|
+
* layout shift.
|
|
961
|
+
*/
|
|
962
|
+
function PreviewSheet(props: {
|
|
963
|
+
open: boolean
|
|
964
|
+
onOpenChange: (open: boolean) => void
|
|
965
|
+
tab: "opencode" | "agentsmd"
|
|
966
|
+
onTab: (tab: "opencode" | "agentsmd") => void
|
|
967
|
+
files: PreviewFiles | null
|
|
968
|
+
loading: boolean
|
|
969
|
+
}): React.JSX.Element {
|
|
970
|
+
return (
|
|
971
|
+
<Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
|
|
972
|
+
<Dialog.Portal>
|
|
973
|
+
<Dialog.Backdrop className="as-sheet-backdrop" />
|
|
974
|
+
<Dialog.Popup className="as-sheet" data-loading={props.loading}>
|
|
975
|
+
<div className="as-sheet-head">
|
|
976
|
+
<Dialog.Title className="as-sheet-title">
|
|
977
|
+
Generated files
|
|
978
|
+
<span className="as-preview-status" role="status">
|
|
979
|
+
{props.loading ? "updating…" : ""}
|
|
980
|
+
</span>
|
|
981
|
+
</Dialog.Title>
|
|
982
|
+
<Dialog.Close className="as-banner-close" aria-label="close preview">
|
|
983
|
+
×
|
|
984
|
+
</Dialog.Close>
|
|
985
|
+
</div>
|
|
986
|
+
<Seg
|
|
987
|
+
ariaLabel="preview file"
|
|
988
|
+
value={props.tab}
|
|
989
|
+
onChange={(v) => props.onTab(v as "opencode" | "agentsmd")}
|
|
990
|
+
options={[
|
|
991
|
+
{ value: "opencode", label: "opencode.json" },
|
|
992
|
+
{ value: "agentsmd", label: "AGENTS.md" },
|
|
993
|
+
]}
|
|
994
|
+
/>
|
|
995
|
+
<pre className="as-code as-sheet-code">
|
|
996
|
+
{props.tab === "opencode"
|
|
997
|
+
? props.files?.opencodeJson || "(nothing to write)"
|
|
998
|
+
: props.files?.agentsMd ?? "(AGENTS.md block disabled)"}
|
|
999
|
+
</pre>
|
|
1000
|
+
</Dialog.Popup>
|
|
1001
|
+
</Dialog.Portal>
|
|
1002
|
+
</Dialog.Root>
|
|
1003
|
+
)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/* ================================================================== */
|
|
1007
|
+
/* app */
|
|
1008
|
+
/* ================================================================== */
|
|
1009
|
+
|
|
1010
|
+
function App(): React.JSX.Element {
|
|
1011
|
+
const [catalog, setCatalog] = useState<CatalogResponse | null>(null)
|
|
1012
|
+
const [existing, setExisting] = useState<StateResponse | null>(null)
|
|
1013
|
+
const [screen, setScreen] = useState<Screen>("loading")
|
|
1014
|
+
const [bootError, setBootError] = useState<string | null>(null)
|
|
1015
|
+
const [state, dispatch] = useReducer(reducer, null as unknown as WizardState)
|
|
1016
|
+
const [result, setResult] = useState<InstallResponse | null>(null)
|
|
1017
|
+
const [installError, setInstallError] = useState<string | null>(null)
|
|
1018
|
+
const [setupNotice, setSetupNotice] = useState<string | null>(null)
|
|
1019
|
+
const [projects, setProjects] = useState<ProjectInfo[]>([])
|
|
1020
|
+
const [project, setProject] = useState("")
|
|
1021
|
+
const [models, setModels] = useState<ModelsResponse | null>(null)
|
|
1022
|
+
const [banner, setBanner] = useState<string | null>(null)
|
|
1023
|
+
const [graph, setGraph] = useState<GraphInfo | null>(null)
|
|
1024
|
+
// True once the user asked to close: we POST /api/shutdown, try window.close(),
|
|
1025
|
+
// and (since browsers usually block script-close of a user-opened tab) swap to
|
|
1026
|
+
// a terminal "you can close this tab" panel.
|
|
1027
|
+
const [stopped, setStopped] = useState(false)
|
|
1028
|
+
|
|
1029
|
+
// Boot: load catalog + projects + models once (catalog/models are project-
|
|
1030
|
+
// independent). State is (re)loaded per selected project below.
|
|
1031
|
+
useEffect(() => {
|
|
1032
|
+
Promise.all([
|
|
1033
|
+
apiGet<CatalogResponse>("/api/catalog"),
|
|
1034
|
+
apiGet<ProjectsResponse>("/api/projects"),
|
|
1035
|
+
apiGet<ModelsResponse>("/api/models"),
|
|
1036
|
+
])
|
|
1037
|
+
.then(([cat, proj, mods]) => {
|
|
1038
|
+
setCatalog(cat)
|
|
1039
|
+
setProjects(proj.projects)
|
|
1040
|
+
setModels(mods)
|
|
1041
|
+
})
|
|
1042
|
+
.catch((e: Error) => {
|
|
1043
|
+
setBootError(e.message)
|
|
1044
|
+
setScreen("error")
|
|
1045
|
+
})
|
|
1046
|
+
}, [])
|
|
1047
|
+
|
|
1048
|
+
// (Re)load the selected project's on-disk state whenever project or catalog
|
|
1049
|
+
// changes. Also runs on first load once catalog is ready.
|
|
1050
|
+
useEffect(() => {
|
|
1051
|
+
if (!catalog) return
|
|
1052
|
+
let alive = true
|
|
1053
|
+
const forProject = project
|
|
1054
|
+
setCurrentProject(project)
|
|
1055
|
+
loadState()
|
|
1056
|
+
.then((st) => {
|
|
1057
|
+
// Ignore a stale resolution: the project changed (or unmounted) while
|
|
1058
|
+
// this request was in flight, so a newer effect owns the state now.
|
|
1059
|
+
if (!alive || forProject !== currentProject) return
|
|
1060
|
+
setExisting(st)
|
|
1061
|
+
dispatch({ t: "replace", state: preloadState(catalog, st) })
|
|
1062
|
+
setScreen(st.installed ? "current" : "setup")
|
|
1063
|
+
})
|
|
1064
|
+
.catch((e: Error) => {
|
|
1065
|
+
if (!alive || forProject !== currentProject) return
|
|
1066
|
+
setBootError(e.message)
|
|
1067
|
+
setScreen("error")
|
|
1068
|
+
})
|
|
1069
|
+
return () => {
|
|
1070
|
+
alive = false
|
|
1071
|
+
}
|
|
1072
|
+
}, [catalog, project])
|
|
1073
|
+
|
|
1074
|
+
// Dependency graph, once per project. Purely additive hints: any failure
|
|
1075
|
+
// leaves graph=null and the UI renders without dependency chips/locks.
|
|
1076
|
+
useEffect(() => {
|
|
1077
|
+
if (!catalog) return
|
|
1078
|
+
let alive = true
|
|
1079
|
+
const forProject = project
|
|
1080
|
+
setCurrentProject(project)
|
|
1081
|
+
apiGet<GraphResponse>("/api/graph")
|
|
1082
|
+
.then((g) => {
|
|
1083
|
+
if (!alive || forProject !== currentProject) return
|
|
1084
|
+
setGraph(buildGraphInfo(g))
|
|
1085
|
+
})
|
|
1086
|
+
.catch(() => {
|
|
1087
|
+
if (!alive || forProject !== currentProject) return
|
|
1088
|
+
setGraph(null)
|
|
1089
|
+
})
|
|
1090
|
+
return () => {
|
|
1091
|
+
alive = false
|
|
1092
|
+
}
|
|
1093
|
+
}, [catalog, project])
|
|
1094
|
+
|
|
1095
|
+
const previewActive = screen === "review"
|
|
1096
|
+
const { preview, files, deletions, loading, error } = usePreview(state, previewActive)
|
|
1097
|
+
|
|
1098
|
+
if (screen === "loading") return <Shell><p className="as-spinner">loading…</p></Shell>
|
|
1099
|
+
if (screen === "error")
|
|
1100
|
+
return (
|
|
1101
|
+
<Shell>
|
|
1102
|
+
<p className="as-error">could not load: {bootError}</p>
|
|
1103
|
+
</Shell>
|
|
1104
|
+
)
|
|
1105
|
+
if (!catalog) return <Shell><p className="as-spinner">loading…</p></Shell>
|
|
1106
|
+
|
|
1107
|
+
async function doInstall(): Promise<void> {
|
|
1108
|
+
setScreen("installing")
|
|
1109
|
+
setInstallError(null)
|
|
1110
|
+
try {
|
|
1111
|
+
const r = await apiPost<InstallResponse>("/api/install", { state })
|
|
1112
|
+
setResult(r)
|
|
1113
|
+
// No dead-end: re-fetch state and return to the current/setup screen with
|
|
1114
|
+
// a dismissible success banner (result files/notes stay reachable).
|
|
1115
|
+
const st = await loadState()
|
|
1116
|
+
setExisting(st)
|
|
1117
|
+
dispatch({ t: "replace", state: preloadState(catalog!, st) })
|
|
1118
|
+
setBanner(`Installed ${r.installed} item(s) → ${r.configPath}`)
|
|
1119
|
+
setScreen(st.installed ? "current" : "setup")
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
setInstallError((e as Error).message)
|
|
1122
|
+
setScreen("review")
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async function onModify(): Promise<void> {
|
|
1127
|
+
// Refetch on-disk state first: the config/lock may have changed externally
|
|
1128
|
+
// since the initial load, and a sync-install replaces the lock with the
|
|
1129
|
+
// selection closure — planning against a stale snapshot could delete or
|
|
1130
|
+
// resurrect items. Preload fresh state BEFORE showing the setup screen.
|
|
1131
|
+
try {
|
|
1132
|
+
const st = await loadState()
|
|
1133
|
+
setExisting(st)
|
|
1134
|
+
dispatch({ t: "replace", state: preloadState(catalog!, st) })
|
|
1135
|
+
if (st.presetBased) {
|
|
1136
|
+
setSetupNotice(null)
|
|
1137
|
+
} else {
|
|
1138
|
+
setSetupNotice(
|
|
1139
|
+
"This existing install was not preset-based: choose one or more presets to (re)plan on top of it; unrelated installed items stay.",
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1142
|
+
} catch {
|
|
1143
|
+
// Fall back to the last-known `existing` snapshot on a fetch failure so
|
|
1144
|
+
// Modify still works offline; the notice mirrors that snapshot.
|
|
1145
|
+
setSetupNotice(
|
|
1146
|
+
existing?.presetBased
|
|
1147
|
+
? null
|
|
1148
|
+
: "This existing install was not preset-based: choose one or more presets to (re)plan on top of it; unrelated installed items stay.",
|
|
1149
|
+
)
|
|
1150
|
+
}
|
|
1151
|
+
setScreen("setup")
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function onSwitchProject(p: string): void {
|
|
1155
|
+
setBanner(null)
|
|
1156
|
+
setResult(null)
|
|
1157
|
+
setProject(p) // triggers the state-reload effect + screen reset.
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async function onClose(): Promise<void> {
|
|
1161
|
+
// Ask the server to stop, then try to close the tab. window.close() only
|
|
1162
|
+
// works for script-opened windows, so we always fall back to the terminal
|
|
1163
|
+
// panel telling the user they can close it themselves.
|
|
1164
|
+
try {
|
|
1165
|
+
await shutdown()
|
|
1166
|
+
} catch {
|
|
1167
|
+
// Server may drop the socket as it exits — that's still a successful stop.
|
|
1168
|
+
}
|
|
1169
|
+
setStopped(true)
|
|
1170
|
+
window.close()
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (stopped) {
|
|
1174
|
+
return (
|
|
1175
|
+
<Shell>
|
|
1176
|
+
<section className="as-section as-stopped" role="status">
|
|
1177
|
+
<h2 className="as-section-title">Server stopped</h2>
|
|
1178
|
+
<p className="as-section-hint">You can close this tab. AgentZ server stopped.</p>
|
|
1179
|
+
</section>
|
|
1180
|
+
</Shell>
|
|
1181
|
+
)
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
return (
|
|
1185
|
+
<Tooltip.Provider>
|
|
1186
|
+
<Shell
|
|
1187
|
+
screen={screen}
|
|
1188
|
+
projects={projects}
|
|
1189
|
+
project={project}
|
|
1190
|
+
onProject={onSwitchProject}
|
|
1191
|
+
>
|
|
1192
|
+
{banner && (
|
|
1193
|
+
<div className="as-banner" role="status">
|
|
1194
|
+
<span className="as-ok">{banner}</span>
|
|
1195
|
+
{result && result.files.length > 0 && (
|
|
1196
|
+
<span className="as-banner-detail"> · {result.files.length} file(s) changed</span>
|
|
1197
|
+
)}
|
|
1198
|
+
<button className="as-btn" data-size="sm" onClick={onClose}>
|
|
1199
|
+
Close
|
|
1200
|
+
</button>
|
|
1201
|
+
<button className="as-banner-close" aria-label="dismiss" onClick={() => setBanner(null)}>
|
|
1202
|
+
×
|
|
1203
|
+
</button>
|
|
1204
|
+
</div>
|
|
1205
|
+
)}
|
|
1206
|
+
{result && banner && <ResultDetails result={result} />}
|
|
1207
|
+
{screen === "current" && existing && (
|
|
1208
|
+
<CurrentInstall state={existing} onModify={onModify} />
|
|
1209
|
+
)}
|
|
1210
|
+
{screen === "setup" && (
|
|
1211
|
+
<Setup
|
|
1212
|
+
catalog={catalog}
|
|
1213
|
+
graph={graph}
|
|
1214
|
+
state={state}
|
|
1215
|
+
dispatch={dispatch}
|
|
1216
|
+
notice={setupNotice}
|
|
1217
|
+
onNext={() => setScreen("review")}
|
|
1218
|
+
/>
|
|
1219
|
+
)}
|
|
1220
|
+
{screen === "review" && (
|
|
1221
|
+
<Review
|
|
1222
|
+
catalog={catalog}
|
|
1223
|
+
graph={graph}
|
|
1224
|
+
models={models}
|
|
1225
|
+
state={state}
|
|
1226
|
+
dispatch={dispatch}
|
|
1227
|
+
preview={preview}
|
|
1228
|
+
files={files}
|
|
1229
|
+
deletions={deletions}
|
|
1230
|
+
loading={loading}
|
|
1231
|
+
error={error ?? installError}
|
|
1232
|
+
onBack={() => setScreen(existing?.installed ? "current" : "setup")}
|
|
1233
|
+
onInstall={doInstall}
|
|
1234
|
+
/>
|
|
1235
|
+
)}
|
|
1236
|
+
{screen === "installing" && <p className="as-spinner">installing…</p>}
|
|
1237
|
+
</Shell>
|
|
1238
|
+
</Tooltip.Provider>
|
|
1239
|
+
)
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function agentsMdValue(
|
|
1243
|
+
chosen: WizardState["agentsMd"],
|
|
1244
|
+
fallback: PreviewModel["agentsMd"] | undefined,
|
|
1245
|
+
): string {
|
|
1246
|
+
const v = chosen ?? fallback ?? "lean"
|
|
1247
|
+
return v === false ? "off" : v
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function preloadState(catalog: CatalogResponse, st: StateResponse): WizardState {
|
|
1251
|
+
const base = defaultState(catalog)
|
|
1252
|
+
if (!st.installed) return base
|
|
1253
|
+
// Preset-based installs recover ALL their presets (multi-select preserved) so
|
|
1254
|
+
// the setup screen preselects every one and the user can toggle any of them.
|
|
1255
|
+
// Non-preset installs keep the default preset as a starting point; Modify
|
|
1256
|
+
// routes to setup with a notice, and the user picks presets from scratch.
|
|
1257
|
+
const presetNames =
|
|
1258
|
+
st.presetBased && st.presets && st.presets.length > 0
|
|
1259
|
+
? st.presets
|
|
1260
|
+
: st.presetBased && st.preset
|
|
1261
|
+
? [st.preset]
|
|
1262
|
+
: [base.presetNames[0]!]
|
|
1263
|
+
return {
|
|
1264
|
+
...base,
|
|
1265
|
+
presetNames,
|
|
1266
|
+
modelPack: st.modelPack ?? base.modelPack,
|
|
1267
|
+
targets: (st.targets as WizardState["targets"] | undefined) ?? base.targets,
|
|
1268
|
+
scope: st.scope ?? base.scope,
|
|
1269
|
+
// Restore template vars from the on-disk config so per-tier model overrides
|
|
1270
|
+
// (model.cheap|normal|expert) and any flags survive a Modify/reinstall
|
|
1271
|
+
// instead of being reset to defaults. Defaults seed first, config wins.
|
|
1272
|
+
vars: st.vars && Object.keys(st.vars).length > 0 ? { ...base.vars, ...st.vars } : base.vars,
|
|
1273
|
+
// Restore existing per-agent overrides from the on-disk config so the
|
|
1274
|
+
// edge-case per-agent path (model/effort) survives a Modify/reinstall
|
|
1275
|
+
// round-trip instead of being dropped. Config wins over the empty default.
|
|
1276
|
+
agents: st.agents && Object.keys(st.agents).length > 0 ? st.agents : base.agents,
|
|
1277
|
+
// Restore per-tier thinking defaults from the on-disk config so the tier
|
|
1278
|
+
// reasoning-effort controls survive a Modify/reinstall round-trip.
|
|
1279
|
+
tierThinking:
|
|
1280
|
+
st.tierThinking && Object.keys(st.tierThinking).length > 0
|
|
1281
|
+
? st.tierThinking
|
|
1282
|
+
: base.tierThinking,
|
|
1283
|
+
// Restore USER-ADDED extras from the lock so a subsequent sync-install does
|
|
1284
|
+
// not delete them (each `type:name` ref → its `extra` bucket). Without this
|
|
1285
|
+
// the wizard would resolve to preset closure only and prune every extra.
|
|
1286
|
+
extra: restoreExtras(st.extras),
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** Map lock `type:name` extra refs back into WizardState `extra` buckets. */
|
|
1291
|
+
function restoreExtras(refs: string[] | undefined): WizardState["extra"] {
|
|
1292
|
+
if (!refs || refs.length === 0) return undefined
|
|
1293
|
+
const buckets: Partial<Record<ExtraKind, string[]>> = {}
|
|
1294
|
+
const kinds: ReadonlySet<string> = new Set([
|
|
1295
|
+
"agent",
|
|
1296
|
+
"skill",
|
|
1297
|
+
"tool",
|
|
1298
|
+
"plugin",
|
|
1299
|
+
"mcp",
|
|
1300
|
+
"command",
|
|
1301
|
+
"rule",
|
|
1302
|
+
"lsp",
|
|
1303
|
+
])
|
|
1304
|
+
for (const ref of refs) {
|
|
1305
|
+
const sep = ref.indexOf(":")
|
|
1306
|
+
if (sep < 0) continue
|
|
1307
|
+
const kind = ref.slice(0, sep)
|
|
1308
|
+
const name = ref.slice(sep + 1)
|
|
1309
|
+
if (!name || !kinds.has(kind)) continue
|
|
1310
|
+
;(buckets[kind as ExtraKind] ??= []).push(name)
|
|
1311
|
+
}
|
|
1312
|
+
return Object.keys(buckets).length > 0 ? buckets : undefined
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/* ================================================================== */
|
|
1316
|
+
/* shell + step header */
|
|
1317
|
+
/* ================================================================== */
|
|
1318
|
+
|
|
1319
|
+
const STEP_LABELS: { id: Screen; label: string }[] = [
|
|
1320
|
+
{ id: "setup", label: "Setup" },
|
|
1321
|
+
{ id: "review", label: "Review" },
|
|
1322
|
+
{ id: "done", label: "Install" },
|
|
1323
|
+
]
|
|
1324
|
+
|
|
1325
|
+
function Shell(props: {
|
|
1326
|
+
children: React.ReactNode
|
|
1327
|
+
screen?: Screen
|
|
1328
|
+
projects?: ProjectInfo[]
|
|
1329
|
+
project?: string
|
|
1330
|
+
onProject?: (p: string) => void
|
|
1331
|
+
}): React.JSX.Element {
|
|
1332
|
+
const showSwitcher = (props.projects?.length ?? 0) > 1
|
|
1333
|
+
return (
|
|
1334
|
+
<div className="as-app">
|
|
1335
|
+
<header className="as-header">
|
|
1336
|
+
<h1 className="as-title">
|
|
1337
|
+
<img className="as-logo" src="/logo.png" alt="" />
|
|
1338
|
+
AgentZ
|
|
1339
|
+
</h1>
|
|
1340
|
+
{showSwitcher && props.onProject && (
|
|
1341
|
+
<label className="as-project-switch">
|
|
1342
|
+
<span className="as-label">project</span>
|
|
1343
|
+
<select
|
|
1344
|
+
className="as-select"
|
|
1345
|
+
value={props.project ?? ""}
|
|
1346
|
+
onChange={(e) => props.onProject!(e.target.value)}
|
|
1347
|
+
>
|
|
1348
|
+
{props.projects!.map((p) => (
|
|
1349
|
+
<option key={p.path} value={p.path}>
|
|
1350
|
+
{p.label}
|
|
1351
|
+
</option>
|
|
1352
|
+
))}
|
|
1353
|
+
</select>
|
|
1354
|
+
</label>
|
|
1355
|
+
)}
|
|
1356
|
+
<ol className="as-steps">
|
|
1357
|
+
{STEP_LABELS.map((s) => (
|
|
1358
|
+
<li
|
|
1359
|
+
key={s.id}
|
|
1360
|
+
className="as-step"
|
|
1361
|
+
data-active={props.screen === s.id || (s.id === "done" && props.screen === "installing")}
|
|
1362
|
+
>
|
|
1363
|
+
{s.label}
|
|
1364
|
+
</li>
|
|
1365
|
+
))}
|
|
1366
|
+
</ol>
|
|
1367
|
+
</header>
|
|
1368
|
+
{props.children}
|
|
1369
|
+
</div>
|
|
1370
|
+
)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/* ================================================================== */
|
|
1374
|
+
/* current install summary */
|
|
1375
|
+
/* ================================================================== */
|
|
1376
|
+
|
|
1377
|
+
function CurrentInstall(props: { state: StateResponse; onModify: () => void }): React.JSX.Element {
|
|
1378
|
+
const s = props.state
|
|
1379
|
+
const groups = groupLockedRefs(s.lockedRefs ?? [])
|
|
1380
|
+
return (
|
|
1381
|
+
<section className="as-section">
|
|
1382
|
+
<h2 className="as-section-title">Current install</h2>
|
|
1383
|
+
<p className="as-section-hint">This project already has AgentZ configured.</p>
|
|
1384
|
+
<div className="as-panel">
|
|
1385
|
+
<dl className="as-summary-grid">
|
|
1386
|
+
{s.preset && (<><dt>preset</dt><dd>{s.preset}</dd></>)}
|
|
1387
|
+
{s.modelPack && (<><dt>model pack</dt><dd>{s.modelPack}</dd></>)}
|
|
1388
|
+
{s.targets && (<><dt>targets</dt><dd>{s.targets.join(", ")}</dd></>)}
|
|
1389
|
+
{s.scope && (<><dt>scope</dt><dd>{s.scope}</dd></>)}
|
|
1390
|
+
<dt>locked items</dt>
|
|
1391
|
+
<dd>{s.lockedItems ?? 0}</dd>
|
|
1392
|
+
</dl>
|
|
1393
|
+
</div>
|
|
1394
|
+
{groups.length > 0 && (
|
|
1395
|
+
<div className="as-panel">
|
|
1396
|
+
<div className="as-row-name">Installed items</div>
|
|
1397
|
+
<dl className="as-summary-grid">
|
|
1398
|
+
{groups.map((g) => (
|
|
1399
|
+
<Fragment key={g.type}>
|
|
1400
|
+
<dt>{g.type}</dt>
|
|
1401
|
+
<dd>{g.names.join(", ")}</dd>
|
|
1402
|
+
</Fragment>
|
|
1403
|
+
))}
|
|
1404
|
+
</dl>
|
|
1405
|
+
</div>
|
|
1406
|
+
)}
|
|
1407
|
+
<div className="as-actions">
|
|
1408
|
+
<button className="as-btn" data-variant="primary" onClick={props.onModify}>
|
|
1409
|
+
Modify
|
|
1410
|
+
</button>
|
|
1411
|
+
</div>
|
|
1412
|
+
</section>
|
|
1413
|
+
)
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** Plural, human-readable bucket label per lock ref `type`. */
|
|
1417
|
+
const REF_TYPE_LABELS: Record<string, string> = {
|
|
1418
|
+
agent: "agents",
|
|
1419
|
+
skill: "skills",
|
|
1420
|
+
tool: "tools",
|
|
1421
|
+
plugin: "plugins",
|
|
1422
|
+
command: "commands",
|
|
1423
|
+
mcp: "mcps",
|
|
1424
|
+
lsp: "language servers",
|
|
1425
|
+
preset: "presets",
|
|
1426
|
+
pack: "packs",
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Group lock refs (`[@ns/]type:name`) by type into readable name lists, sorted
|
|
1431
|
+
* by a stable type order then name. So the current-install screen shows WHAT is
|
|
1432
|
+
* installed, not just a count.
|
|
1433
|
+
*/
|
|
1434
|
+
function groupLockedRefs(refs: string[]): { type: string; names: string[] }[] {
|
|
1435
|
+
const buckets = new Map<string, Set<string>>()
|
|
1436
|
+
for (const ref of refs) {
|
|
1437
|
+
const m = /(?:^|\/)([a-z]+):(.+)$/.exec(ref)
|
|
1438
|
+
const rawType = m?.[1] ?? "other"
|
|
1439
|
+
const name = m?.[2] ?? ref
|
|
1440
|
+
const label = REF_TYPE_LABELS[rawType] ?? rawType
|
|
1441
|
+
if (!buckets.has(label)) buckets.set(label, new Set())
|
|
1442
|
+
buckets.get(label)!.add(name)
|
|
1443
|
+
}
|
|
1444
|
+
const order = Object.values(REF_TYPE_LABELS)
|
|
1445
|
+
return [...buckets.entries()]
|
|
1446
|
+
.map(([type, set]) => ({ type, names: [...set].sort() }))
|
|
1447
|
+
.sort((a, b) => {
|
|
1448
|
+
const ia = order.indexOf(a.type)
|
|
1449
|
+
const ib = order.indexOf(b.type)
|
|
1450
|
+
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.type.localeCompare(b.type)
|
|
1451
|
+
})
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/* ================================================================== */
|
|
1455
|
+
/* setup picker */
|
|
1456
|
+
/* ================================================================== */
|
|
1457
|
+
|
|
1458
|
+
function Setup(props: {
|
|
1459
|
+
catalog: CatalogResponse
|
|
1460
|
+
graph: GraphInfo | null
|
|
1461
|
+
state: WizardState
|
|
1462
|
+
dispatch: React.Dispatch<Action>
|
|
1463
|
+
notice?: string | null
|
|
1464
|
+
onNext: () => void
|
|
1465
|
+
}): React.JSX.Element {
|
|
1466
|
+
const { catalog, graph, state, dispatch } = props
|
|
1467
|
+
return (
|
|
1468
|
+
<>
|
|
1469
|
+
{props.notice && <p className="as-notice">{props.notice}</p>}
|
|
1470
|
+
<section className="as-section">
|
|
1471
|
+
<h2 className="as-section-title">Choose presets</h2>
|
|
1472
|
+
<p className="as-section-hint">
|
|
1473
|
+
A preset is a curated set of agents, skills and tools. Select one or more.
|
|
1474
|
+
</p>
|
|
1475
|
+
<div className="as-grid">
|
|
1476
|
+
{catalog.presets.map((p) => (
|
|
1477
|
+
<SelectCard
|
|
1478
|
+
key={p.name}
|
|
1479
|
+
title={p.label}
|
|
1480
|
+
description={p.description}
|
|
1481
|
+
selected={state.presetNames.includes(p.name)}
|
|
1482
|
+
graph={graph}
|
|
1483
|
+
graphId={`preset:${p.name}`}
|
|
1484
|
+
onToggle={() => dispatch({ t: "preset", name: p.name })}
|
|
1485
|
+
/>
|
|
1486
|
+
))}
|
|
1487
|
+
</div>
|
|
1488
|
+
</section>
|
|
1489
|
+
|
|
1490
|
+
<section className="as-section">
|
|
1491
|
+
<h2 className="as-section-title">Model pack</h2>
|
|
1492
|
+
<p className="as-section-hint">Controls which models each agent tier uses.</p>
|
|
1493
|
+
<div className="as-grid">
|
|
1494
|
+
{catalog.packs.map((p) => (
|
|
1495
|
+
<SelectCard
|
|
1496
|
+
key={p.id}
|
|
1497
|
+
title={p.label}
|
|
1498
|
+
description={p.description}
|
|
1499
|
+
tiers={p.tiers}
|
|
1500
|
+
selected={state.modelPack === p.id}
|
|
1501
|
+
graph={graph}
|
|
1502
|
+
graphId={`pack:${p.id}`}
|
|
1503
|
+
onToggle={() => dispatch({ t: "pack", pack: p.id })}
|
|
1504
|
+
/>
|
|
1505
|
+
))}
|
|
1506
|
+
</div>
|
|
1507
|
+
</section>
|
|
1508
|
+
|
|
1509
|
+
<div className="as-actions">
|
|
1510
|
+
<button
|
|
1511
|
+
className="as-btn"
|
|
1512
|
+
data-variant="primary"
|
|
1513
|
+
disabled={state.presetNames.length === 0}
|
|
1514
|
+
onClick={props.onNext}
|
|
1515
|
+
>
|
|
1516
|
+
Review plan
|
|
1517
|
+
</button>
|
|
1518
|
+
</div>
|
|
1519
|
+
</>
|
|
1520
|
+
)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Selectable preset/pack card. Outer element is a plain container so the
|
|
1525
|
+
* graph-driven composition <details> can live inside as a sibling without
|
|
1526
|
+
* nested-interactive markup. Selection is a real full-width <button> (native
|
|
1527
|
+
* Enter/Space, no key-handler hacks); the <details> composition sits beside it.
|
|
1528
|
+
* Check indicator space is always reserved -> zero shift on select.
|
|
1529
|
+
*/
|
|
1530
|
+
function SelectCard(props: {
|
|
1531
|
+
title: string
|
|
1532
|
+
description: string
|
|
1533
|
+
tiers?: CatalogPackTier[]
|
|
1534
|
+
selected: boolean
|
|
1535
|
+
graph: GraphInfo | null
|
|
1536
|
+
graphId: string
|
|
1537
|
+
onToggle: () => void
|
|
1538
|
+
}): React.JSX.Element {
|
|
1539
|
+
const counts = props.graph ? memberCounts(props.graph, props.graphId) : null
|
|
1540
|
+
const members = props.graph?.members.get(props.graphId) ?? []
|
|
1541
|
+
return (
|
|
1542
|
+
<div className="as-card" data-selected={props.selected}>
|
|
1543
|
+
<button
|
|
1544
|
+
type="button"
|
|
1545
|
+
className="as-card-select"
|
|
1546
|
+
aria-pressed={props.selected}
|
|
1547
|
+
onClick={props.onToggle}
|
|
1548
|
+
>
|
|
1549
|
+
<span className="as-card-check" aria-hidden="true">
|
|
1550
|
+
<CheckIcon />
|
|
1551
|
+
</span>
|
|
1552
|
+
<span className="as-card-title">{props.title}</span>
|
|
1553
|
+
<span className="as-card-desc">{props.description}</span>
|
|
1554
|
+
{props.tiers && props.tiers.length > 0 && (
|
|
1555
|
+
<span className="as-card-tiers">
|
|
1556
|
+
{props.tiers.map((t) => (
|
|
1557
|
+
<span key={t.tier} className="as-tier-chip">
|
|
1558
|
+
<span className="as-tier-name">{t.tier}</span>
|
|
1559
|
+
<span className="as-tier-model">{t.model}</span>
|
|
1560
|
+
{t.effort && <span className="as-tier-effort">@{t.effort}</span>}
|
|
1561
|
+
</span>
|
|
1562
|
+
))}
|
|
1563
|
+
</span>
|
|
1564
|
+
)}
|
|
1565
|
+
{counts && <span className="as-card-meta">{counts}</span>}
|
|
1566
|
+
</button>
|
|
1567
|
+
{members.length > 0 && (
|
|
1568
|
+
<details className="as-card-comp">
|
|
1569
|
+
<summary>what this pulls in</summary>
|
|
1570
|
+
<ul>
|
|
1571
|
+
{members.map((id) => (
|
|
1572
|
+
<li key={id}>
|
|
1573
|
+
<span className="as-dep-kind">{id.split(":")[0]}</span>
|
|
1574
|
+
{props.graph?.labels.get(id) ?? id.split(":").pop()}
|
|
1575
|
+
</li>
|
|
1576
|
+
))}
|
|
1577
|
+
</ul>
|
|
1578
|
+
</details>
|
|
1579
|
+
)}
|
|
1580
|
+
</div>
|
|
1581
|
+
)
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/* ================================================================== */
|
|
1585
|
+
/* review screen (app shell: main column + sticky rail) */
|
|
1586
|
+
/* ================================================================== */
|
|
1587
|
+
|
|
1588
|
+
function Review(props: {
|
|
1589
|
+
catalog: CatalogResponse
|
|
1590
|
+
graph: GraphInfo | null
|
|
1591
|
+
models: ModelsResponse | null
|
|
1592
|
+
state: WizardState
|
|
1593
|
+
dispatch: React.Dispatch<Action>
|
|
1594
|
+
preview: PreviewModel | null
|
|
1595
|
+
files: PreviewFiles | null
|
|
1596
|
+
deletions: PreviewResponse["deletions"]
|
|
1597
|
+
loading: boolean
|
|
1598
|
+
error: string | null
|
|
1599
|
+
onBack: () => void
|
|
1600
|
+
onInstall: () => void
|
|
1601
|
+
}): React.JSX.Element {
|
|
1602
|
+
const { catalog, graph, models, state, dispatch, preview, files, deletions } = props
|
|
1603
|
+
const primary = catalog.targets.find((t) => t.id === "opencode")
|
|
1604
|
+
const others = catalog.targets.filter((t) => t.id !== "opencode")
|
|
1605
|
+
const [sheetOpen, setSheetOpen] = useState(false)
|
|
1606
|
+
const [sheetTab, setSheetTab] = useState<"opencode" | "agentsmd">("opencode")
|
|
1607
|
+
const [delOpen, setDelOpen] = useState(false)
|
|
1608
|
+
// The "Override" switch controls VISIBILITY only of every agent
|
|
1609
|
+
// row's per-agent model/effort controls (the EDGE-CASE override path; the
|
|
1610
|
+
// primary path is the per-tier ModelPickers in "Targets & options"). It never
|
|
1611
|
+
// mutates overrides: toggling OFF hides default rows' controls but rows that
|
|
1612
|
+
// already carry an override keep showing theirs (AgentRow OR-s `custom` in) so
|
|
1613
|
+
// no active override becomes hidden. Clearing an override is the per-row reset
|
|
1614
|
+
// control's explicit job. Seeded OFF always: even a pre-existing per-agent
|
|
1615
|
+
// override must not auto-open anything (rows with an override still show their
|
|
1616
|
+
// own controls via AgentRow's `custom` OR, so no active override is hidden).
|
|
1617
|
+
const [showOverride, setShowOverride] = useState(false)
|
|
1618
|
+
|
|
1619
|
+
// Enabled item set (plan items minus exclusions) -> dependency-locked set.
|
|
1620
|
+
// Locked items keep their Switch ON and read-only until every enabled
|
|
1621
|
+
// dependent is disabled; graph==null keeps this map empty (no locks).
|
|
1622
|
+
const locks = useMemo(() => {
|
|
1623
|
+
const enabled = new Set<string>()
|
|
1624
|
+
for (const a of preview?.agents ?? []) enabled.add(`agent:${a.id}`)
|
|
1625
|
+
for (const s of preview?.skills ?? []) if (!state.excluded.skill.includes(s.id)) enabled.add(`skill:${s.id}`)
|
|
1626
|
+
for (const t of preview?.tools ?? []) if (!state.excluded.tool.includes(t.id)) enabled.add(`tool:${t.id}`)
|
|
1627
|
+
for (const p of preview?.plugins ?? []) if (!state.excluded.plugin.includes(p.id)) enabled.add(`plugin:${p.id}`)
|
|
1628
|
+
for (const c of preview?.commands ?? []) enabled.add(`command:${c.id}`)
|
|
1629
|
+
for (const r of preview?.rules ?? []) if (!state.excluded.rule.includes(r.id)) enabled.add(`rule:${r.id}`)
|
|
1630
|
+
for (const m of preview?.mcps ?? []) if (!state.excluded.mcp.includes(m.id)) enabled.add(`mcp:${m.id}`)
|
|
1631
|
+
for (const l of preview?.lsps ?? []) if (!state.excluded.lsp.includes(l.id)) enabled.add(`lsp:${l.id}`)
|
|
1632
|
+
return computeLocks(graph, enabled)
|
|
1633
|
+
}, [graph, preview, state.excluded])
|
|
1634
|
+
|
|
1635
|
+
// Reconcile: a dependency-locked item may still sit in state.excluded (it was
|
|
1636
|
+
// toggled off before something started requiring it). ItemSwitch renders it
|
|
1637
|
+
// forced-ON, but the install/preview payload would still exclude it. Re-enable
|
|
1638
|
+
// any locked id whose name is in state.excluded so UI and state agree. Guarded:
|
|
1639
|
+
// only dispatch when actually excluded, so no toggle→re-render→dispatch loop.
|
|
1640
|
+
useEffect(() => {
|
|
1641
|
+
for (const id of locks.keys()) {
|
|
1642
|
+
const sep = id.indexOf(":")
|
|
1643
|
+
const kind = id.slice(0, sep) as keyof WizardState["excluded"]
|
|
1644
|
+
const name = id.slice(sep + 1)
|
|
1645
|
+
if (state.excluded[kind]?.includes(name)) {
|
|
1646
|
+
dispatch({ t: "toggle", kind, name, on: true })
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
}, [locks, state.excluded, dispatch])
|
|
1650
|
+
|
|
1651
|
+
function openSheet(tab: "opencode" | "agentsmd"): void {
|
|
1652
|
+
setSheetTab(tab)
|
|
1653
|
+
setSheetOpen(true)
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const itemCounts = preview
|
|
1657
|
+
? [
|
|
1658
|
+
{ label: "agents", n: preview.agents.length },
|
|
1659
|
+
{ label: "skills", n: preview.skills.filter((s) => !state.excluded.skill.includes(s.id)).length },
|
|
1660
|
+
{ label: "tools", n: preview.tools.filter((t) => !state.excluded.tool.includes(t.id)).length },
|
|
1661
|
+
{ label: "plugins", n: preview.plugins.filter((p) => !state.excluded.plugin.includes(p.id)).length },
|
|
1662
|
+
{ label: "commands", n: preview.commands.length },
|
|
1663
|
+
{ label: "rules", n: preview.rules.filter((r) => !state.excluded.rule.includes(r.id)).length },
|
|
1664
|
+
{ label: "mcps", n: preview.mcps.filter((m) => !state.excluded.mcp.includes(m.id)).length },
|
|
1665
|
+
{ label: "language servers", n: preview.lsps.filter((l) => !state.excluded.lsp.includes(l.id)).length },
|
|
1666
|
+
].filter((c) => c.n > 0)
|
|
1667
|
+
: []
|
|
1668
|
+
|
|
1669
|
+
return (
|
|
1670
|
+
<div className="as-review">
|
|
1671
|
+
<div className="as-review-main">
|
|
1672
|
+
{props.error && <p className="as-error">{props.error}</p>}
|
|
1673
|
+
|
|
1674
|
+
{/* targets + scope + AGENTS.md */}
|
|
1675
|
+
<section className="as-section">
|
|
1676
|
+
<div className="as-section-head">
|
|
1677
|
+
<h2 className="as-section-title">Targets & options</h2>
|
|
1678
|
+
</div>
|
|
1679
|
+
<div className="as-panel">
|
|
1680
|
+
<div className="as-row">
|
|
1681
|
+
<div className="as-row-main">
|
|
1682
|
+
<div className="as-row-name">Frameworks</div>
|
|
1683
|
+
<div className="as-row-desc">Install into these agent frameworks.</div>
|
|
1684
|
+
</div>
|
|
1685
|
+
</div>
|
|
1686
|
+
{primary && <TargetRow target={primary} state={state} dispatch={dispatch} />}
|
|
1687
|
+
{others.length > 0 && (
|
|
1688
|
+
<Collapsible.Root className="as-collapse" data-variant="warn">
|
|
1689
|
+
<Collapsible.Trigger className="as-collapse-trigger">
|
|
1690
|
+
Other harnesses (experimental)
|
|
1691
|
+
</Collapsible.Trigger>
|
|
1692
|
+
<Collapsible.Panel className="as-collapse-panel">
|
|
1693
|
+
<p className="as-warn">
|
|
1694
|
+
Reduced support: thinking/reasoning effort, plugins and commands are dropped for
|
|
1695
|
+
these targets. Enable only as a deliberate experimental choice.
|
|
1696
|
+
</p>
|
|
1697
|
+
{others.map((t) => (
|
|
1698
|
+
<TargetRow key={t.id} target={t} state={state} dispatch={dispatch} />
|
|
1699
|
+
))}
|
|
1700
|
+
</Collapsible.Panel>
|
|
1701
|
+
</Collapsible.Root>
|
|
1702
|
+
)}
|
|
1703
|
+
<div className="as-row as-row-controls">
|
|
1704
|
+
<div className="as-field">
|
|
1705
|
+
<span className="as-label" id="as-scope-label">Scope</span>
|
|
1706
|
+
<Seg
|
|
1707
|
+
ariaLabel="scope"
|
|
1708
|
+
value={state.scope}
|
|
1709
|
+
onChange={(v) => dispatch({ t: "scope", scope: v as "project" | "user" })}
|
|
1710
|
+
options={[
|
|
1711
|
+
{ value: "project", label: "project" },
|
|
1712
|
+
{ value: "user", label: "user" },
|
|
1713
|
+
]}
|
|
1714
|
+
/>
|
|
1715
|
+
</div>
|
|
1716
|
+
<div className="as-field">
|
|
1717
|
+
<span className="as-label" id="as-agentsmd-label">AGENTS.md</span>
|
|
1718
|
+
<Seg
|
|
1719
|
+
ariaLabel="AGENTS.md mode"
|
|
1720
|
+
value={agentsMdValue(state.agentsMd, preview?.agentsMd)}
|
|
1721
|
+
onChange={(v) => dispatch({ t: "agentsMd", mode: v })}
|
|
1722
|
+
options={catalog.agentsModes.map((m) => ({ value: m.value, label: m.label }))}
|
|
1723
|
+
/>
|
|
1724
|
+
</div>
|
|
1725
|
+
</div>
|
|
1726
|
+
{/* Model tier overrides: compact nested collapsible, collapsed by
|
|
1727
|
+
default. The full trigger row owns the "Model overrides" title;
|
|
1728
|
+
TierOverrides renders only description + fields inside the panel.
|
|
1729
|
+
State (any set tier model/effort) survives collapse untouched. */}
|
|
1730
|
+
<Collapsible.Root className="as-collapse as-tier-collapse">
|
|
1731
|
+
<Collapsible.Trigger className="as-collapse-trigger as-tier-collapse-trigger">
|
|
1732
|
+
<span className="as-tier-collapse-title">Model overrides</span>
|
|
1733
|
+
<Chevron />
|
|
1734
|
+
</Collapsible.Trigger>
|
|
1735
|
+
<Collapsible.Panel className="as-collapse-panel as-section-panel">
|
|
1736
|
+
<TierOverrides catalog={catalog} models={models} state={state} dispatch={dispatch} />
|
|
1737
|
+
</Collapsible.Panel>
|
|
1738
|
+
</Collapsible.Root>
|
|
1739
|
+
</div>
|
|
1740
|
+
</section>
|
|
1741
|
+
|
|
1742
|
+
{/* agents: per-agent model/effort overrides (edge-case path; the
|
|
1743
|
+
primary override path is the tier ModelPickers above). Collapsible
|
|
1744
|
+
like its sibling sections, collapsed by default. */}
|
|
1745
|
+
<CollapsibleSection
|
|
1746
|
+
title="Agents"
|
|
1747
|
+
enabled={(preview?.agents ?? []).length}
|
|
1748
|
+
total={(preview?.agents ?? []).length}
|
|
1749
|
+
addSlot={
|
|
1750
|
+
<div className="as-section-head-actions">
|
|
1751
|
+
<div className="as-field as-override-toggle">
|
|
1752
|
+
<span className="as-label" id="as-override-label">Override</span>
|
|
1753
|
+
<ItemSwitch
|
|
1754
|
+
checked={showOverride}
|
|
1755
|
+
label="Show per-agent model override controls"
|
|
1756
|
+
onChange={setShowOverride}
|
|
1757
|
+
/>
|
|
1758
|
+
</div>
|
|
1759
|
+
<AddPopover
|
|
1760
|
+
kind="agent"
|
|
1761
|
+
label="Agents"
|
|
1762
|
+
items={catalog.agents}
|
|
1763
|
+
added={state.extra?.agent ?? []}
|
|
1764
|
+
present={(preview?.agents ?? []).map((a) => a.id)}
|
|
1765
|
+
graph={graph}
|
|
1766
|
+
dispatch={dispatch}
|
|
1767
|
+
/>
|
|
1768
|
+
</div>
|
|
1769
|
+
}
|
|
1770
|
+
>
|
|
1771
|
+
{models && models.available && !models.connected && (
|
|
1772
|
+
<p className="as-warn">Showing full models.dev catalog: no opencode providers detected.</p>
|
|
1773
|
+
)}
|
|
1774
|
+
<div className="as-panel">
|
|
1775
|
+
{(preview?.agents ?? []).map((agent) => (
|
|
1776
|
+
<AgentRow
|
|
1777
|
+
key={agent.id}
|
|
1778
|
+
agent={agent}
|
|
1779
|
+
catalog={catalog}
|
|
1780
|
+
models={models}
|
|
1781
|
+
state={state}
|
|
1782
|
+
graph={graph}
|
|
1783
|
+
showOverride={showOverride}
|
|
1784
|
+
dispatch={dispatch}
|
|
1785
|
+
/>
|
|
1786
|
+
))}
|
|
1787
|
+
{!preview && <p className="as-spinner">resolving plan…</p>}
|
|
1788
|
+
</div>
|
|
1789
|
+
</CollapsibleSection>
|
|
1790
|
+
|
|
1791
|
+
<ToggleSection
|
|
1792
|
+
title="Skills"
|
|
1793
|
+
kind="skill"
|
|
1794
|
+
items={preview?.skills ?? []}
|
|
1795
|
+
excluded={state.excluded.skill}
|
|
1796
|
+
catalogItems={catalog.skills}
|
|
1797
|
+
graph={graph}
|
|
1798
|
+
locks={locks}
|
|
1799
|
+
state={state}
|
|
1800
|
+
dispatch={dispatch}
|
|
1801
|
+
/>
|
|
1802
|
+
<ToggleSection
|
|
1803
|
+
title="Tools"
|
|
1804
|
+
kind="tool"
|
|
1805
|
+
items={preview?.tools ?? []}
|
|
1806
|
+
excluded={state.excluded.tool}
|
|
1807
|
+
catalogItems={catalog.tools}
|
|
1808
|
+
graph={graph}
|
|
1809
|
+
locks={locks}
|
|
1810
|
+
state={state}
|
|
1811
|
+
dispatch={dispatch}
|
|
1812
|
+
/>
|
|
1813
|
+
<ToggleSection
|
|
1814
|
+
title="Plugins"
|
|
1815
|
+
kind="plugin"
|
|
1816
|
+
items={preview?.plugins ?? []}
|
|
1817
|
+
excluded={state.excluded.plugin}
|
|
1818
|
+
catalogItems={catalog.plugins}
|
|
1819
|
+
graph={graph}
|
|
1820
|
+
locks={locks}
|
|
1821
|
+
state={state}
|
|
1822
|
+
dispatch={dispatch}
|
|
1823
|
+
/>
|
|
1824
|
+
<ToggleSection
|
|
1825
|
+
title="Commands"
|
|
1826
|
+
kind="command"
|
|
1827
|
+
items={(preview?.commands ?? []).map((c) => ({ id: c.id, label: c.label }))}
|
|
1828
|
+
excluded={[]}
|
|
1829
|
+
catalogItems={catalog.commands}
|
|
1830
|
+
graph={graph}
|
|
1831
|
+
locks={locks}
|
|
1832
|
+
state={state}
|
|
1833
|
+
dispatch={dispatch}
|
|
1834
|
+
readOnly
|
|
1835
|
+
/>
|
|
1836
|
+
<ToggleSection
|
|
1837
|
+
title="Rules"
|
|
1838
|
+
kind="rule"
|
|
1839
|
+
items={preview?.rules ?? []}
|
|
1840
|
+
excluded={state.excluded.rule}
|
|
1841
|
+
catalogItems={catalog.rules}
|
|
1842
|
+
graph={graph}
|
|
1843
|
+
locks={locks}
|
|
1844
|
+
state={state}
|
|
1845
|
+
dispatch={dispatch}
|
|
1846
|
+
/>
|
|
1847
|
+
|
|
1848
|
+
{/* MCPs + their options */}
|
|
1849
|
+
<CollapsibleSection
|
|
1850
|
+
title="MCP servers"
|
|
1851
|
+
total={(preview?.mcps ?? []).length}
|
|
1852
|
+
enabled={(preview?.mcps ?? []).filter((m) => !state.excluded.mcp.includes(m.id)).length}
|
|
1853
|
+
addSlot={
|
|
1854
|
+
<AddPopover
|
|
1855
|
+
kind="mcp"
|
|
1856
|
+
label="MCP servers"
|
|
1857
|
+
items={catalog.mcps.map((m) => ({ name: m.name, label: m.label, description: m.description }))}
|
|
1858
|
+
added={state.extra?.mcp ?? []}
|
|
1859
|
+
present={(preview?.mcps ?? []).map((m) => m.id)}
|
|
1860
|
+
graph={graph}
|
|
1861
|
+
dispatch={dispatch}
|
|
1862
|
+
/>
|
|
1863
|
+
}
|
|
1864
|
+
>
|
|
1865
|
+
<div className="as-panel">
|
|
1866
|
+
{(preview?.mcps ?? []).map((mcp) => {
|
|
1867
|
+
const catMcp = catalog.mcps.find((m) => m.name === mcp.id)
|
|
1868
|
+
const excluded = state.excluded.mcp.includes(mcp.id)
|
|
1869
|
+
const lockedBy = locks.get(`mcp:${mcp.id}`)
|
|
1870
|
+
const isExtra = (state.extra?.mcp ?? []).includes(mcp.id)
|
|
1871
|
+
const lockNoteId = `lock-mcp-${mcp.id}`
|
|
1872
|
+
return (
|
|
1873
|
+
<div key={mcp.id}>
|
|
1874
|
+
<div className="as-row">
|
|
1875
|
+
<ItemSwitch
|
|
1876
|
+
checked={!excluded}
|
|
1877
|
+
locked={Boolean(lockedBy)}
|
|
1878
|
+
label={mcp.label}
|
|
1879
|
+
describedById={lockNoteId}
|
|
1880
|
+
onChange={(on) => {
|
|
1881
|
+
// Same extra/switch parity as ToggleSection: an added
|
|
1882
|
+
// MCP's switch OFF removes the extra (recoverable via
|
|
1883
|
+
// "+ Add") rather than orphaning it in `excluded`.
|
|
1884
|
+
if (!on && isExtra) {
|
|
1885
|
+
dispatch({ t: "extra", kind: "mcp", name: mcp.id, on: false })
|
|
1886
|
+
return
|
|
1887
|
+
}
|
|
1888
|
+
dispatch({ t: "toggle", kind: "mcp", name: mcp.id, on })
|
|
1889
|
+
}}
|
|
1890
|
+
/>
|
|
1891
|
+
<div className="as-row-main">
|
|
1892
|
+
<span className="as-row-name" title={catMcp?.description || undefined}>
|
|
1893
|
+
{mcp.label}
|
|
1894
|
+
{isExtra && <span className="as-tag">added</span>}
|
|
1895
|
+
</span>
|
|
1896
|
+
<div className="as-row-detail">{mcp.detail}</div>
|
|
1897
|
+
{lockedBy && <LockNote requirers={lockedBy} id={lockNoteId} />}
|
|
1898
|
+
</div>
|
|
1899
|
+
{isExtra && (
|
|
1900
|
+
<button
|
|
1901
|
+
type="button"
|
|
1902
|
+
className="as-remove"
|
|
1903
|
+
aria-label={`remove ${mcp.label}`}
|
|
1904
|
+
onClick={() => dispatch({ t: "extra", kind: "mcp", name: mcp.id, on: false })}
|
|
1905
|
+
>
|
|
1906
|
+
×
|
|
1907
|
+
</button>
|
|
1908
|
+
)}
|
|
1909
|
+
</div>
|
|
1910
|
+
{!excluded &&
|
|
1911
|
+
catMcp?.options.map((opt) => (
|
|
1912
|
+
<div className="as-row as-row-nested" key={opt.id}>
|
|
1913
|
+
<ItemSwitch
|
|
1914
|
+
checked={state.mcps[mcp.id]?.options?.[opt.id] ?? opt.default}
|
|
1915
|
+
label={`${mcp.label}: ${opt.label}`}
|
|
1916
|
+
onChange={(on) => dispatch({ t: "mcpOption", mcp: mcp.id, option: opt.id, on })}
|
|
1917
|
+
/>
|
|
1918
|
+
<div className="as-row-main">
|
|
1919
|
+
<span className="as-row-name">{opt.label}</span>
|
|
1920
|
+
{opt.description && <span className="as-row-desc"> · {opt.description}</span>}
|
|
1921
|
+
</div>
|
|
1922
|
+
</div>
|
|
1923
|
+
))}
|
|
1924
|
+
</div>
|
|
1925
|
+
)
|
|
1926
|
+
})}
|
|
1927
|
+
{(preview?.mcps ?? []).length === 0 && <p className="as-row-desc">none</p>}
|
|
1928
|
+
</div>
|
|
1929
|
+
</CollapsibleSection>
|
|
1930
|
+
|
|
1931
|
+
<ToggleSection
|
|
1932
|
+
title="Language servers"
|
|
1933
|
+
kind="lsp"
|
|
1934
|
+
items={preview?.lsps ?? []}
|
|
1935
|
+
excluded={state.excluded.lsp}
|
|
1936
|
+
catalogItems={catalog.lsps}
|
|
1937
|
+
graph={graph}
|
|
1938
|
+
locks={locks}
|
|
1939
|
+
state={state}
|
|
1940
|
+
dispatch={dispatch}
|
|
1941
|
+
/>
|
|
1942
|
+
</div>
|
|
1943
|
+
|
|
1944
|
+
{/* sticky right rail: summary + previews + install */}
|
|
1945
|
+
<aside className="as-rail" aria-label="setup summary">
|
|
1946
|
+
<div className="as-panel as-rail-panel">
|
|
1947
|
+
<h2 className="as-section-title">Summary</h2>
|
|
1948
|
+
<dl className="as-summary-grid as-rail-grid">
|
|
1949
|
+
<dt>presets</dt>
|
|
1950
|
+
<dd>{state.presetNames.join(", ") || "-"}</dd>
|
|
1951
|
+
<dt>pack</dt>
|
|
1952
|
+
<dd>{state.modelPack}</dd>
|
|
1953
|
+
<dt>targets</dt>
|
|
1954
|
+
<dd>{state.targets.join(", ") || "-"}</dd>
|
|
1955
|
+
<dt>scope</dt>
|
|
1956
|
+
<dd>{state.scope}</dd>
|
|
1957
|
+
</dl>
|
|
1958
|
+
{itemCounts.length > 0 && (
|
|
1959
|
+
<p className="as-rail-counts">
|
|
1960
|
+
{itemCounts.map((c) => `${c.n} ${c.label}`).join(" · ")}
|
|
1961
|
+
</p>
|
|
1962
|
+
)}
|
|
1963
|
+
{deletions.length > 0 && (
|
|
1964
|
+
<p className="as-rail-counts as-rail-deletions">
|
|
1965
|
+
{deletions.length} file(s) to delete
|
|
1966
|
+
</p>
|
|
1967
|
+
)}
|
|
1968
|
+
<div className="as-rail-previews">
|
|
1969
|
+
<span className="as-label">Preview files</span>
|
|
1970
|
+
<button type="button" className="as-btn" data-size="sm" onClick={() => openSheet("opencode")}>
|
|
1971
|
+
opencode.json
|
|
1972
|
+
</button>
|
|
1973
|
+
<button type="button" className="as-btn" data-size="sm" onClick={() => openSheet("agentsmd")}>
|
|
1974
|
+
AGENTS.md
|
|
1975
|
+
</button>
|
|
1976
|
+
{deletions.length > 0 && (
|
|
1977
|
+
<button
|
|
1978
|
+
type="button"
|
|
1979
|
+
className="as-btn"
|
|
1980
|
+
data-size="sm"
|
|
1981
|
+
data-variant="danger"
|
|
1982
|
+
onClick={() => setDelOpen(true)}
|
|
1983
|
+
>
|
|
1984
|
+
Deleted files ({deletions.length})
|
|
1985
|
+
</button>
|
|
1986
|
+
)}
|
|
1987
|
+
</div>
|
|
1988
|
+
<div className="as-rail-actions">
|
|
1989
|
+
{props.error && <p className="as-error as-rail-error">{props.error}</p>}
|
|
1990
|
+
<button className="as-btn" onClick={props.onBack}>Back</button>
|
|
1991
|
+
<button
|
|
1992
|
+
className="as-btn"
|
|
1993
|
+
data-variant="primary"
|
|
1994
|
+
disabled={!preview || props.loading || props.error != null || state.targets.length === 0}
|
|
1995
|
+
onClick={props.onInstall}
|
|
1996
|
+
>
|
|
1997
|
+
{props.loading ? "updating…" : "Install"}
|
|
1998
|
+
</button>
|
|
1999
|
+
</div>
|
|
2000
|
+
</div>
|
|
2001
|
+
</aside>
|
|
2002
|
+
|
|
2003
|
+
<PreviewSheet
|
|
2004
|
+
open={sheetOpen}
|
|
2005
|
+
onOpenChange={setSheetOpen}
|
|
2006
|
+
tab={sheetTab}
|
|
2007
|
+
onTab={setSheetTab}
|
|
2008
|
+
files={files}
|
|
2009
|
+
loading={props.loading}
|
|
2010
|
+
/>
|
|
2011
|
+
|
|
2012
|
+
<DeletionsSheet open={delOpen} onOpenChange={setDelOpen} deletions={deletions} />
|
|
2013
|
+
</div>
|
|
2014
|
+
)
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* Deletion preview sheet: lists the project-relative paths a sync-install would
|
|
2019
|
+
* remove (deselected items whose lock refs left the resolution closure). Only
|
|
2020
|
+
* opened when the preview reported a non-empty `deletions` set.
|
|
2021
|
+
*/
|
|
2022
|
+
function DeletionsSheet(props: {
|
|
2023
|
+
open: boolean
|
|
2024
|
+
onOpenChange: (open: boolean) => void
|
|
2025
|
+
deletions: PreviewResponse["deletions"]
|
|
2026
|
+
}): React.JSX.Element {
|
|
2027
|
+
return (
|
|
2028
|
+
<Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
|
|
2029
|
+
<Dialog.Portal>
|
|
2030
|
+
<Dialog.Backdrop className="as-sheet-backdrop" />
|
|
2031
|
+
<Dialog.Popup className="as-sheet">
|
|
2032
|
+
<div className="as-sheet-head">
|
|
2033
|
+
<Dialog.Title className="as-sheet-title">
|
|
2034
|
+
Files to delete
|
|
2035
|
+
<span className="as-preview-status">{props.deletions.length} file(s)</span>
|
|
2036
|
+
</Dialog.Title>
|
|
2037
|
+
<Dialog.Close className="as-banner-close" aria-label="close deletions">
|
|
2038
|
+
×
|
|
2039
|
+
</Dialog.Close>
|
|
2040
|
+
</div>
|
|
2041
|
+
<p className="as-section-hint">
|
|
2042
|
+
Installing this selection removes the following files (deselected items no longer in
|
|
2043
|
+
the resolution closure).
|
|
2044
|
+
</p>
|
|
2045
|
+
<ul className="as-filelist">
|
|
2046
|
+
{props.deletions.map((d, i) => (
|
|
2047
|
+
<li key={i}>
|
|
2048
|
+
<span className="as-file-status as-file-removed">removed</span>
|
|
2049
|
+
{d.path}
|
|
2050
|
+
</li>
|
|
2051
|
+
))}
|
|
2052
|
+
</ul>
|
|
2053
|
+
</Dialog.Popup>
|
|
2054
|
+
</Dialog.Portal>
|
|
2055
|
+
</Dialog.Root>
|
|
2056
|
+
)
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/** A single framework target switch row. */
|
|
2060
|
+
function TargetRow(props: {
|
|
2061
|
+
target: { id: string; label: string; detected: boolean }
|
|
2062
|
+
state: WizardState
|
|
2063
|
+
dispatch: React.Dispatch<Action>
|
|
2064
|
+
}): React.JSX.Element {
|
|
2065
|
+
const { target: t, state, dispatch } = props
|
|
2066
|
+
return (
|
|
2067
|
+
<div className="as-row">
|
|
2068
|
+
<ItemSwitch
|
|
2069
|
+
checked={(state.targets as string[]).includes(t.id)}
|
|
2070
|
+
label={t.label}
|
|
2071
|
+
onChange={(on) =>
|
|
2072
|
+
dispatch({
|
|
2073
|
+
t: "targets",
|
|
2074
|
+
targets: on ? [...state.targets, t.id] : state.targets.filter((x) => x !== t.id),
|
|
2075
|
+
})
|
|
2076
|
+
}
|
|
2077
|
+
/>
|
|
2078
|
+
<div className="as-row-main">
|
|
2079
|
+
<span className="as-row-name">{t.label}</span>
|
|
2080
|
+
{t.detected && <span className="as-row-desc"> · detected</span>}
|
|
2081
|
+
</div>
|
|
2082
|
+
</div>
|
|
2083
|
+
)
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
/**
|
|
2087
|
+
* One agent row: resolved model as a hue badge (`provider/model : effort`)
|
|
2088
|
+
* tagged "default" (the resolved tier model) or "custom" (user override, with a
|
|
2089
|
+
* tooltip'd reset control). When the section's global "Override defaults"
|
|
2090
|
+
* switch is ON the searchable model combobox and the reasoning-effort segmented
|
|
2091
|
+
* control are shown; clearing the fields resets to the resolved tier model
|
|
2092
|
+
* (which may itself be a user tier override), the reducer drops the per-agent
|
|
2093
|
+
* override, and the badge flips back to DEFAULT.
|
|
2094
|
+
*/
|
|
2095
|
+
function AgentRow(props: {
|
|
2096
|
+
agent: { id: string; label: string; model: string; reasoningEffort?: string }
|
|
2097
|
+
catalog: CatalogResponse
|
|
2098
|
+
models: ModelsResponse | null
|
|
2099
|
+
state: WizardState
|
|
2100
|
+
graph: GraphInfo | null
|
|
2101
|
+
showOverride: boolean
|
|
2102
|
+
dispatch: React.Dispatch<Action>
|
|
2103
|
+
}): React.JSX.Element {
|
|
2104
|
+
const { agent, catalog, models, state, graph, showOverride, dispatch } = props
|
|
2105
|
+
const override = typeof state.agents[agent.id]?.model === "string" ? (state.agents[agent.id]!.model as string) : ""
|
|
2106
|
+
const effort = state.agents[agent.id]?.thinking?.reasoningEffort
|
|
2107
|
+
const custom = override !== "" || effort !== undefined
|
|
2108
|
+
// Show the model/effort controls when the global toggle is ON, OR when this
|
|
2109
|
+
// row already carries an override — so turning the toggle OFF never hides an
|
|
2110
|
+
// active override (it only collapses default rows). Per-row reset stays the
|
|
2111
|
+
// explicit clearing path.
|
|
2112
|
+
const controlsVisible = showOverride || custom
|
|
2113
|
+
// Badge effort: the user's override effort when set, otherwise the resolved
|
|
2114
|
+
// effort the preview reports for the tier model (may be absent → no suffix,
|
|
2115
|
+
// never invented).
|
|
2116
|
+
const badgeEffort = effort ?? agent.reasoningEffort
|
|
2117
|
+
const isExtra = (state.extra?.agent ?? []).includes(agent.id)
|
|
2118
|
+
const agentDesc = catalog.agents.find((a) => a.name === agent.id)?.description
|
|
2119
|
+
function reset(): void {
|
|
2120
|
+
dispatch({ t: "agentModel", agentId: agent.id, model: "" })
|
|
2121
|
+
dispatch({ t: "agentEffort", agentId: agent.id, effort: "" })
|
|
2122
|
+
}
|
|
2123
|
+
return (
|
|
2124
|
+
<div className="as-row as-agent-row">
|
|
2125
|
+
<div className="as-row-main">
|
|
2126
|
+
<span className="as-row-name" title={agentDesc || undefined}>
|
|
2127
|
+
{agent.label}
|
|
2128
|
+
{isExtra && <span className="as-tag">added</span>}
|
|
2129
|
+
</span>
|
|
2130
|
+
<div className="as-row-detail as-agent-model">
|
|
2131
|
+
<ModelBadge model={agent.model} effort={badgeEffort} />
|
|
2132
|
+
<span className="as-tag" data-variant={custom ? "custom" : "default"}>
|
|
2133
|
+
{custom ? "custom" : "default"}
|
|
2134
|
+
</span>
|
|
2135
|
+
{custom && (
|
|
2136
|
+
<Tooltip.Root>
|
|
2137
|
+
<Tooltip.Trigger
|
|
2138
|
+
render={
|
|
2139
|
+
<button type="button" className="as-remove" aria-label={`reset ${agent.label} to tier model`} onClick={reset} />
|
|
2140
|
+
}
|
|
2141
|
+
>
|
|
2142
|
+
↺
|
|
2143
|
+
</Tooltip.Trigger>
|
|
2144
|
+
<Tooltip.Portal>
|
|
2145
|
+
<Tooltip.Positioner className="as-positioner" sideOffset={6}>
|
|
2146
|
+
<Tooltip.Popup className="as-tooltip">Reset to tier model</Tooltip.Popup>
|
|
2147
|
+
</Tooltip.Positioner>
|
|
2148
|
+
</Tooltip.Portal>
|
|
2149
|
+
</Tooltip.Root>
|
|
2150
|
+
)}
|
|
2151
|
+
</div>
|
|
2152
|
+
<DepChips info={graph} kind="agent" name={agent.id} />
|
|
2153
|
+
</div>
|
|
2154
|
+
{controlsVisible && (
|
|
2155
|
+
<div className="as-agent-controls">
|
|
2156
|
+
<div className="as-field">
|
|
2157
|
+
<label className="as-label" htmlFor={`model-${agent.id}`}>model override</label>
|
|
2158
|
+
<ModelPicker
|
|
2159
|
+
id={`model-${agent.id}`}
|
|
2160
|
+
models={models}
|
|
2161
|
+
placeholder={agent.model}
|
|
2162
|
+
value={override}
|
|
2163
|
+
onChange={(v) => dispatch({ t: "agentModel", agentId: agent.id, model: v })}
|
|
2164
|
+
/>
|
|
2165
|
+
</div>
|
|
2166
|
+
<div className="as-field">
|
|
2167
|
+
<span className="as-label">effort</span>
|
|
2168
|
+
<Seg
|
|
2169
|
+
ariaLabel={`${agent.label} reasoning effort`}
|
|
2170
|
+
value={effort ?? ""}
|
|
2171
|
+
onChange={(v) => dispatch({ t: "agentEffort", agentId: agent.id, effort: v })}
|
|
2172
|
+
options={[
|
|
2173
|
+
{ value: "", label: "auto" },
|
|
2174
|
+
...catalog.reasoningEfforts.map((r) => ({ value: r, label: r })),
|
|
2175
|
+
]}
|
|
2176
|
+
/>
|
|
2177
|
+
</div>
|
|
2178
|
+
</div>
|
|
2179
|
+
)}
|
|
2180
|
+
{isExtra && (
|
|
2181
|
+
<button
|
|
2182
|
+
type="button"
|
|
2183
|
+
className="as-remove"
|
|
2184
|
+
aria-label={`remove ${agent.label}`}
|
|
2185
|
+
onClick={() => dispatch({ t: "extra", kind: "agent", name: agent.id, on: false })}
|
|
2186
|
+
>
|
|
2187
|
+
×
|
|
2188
|
+
</button>
|
|
2189
|
+
)}
|
|
2190
|
+
</div>
|
|
2191
|
+
)
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
/** Chevron that rotates with the collapsible open state (CSS drives rotation). */
|
|
2195
|
+
function Chevron(): React.JSX.Element {
|
|
2196
|
+
return (
|
|
2197
|
+
<svg className="as-chevron" width="14" height="14" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
|
|
2198
|
+
<path d="M3 4.5 L6 7.5 L9 4.5" stroke="currentColor" strokeWidth="1.9" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
2199
|
+
</svg>
|
|
2200
|
+
)
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* A review section that starts COLLAPSED: the header row (title + "enabled/
|
|
2205
|
+
* total" count badge + optional "+ Add" slot + chevron) is always visible and
|
|
2206
|
+
* the body animates open/closed via Base UI Collapsible (140ms height/opacity).
|
|
2207
|
+
* The "+ Add" slot lives OUTSIDE the trigger so it stays clickable while the
|
|
2208
|
+
* section is collapsed (its popover is a portal overlay, so it does not expand
|
|
2209
|
+
* the section — the count still reflects any additions live).
|
|
2210
|
+
*/
|
|
2211
|
+
function CollapsibleSection(props: {
|
|
2212
|
+
title: string
|
|
2213
|
+
enabled: number
|
|
2214
|
+
total: number
|
|
2215
|
+
defaultOpen?: boolean
|
|
2216
|
+
addSlot?: React.ReactNode
|
|
2217
|
+
children: React.ReactNode
|
|
2218
|
+
}): React.JSX.Element {
|
|
2219
|
+
return (
|
|
2220
|
+
<Collapsible.Root className="as-section as-section-collapsible" defaultOpen={props.defaultOpen ?? false}>
|
|
2221
|
+
<div className="as-section-head as-collapse-head">
|
|
2222
|
+
<Collapsible.Trigger className="as-collapse-head-trigger">
|
|
2223
|
+
<h2 className="as-section-title">{props.title}</h2>
|
|
2224
|
+
<span className="as-count as-section-count">
|
|
2225
|
+
{props.enabled}/{props.total}
|
|
2226
|
+
</span>
|
|
2227
|
+
<Chevron />
|
|
2228
|
+
</Collapsible.Trigger>
|
|
2229
|
+
{props.addSlot}
|
|
2230
|
+
</div>
|
|
2231
|
+
<Collapsible.Panel className="as-collapse-panel as-section-panel">
|
|
2232
|
+
{props.children}
|
|
2233
|
+
</Collapsible.Panel>
|
|
2234
|
+
</Collapsible.Root>
|
|
2235
|
+
)
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
/**
|
|
2239
|
+
* A toggle section card (Skills/Tools/Plugins/Commands): switch rows with
|
|
2240
|
+
* dependency locks, an "+ Add" popover in the header, and remove affordances
|
|
2241
|
+
* on rows the user added beyond the presets.
|
|
2242
|
+
*/
|
|
2243
|
+
function ToggleSection(props: {
|
|
2244
|
+
title: string
|
|
2245
|
+
kind: keyof WizardState["excluded"] | "command"
|
|
2246
|
+
items: { id: string; label: string; contradicts?: string[] }[]
|
|
2247
|
+
excluded: string[]
|
|
2248
|
+
catalogItems: { name: string; label: string; description: string }[]
|
|
2249
|
+
graph: GraphInfo | null
|
|
2250
|
+
locks: Map<string, string[]>
|
|
2251
|
+
state: WizardState
|
|
2252
|
+
dispatch: React.Dispatch<Action>
|
|
2253
|
+
readOnly?: boolean
|
|
2254
|
+
}): React.JSX.Element {
|
|
2255
|
+
const extras = props.state.extra?.[props.kind as ExtraKind] ?? []
|
|
2256
|
+
// Bare item name -> catalog description, for the hover title on each row.
|
|
2257
|
+
const descOf = new Map(props.catalogItems.map((c) => [c.name, c.description]))
|
|
2258
|
+
const total = props.items.length
|
|
2259
|
+
const enabled = props.readOnly
|
|
2260
|
+
? total
|
|
2261
|
+
: props.items.filter((i) => !props.excluded.includes(i.id)).length
|
|
2262
|
+
// Active contradictions: an item's declared conflict only "fires" when BOTH
|
|
2263
|
+
// it and the named peer are currently selected (not excluded). Advisory —
|
|
2264
|
+
// selection stays allowed; we just surface the conflict in amber.
|
|
2265
|
+
const isSelected = (id: string) => props.readOnly || !props.excluded.includes(id)
|
|
2266
|
+
const activeConflicts = (item: { id: string; contradicts?: string[] }): string[] =>
|
|
2267
|
+
isSelected(item.id) ? (item.contradicts ?? []).filter(isSelected) : []
|
|
2268
|
+
const conflictPairs = new Set<string>()
|
|
2269
|
+
for (const it of props.items)
|
|
2270
|
+
for (const other of activeConflicts(it)) conflictPairs.add([it.id, other].sort().join(" ↔ "))
|
|
2271
|
+
return (
|
|
2272
|
+
<CollapsibleSection
|
|
2273
|
+
title={props.title}
|
|
2274
|
+
enabled={enabled}
|
|
2275
|
+
total={total}
|
|
2276
|
+
addSlot={
|
|
2277
|
+
<AddPopover
|
|
2278
|
+
kind={props.kind as ExtraKind}
|
|
2279
|
+
label={props.title}
|
|
2280
|
+
items={props.catalogItems}
|
|
2281
|
+
added={extras}
|
|
2282
|
+
present={props.items.map((i) => i.id)}
|
|
2283
|
+
graph={props.graph}
|
|
2284
|
+
dispatch={props.dispatch}
|
|
2285
|
+
/>
|
|
2286
|
+
}
|
|
2287
|
+
>
|
|
2288
|
+
<div className="as-panel">
|
|
2289
|
+
{conflictPairs.size > 0 && (
|
|
2290
|
+
<p className="as-warn as-contradict-summary">
|
|
2291
|
+
Conflicting rules selected: {[...conflictPairs].join(", ")}. Both stay installable — consider keeping one.
|
|
2292
|
+
</p>
|
|
2293
|
+
)}
|
|
2294
|
+
{props.items.map((item) => {
|
|
2295
|
+
const lockedBy = props.locks.get(`${props.kind}:${item.id}`)
|
|
2296
|
+
const isExtra = extras.includes(item.id)
|
|
2297
|
+
const lockNoteId = `lock-${props.kind}-${item.id}`
|
|
2298
|
+
const conflicts = activeConflicts(item)
|
|
2299
|
+
return (
|
|
2300
|
+
<div className="as-row" key={item.id}>
|
|
2301
|
+
<ItemSwitch
|
|
2302
|
+
checked={!props.excluded.includes(item.id)}
|
|
2303
|
+
locked={Boolean(lockedBy) && !props.readOnly}
|
|
2304
|
+
disabled={props.readOnly}
|
|
2305
|
+
label={item.label}
|
|
2306
|
+
describedById={lockNoteId}
|
|
2307
|
+
onChange={(on) => {
|
|
2308
|
+
if (props.readOnly) return
|
|
2309
|
+
// An extra item's switch mirrors its × remove: turning it OFF
|
|
2310
|
+
// deletes the extra (recoverable via "+ Add") instead of
|
|
2311
|
+
// pushing it into `excluded`, which would drop its preview row
|
|
2312
|
+
// AND keep it in `extra` — leaving it invisible everywhere.
|
|
2313
|
+
if (!on && isExtra) {
|
|
2314
|
+
props.dispatch({ t: "extra", kind: props.kind as ExtraKind, name: item.id, on: false })
|
|
2315
|
+
return
|
|
2316
|
+
}
|
|
2317
|
+
props.dispatch({
|
|
2318
|
+
t: "toggle",
|
|
2319
|
+
kind: props.kind as keyof WizardState["excluded"],
|
|
2320
|
+
name: item.id,
|
|
2321
|
+
on,
|
|
2322
|
+
})
|
|
2323
|
+
}}
|
|
2324
|
+
/>
|
|
2325
|
+
<div className="as-row-main">
|
|
2326
|
+
<span className="as-row-name" title={descOf.get(item.id) || undefined}>
|
|
2327
|
+
{item.label}
|
|
2328
|
+
{isExtra && <span className="as-tag">added</span>}
|
|
2329
|
+
{conflicts.map((name) => (
|
|
2330
|
+
<span className="as-tag as-tag-warn" key={name}>
|
|
2331
|
+
contradicts {name}
|
|
2332
|
+
</span>
|
|
2333
|
+
))}
|
|
2334
|
+
</span>
|
|
2335
|
+
{lockedBy && !props.readOnly && <LockNote requirers={lockedBy} id={lockNoteId} />}
|
|
2336
|
+
<DepChips info={props.graph} kind={props.kind} name={item.id} />
|
|
2337
|
+
</div>
|
|
2338
|
+
{isExtra && (
|
|
2339
|
+
<button
|
|
2340
|
+
type="button"
|
|
2341
|
+
className="as-remove"
|
|
2342
|
+
aria-label={`remove ${item.label}`}
|
|
2343
|
+
onClick={() => props.dispatch({ t: "extra", kind: props.kind as ExtraKind, name: item.id, on: false })}
|
|
2344
|
+
>
|
|
2345
|
+
×
|
|
2346
|
+
</button>
|
|
2347
|
+
)}
|
|
2348
|
+
</div>
|
|
2349
|
+
)
|
|
2350
|
+
})}
|
|
2351
|
+
{props.items.length === 0 && <p className="as-row-desc">none</p>}
|
|
2352
|
+
</div>
|
|
2353
|
+
</CollapsibleSection>
|
|
2354
|
+
)
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/* ================================================================== */
|
|
2358
|
+
/* post-install result details (shown under the success banner) */
|
|
2359
|
+
/* ================================================================== */
|
|
2360
|
+
|
|
2361
|
+
function ResultDetails(props: { result: InstallResponse }): React.JSX.Element {
|
|
2362
|
+
const r = props.result
|
|
2363
|
+
return (
|
|
2364
|
+
<details className="as-details">
|
|
2365
|
+
<summary className="as-summary">
|
|
2366
|
+
Last install: {r.installed} item(s), {r.files.length} file(s)
|
|
2367
|
+
{r.files.some((f) => f.status === "removed") &&
|
|
2368
|
+
` (${r.files.filter((f) => f.status === "removed").length} removed)`}
|
|
2369
|
+
</summary>
|
|
2370
|
+
{r.notes.length > 0 && (
|
|
2371
|
+
<ul className="as-notes">
|
|
2372
|
+
{r.notes.map((n, i) => (
|
|
2373
|
+
<li key={i}>{n}</li>
|
|
2374
|
+
))}
|
|
2375
|
+
</ul>
|
|
2376
|
+
)}
|
|
2377
|
+
<ul className="as-filelist">
|
|
2378
|
+
{r.files.map((f, i) => (
|
|
2379
|
+
<li key={i}>
|
|
2380
|
+
<span
|
|
2381
|
+
className="as-file-status"
|
|
2382
|
+
data-status={f.status}
|
|
2383
|
+
{...(f.status === "removed" ? { "data-removed": "" } : {})}
|
|
2384
|
+
>
|
|
2385
|
+
{f.status}
|
|
2386
|
+
</span>
|
|
2387
|
+
{f.path}
|
|
2388
|
+
</li>
|
|
2389
|
+
))}
|
|
2390
|
+
</ul>
|
|
2391
|
+
</details>
|
|
2392
|
+
)
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
/* ================================================================== */
|
|
2396
|
+
/* mount */
|
|
2397
|
+
/* ================================================================== */
|
|
2398
|
+
|
|
2399
|
+
const root = document.getElementById("root")
|
|
2400
|
+
if (root) {
|
|
2401
|
+
createRoot(root).render(
|
|
2402
|
+
<StrictMode>
|
|
2403
|
+
<App />
|
|
2404
|
+
</StrictMode>,
|
|
2405
|
+
)
|
|
2406
|
+
}
|