@aion0/forge 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/RELEASE_NOTES.md +60 -7
  2. package/app/api/agents/[id]/test/route.ts +150 -0
  3. package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
  4. package/app/api/connectors/tool-test/route.ts +70 -0
  5. package/app/api/jobs/[id]/cancel/route.ts +50 -0
  6. package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
  7. package/app/api/jobs/[id]/run/route.ts +22 -2
  8. package/app/api/jobs/route.ts +11 -1
  9. package/app/api/pipelines/[id]/schema/route.ts +53 -0
  10. package/app/api/pipelines/bulk-delete/route.ts +39 -0
  11. package/app/api/pipelines/gc/route.ts +27 -0
  12. package/app/api/schedules/[id]/cancel/route.ts +27 -0
  13. package/app/api/schedules/[id]/route.ts +173 -0
  14. package/app/api/schedules/[id]/run/route.ts +45 -0
  15. package/app/api/schedules/[id]/runs/route.ts +22 -0
  16. package/app/api/schedules/[id]/stop/route.ts +33 -0
  17. package/app/api/schedules/route.ts +175 -0
  18. package/app/api/tasks/bulk-delete/route.ts +47 -0
  19. package/bin/forge-server.mjs +22 -1
  20. package/cli/mw.mjs +186 -7657
  21. package/cli/mw.ts +26 -0
  22. package/components/ConnectorsPanel.tsx +46 -0
  23. package/components/Dashboard.tsx +23 -10
  24. package/components/JobsView.tsx +245 -6
  25. package/components/PipelineEditor.tsx +38 -1
  26. package/components/PipelineView.tsx +325 -4
  27. package/components/ScheduleCreateModal.tsx +1507 -0
  28. package/components/SchedulesView.tsx +605 -0
  29. package/components/SettingsModal.tsx +116 -7
  30. package/docs/Team-Workflow-Integration.md +487 -0
  31. package/docs/UI-Design-Brief-SidePanel.md +278 -0
  32. package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
  33. package/lib/__tests__/foreach-before.test.ts +201 -0
  34. package/lib/__tests__/foreach-parse.test.ts +114 -0
  35. package/lib/__tests__/foreach-snapshot.test.ts +112 -0
  36. package/lib/__tests__/foreach-source.test.ts +105 -0
  37. package/lib/__tests__/foreach-template.test.ts +112 -0
  38. package/lib/chat/agent-loop.ts +3 -3
  39. package/lib/chat-standalone.ts +26 -1
  40. package/lib/claude-process.ts +8 -5
  41. package/lib/connectors/sync.ts +8 -2
  42. package/lib/crypto.ts +1 -1
  43. package/lib/dirs.ts +22 -7
  44. package/lib/help-docs/05-pipelines.md +171 -0
  45. package/lib/help-docs/13-schedules.md +165 -0
  46. package/lib/help-docs/23-automation-states.md +148 -0
  47. package/lib/help-docs/CLAUDE.md +6 -6
  48. package/lib/init.ts +25 -6
  49. package/lib/jobs/recipes.ts +3 -2
  50. package/lib/jobs/scheduler.ts +215 -11
  51. package/lib/jobs/store.ts +79 -3
  52. package/lib/jobs/types.ts +31 -0
  53. package/lib/logger.ts +1 -1
  54. package/lib/notify.ts +13 -6
  55. package/lib/pipeline-gc.ts +105 -0
  56. package/lib/pipeline-scheduler.ts +29 -0
  57. package/lib/pipeline.ts +811 -330
  58. package/lib/schedules/action-runner.ts +257 -0
  59. package/lib/schedules/scheduler.ts +422 -0
  60. package/lib/schedules/state.ts +41 -0
  61. package/lib/schedules/store.ts +618 -0
  62. package/lib/schedules/types.ts +117 -0
  63. package/lib/settings.ts +35 -0
  64. package/lib/task-manager.ts +56 -13
  65. package/lib/workflow-marketplace.ts +7 -1
  66. package/lib/workspace/skill-installer.ts +7 -6
  67. package/package.json +3 -1
  68. package/lib/help-docs/19-jobs.md +0 -145
  69. package/lib/help-docs/20-mantis-bug-fix.md +0 -115
  70. package/lib/help-docs/22-recipes.md +0 -124
@@ -0,0 +1,1507 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ScheduleCreateModal — 3-step wizard:
5
+ * 1. Pick body (kind: pipeline | skill, then ref)
6
+ * 2. Fill input (schema-driven for pipeline; project + user_prompt for skill)
7
+ * 3. Trigger (period / cron / once / manual + name)
8
+ *
9
+ * V2 Phase 2: pipeline + skill body. connector_tool + action types
10
+ * (chat / email / telegram) land in phases 3-6.
11
+ */
12
+
13
+ import { useEffect, useState } from 'react';
14
+
15
+ type BodyKind = 'pipeline' | 'skill' | 'connector_tool';
16
+
17
+ interface Workflow { name: string; description?: string; }
18
+ interface SkillItem { name: string; displayName?: string; description?: string; installedGlobal?: boolean; installedProjects?: string[]; }
19
+ interface ProjectInfo { path: string; name: string; }
20
+ interface PipelineInputField {
21
+ name: string;
22
+ description: string;
23
+ label?: string;
24
+ required: boolean;
25
+ type: 'string' | 'integer' | 'number' | 'boolean' | 'enum';
26
+ enum?: string[] | null;
27
+ default?: string | number | boolean | null;
28
+ multiline?: boolean;
29
+ }
30
+ interface PipelineSchema { name: string; description: string | null; input: PipelineInputField[]; }
31
+ interface ConnectorToolParam {
32
+ type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | string;
33
+ label?: string;
34
+ description?: string;
35
+ required?: boolean;
36
+ default?: unknown;
37
+ enum?: unknown[];
38
+ }
39
+ interface ConnectorTool {
40
+ name: string;
41
+ description?: string;
42
+ parameters?: Record<string, ConnectorToolParam>;
43
+ input_schema?: any;
44
+ destructive?: boolean;
45
+ }
46
+ interface ConnectorEntry { id?: string; tools?: Record<string, ConnectorTool>; }
47
+ interface ConnectorPayload {
48
+ plugin_id: string; name: string; description?: string;
49
+ installed: boolean;
50
+ entries?: ConnectorEntry[];
51
+ }
52
+
53
+ /** Flatten connector → list of {ref, tool} for the picker.
54
+ * ref = "<plugin_id>.<tool_name>" — matches the body_ref shape the
55
+ * scheduler / dispatchTool expect. */
56
+ function flattenConnectorTools(connectors: ConnectorPayload[]): Array<{
57
+ pluginId: string; pluginName: string; toolName: string; ref: string; tool: ConnectorTool;
58
+ }> {
59
+ const out: Array<{ pluginId: string; pluginName: string; toolName: string; ref: string; tool: ConnectorTool }> = [];
60
+ for (const c of connectors) {
61
+ if (!c.installed) continue;
62
+ for (const e of c.entries || []) {
63
+ const tools = e.tools || {};
64
+ for (const [toolName, t] of Object.entries(tools)) {
65
+ out.push({
66
+ pluginId: c.plugin_id,
67
+ pluginName: c.name || c.plugin_id,
68
+ toolName,
69
+ ref: `${c.plugin_id}.${toolName}`,
70
+ tool: { ...t, name: toolName },
71
+ });
72
+ }
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** Subset of Schedule fields needed to seed the modal in edit mode. */
79
+ export interface EditableSchedule {
80
+ id: string;
81
+ name: string;
82
+ body_kind: BodyKind;
83
+ body_ref: string;
84
+ input: Record<string, unknown>;
85
+ skills?: string[];
86
+ action_kind: 'none' | 'chat' | 'email' | 'telegram';
87
+ action_config: Record<string, unknown>;
88
+ schedule_kind: 'period' | 'once' | 'cron' | 'manual';
89
+ schedule_interval_minutes: number;
90
+ schedule_at: string | null;
91
+ schedule_cron: string | null;
92
+ }
93
+
94
+ interface Props {
95
+ onClose: () => void;
96
+ onCreated: () => void;
97
+ /** When set, the modal opens in edit mode: body_kind/body_ref locked,
98
+ * submit issues PATCH instead of POST, title/button labels swap. */
99
+ existing?: EditableSchedule | null;
100
+ }
101
+
102
+ // Cast Schedule.input (Record<string, unknown>) into the modal's string-keyed
103
+ // form. The form coerces everything back to strings anyway; numbers/booleans
104
+ // stringify cleanly.
105
+ function inputToStrings(v: Record<string, unknown> | undefined): Record<string, string> {
106
+ if (!v) return {};
107
+ const out: Record<string, string> = {};
108
+ for (const [k, val] of Object.entries(v)) out[k] = val == null ? '' : String(val);
109
+ return out;
110
+ }
111
+
112
+ // Convert an ISO-UTC string ("2026-05-23T12:00:00.000Z") into the YYYY-MM-DDTHH:MM
113
+ // local form <input type="datetime-local"> requires. Strips seconds + tz.
114
+ function toLocalDatetimeInput(iso: string): string {
115
+ const d = new Date(iso);
116
+ if (isNaN(d.getTime())) return '';
117
+ const pad = (n: number) => String(n).padStart(2, '0');
118
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
119
+ }
120
+
121
+ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Props) {
122
+ const editing = !!existing;
123
+
124
+ // In edit mode skip past Step 1 (body_kind / body_ref are immutable).
125
+ const [step, setStep] = useState<1 | 2 | 3>(editing ? 2 : 1);
126
+
127
+ // Step 1
128
+ const [bodyKind, setBodyKind] = useState<BodyKind>(existing?.body_kind ?? 'pipeline');
129
+ const [bodyRef, setBodyRef] = useState<string>(existing?.body_ref ?? '');
130
+ const [workflows, setWorkflows] = useState<Workflow[]>([]);
131
+ const [skills, setSkills] = useState<SkillItem[]>([]);
132
+ const [projects, setProjects] = useState<ProjectInfo[]>([]);
133
+ const [connectors, setConnectors] = useState<ConnectorPayload[]>([]);
134
+
135
+ // Step 2 — for pipeline body
136
+ const [pipelineSchema, setPipelineSchema] = useState<PipelineSchema | null>(null);
137
+ // Step 2 — for skill body (free-form)
138
+ const [skillProject, setSkillProject] = useState(
139
+ existing?.body_kind === 'skill' ? String(existing.input?.project ?? '') : ''
140
+ );
141
+ const [skillUserPrompt, setSkillUserPrompt] = useState(
142
+ existing?.body_kind === 'skill' ? String(existing.input?.user_prompt ?? '') : ''
143
+ );
144
+ // Step 2 — for connector_tool body
145
+ const [toolInputJson, setToolInputJson] = useState(
146
+ existing?.body_kind === 'connector_tool' ? JSON.stringify(existing.input ?? {}, null, 2) : '{}'
147
+ );
148
+ const [toolInputJsonErr, setToolInputJsonErr] = useState('');
149
+
150
+ const [input, setInput] = useState<Record<string, string>>(
151
+ existing?.body_kind === 'pipeline' ? inputToStrings(existing.input) : {}
152
+ );
153
+
154
+ // Extra skills attached to the dispatched body (pipeline tasks / skill task).
155
+ // Parallel to Job.skills — forwarded as --append-system-prompt; auto-installed
156
+ // into the target project on dispatch. Not meaningful for connector_tool.
157
+ const [extraSkills, setExtraSkills] = useState<string[]>(Array.isArray(existing?.skills) ? existing!.skills! : []);
158
+ const [extraSkillsPickerOpen, setExtraSkillsPickerOpen] = useState(false);
159
+ // /api/skills returns installedGlobal/installedProjects; we list ALL for the
160
+ // picker (auto-install handles missing ones) but body_kind=skill picker uses
161
+ // a filtered list (separate state `skills`).
162
+ const [allSkills, setAllSkills] = useState<SkillItem[]>([]);
163
+
164
+ // Step 3
165
+ const [name, setName] = useState(existing?.name ?? '');
166
+ const [trigger, setTrigger] = useState<'period' | 'cron' | 'once' | 'manual'>(existing?.schedule_kind ?? 'period');
167
+ const [interval, setInterval] = useState(existing?.schedule_interval_minutes ?? 10);
168
+ const [cron, setCron] = useState(existing?.schedule_cron ?? '*/15 * * * *');
169
+ const [scheduleAt, setScheduleAt] = useState(
170
+ // <input type=datetime-local> expects YYYY-MM-DDTHH:MM (local), not ISO Z.
171
+ existing?.schedule_at ? toLocalDatetimeInput(existing.schedule_at) : ''
172
+ );
173
+ const [actionKind, setActionKind] = useState<'none' | 'chat' | 'email' | 'telegram'>(existing?.action_kind ?? 'none');
174
+ const [chatSessionId, setChatSessionId] = useState(
175
+ existing?.action_kind === 'chat' ? String(existing.action_config?.session_id ?? '') : ''
176
+ );
177
+ const [chatPrefix, setChatPrefix] = useState(
178
+ existing?.action_kind === 'chat' ? String(existing.action_config?.prefix ?? '') : ''
179
+ );
180
+ const [chatSessions, setChatSessions] = useState<Array<{ id: string; title: string }>>([]);
181
+ const [emailTo, setEmailTo] = useState(
182
+ existing?.action_kind === 'email'
183
+ ? (Array.isArray(existing.action_config?.to)
184
+ ? (existing.action_config.to as string[]).join(', ')
185
+ : String(existing.action_config?.to ?? ''))
186
+ : ''
187
+ );
188
+ const [emailSubjectTpl, setEmailSubjectTpl] = useState(
189
+ existing?.action_kind === 'email'
190
+ ? String(existing.action_config?.subject_template ?? 'Forge schedule — {date}')
191
+ : 'Forge schedule — {date}'
192
+ );
193
+ const [emailBodyTpl, setEmailBodyTpl] = useState(
194
+ existing?.action_kind === 'email'
195
+ ? String(existing.action_config?.body_template ?? '{body_output}')
196
+ : '{body_output}'
197
+ );
198
+ const [telegramChatId, setTelegramChatId] = useState(
199
+ existing?.action_kind === 'telegram' ? String(existing.action_config?.chat_id ?? '') : ''
200
+ );
201
+ const [telegramPrefix, setTelegramPrefix] = useState(
202
+ existing?.action_kind === 'telegram' ? String(existing.action_config?.prefix ?? '') : ''
203
+ );
204
+
205
+ const [submitting, setSubmitting] = useState(false);
206
+ const [err, setErr] = useState('');
207
+
208
+ // Load source lists on mount
209
+ useEffect(() => {
210
+ (async () => {
211
+ try {
212
+ const [wfR, skR, cnR, csR] = await Promise.all([
213
+ fetch('/api/pipelines?type=workflows').then((r) => r.json()),
214
+ fetch('/api/skills').then((r) => r.json()),
215
+ fetch('/api/connectors').then((r) => r.json()).catch(() => ({ connectors: [] })),
216
+ fetch('/api/chat-proxy/sessions').then((r) => r.json()).catch(() => ({ sessions: [] })),
217
+ ]);
218
+ if (Array.isArray(csR?.sessions)) setChatSessions(csR.sessions);
219
+ if (Array.isArray(wfR)) setWorkflows(wfR);
220
+ else if (wfR?.workflows) setWorkflows(wfR.workflows);
221
+ if (Array.isArray(skR?.skills)) {
222
+ // Body picker only shows installed skills (forces user to install first).
223
+ setSkills(skR.skills.filter((s: SkillItem) => s.installedGlobal || (s.installedProjects?.length ?? 0) > 0));
224
+ // Extra-skills picker lists everything — Forge auto-installs missing
225
+ // ones into the target project at dispatch time.
226
+ setAllSkills(skR.skills);
227
+ }
228
+ if (skR?.projects) setProjects(skR.projects);
229
+ const conns: ConnectorPayload[] = Array.isArray(cnR) ? cnR : (cnR?.connectors || []);
230
+ setConnectors(conns.filter((c) => c.installed));
231
+ } catch (e) {
232
+ setErr(e instanceof Error ? e.message : String(e));
233
+ }
234
+ })();
235
+ }, []);
236
+
237
+ // Load pipeline schema when pipeline body picked
238
+ useEffect(() => {
239
+ if (bodyKind !== 'pipeline' || !bodyRef) { setPipelineSchema(null); return; }
240
+ (async () => {
241
+ try {
242
+ const r = await fetch(`/api/pipelines/${encodeURIComponent(bodyRef)}/schema`);
243
+ if (!r.ok) {
244
+ const j = await r.json().catch(() => ({}));
245
+ setErr(j.error || `Schema fetch failed (${r.status})`);
246
+ return;
247
+ }
248
+ const j = (await r.json()) as PipelineSchema;
249
+ setPipelineSchema(j);
250
+ const initial: Record<string, string> = {};
251
+ for (const f of j.input) {
252
+ // Prefer existing user-typed value, then the schema-declared
253
+ // default (so v2 multiline defaults like user_prompt / mr_body_template
254
+ // pre-fill the textarea instead of being empty placeholder hints).
255
+ initial[f.name] = input[f.name] ?? (f.default != null ? String(f.default) : '');
256
+ }
257
+ setInput(initial);
258
+ if (!name) {
259
+ const firstVal = Object.values(initial).find((v) => v.length > 0);
260
+ setName(`${bodyRef}${firstVal ? ' · ' + firstVal : ''}`);
261
+ }
262
+ } catch (e) {
263
+ setErr(e instanceof Error ? e.message : String(e));
264
+ }
265
+ })();
266
+ // eslint-disable-next-line react-hooks/exhaustive-deps
267
+ }, [bodyKind, bodyRef]);
268
+
269
+ // Auto-suggest name for skill body
270
+ useEffect(() => {
271
+ if (bodyKind === 'skill' && bodyRef && !name) {
272
+ setName(`${bodyRef}`);
273
+ }
274
+ // eslint-disable-next-line react-hooks/exhaustive-deps
275
+ }, [bodyKind, bodyRef]);
276
+
277
+ // Reset body_ref when body_kind switches
278
+ function onBodyKindChange(k: BodyKind) {
279
+ setBodyKind(k);
280
+ setBodyRef('');
281
+ setInput({});
282
+ setPipelineSchema(null);
283
+ }
284
+
285
+ function canProceed(): boolean {
286
+ if (step === 1) return !!bodyRef;
287
+ if (step === 2) {
288
+ if (bodyKind === 'pipeline') {
289
+ if (!pipelineSchema) return false;
290
+ for (const f of pipelineSchema.input) {
291
+ if (f.required && !input[f.name]?.trim()) return false;
292
+ }
293
+ return true;
294
+ }
295
+ if (bodyKind === 'skill') {
296
+ return !!skillProject && !!skillUserPrompt.trim();
297
+ }
298
+ if (bodyKind === 'connector_tool') {
299
+ return !toolInputJsonErr;
300
+ }
301
+ return false;
302
+ }
303
+ if (step === 3) {
304
+ if (!name.trim()) return false;
305
+ if (trigger === 'cron' && !cron.trim()) return false;
306
+ if (trigger === 'once' && !scheduleAt) return false;
307
+ if (actionKind === 'chat' && !chatSessionId) return false;
308
+ if (actionKind === 'email' && !emailTo.trim()) return false;
309
+ return true;
310
+ }
311
+ return false;
312
+ }
313
+
314
+ async function submit() {
315
+ setErr('');
316
+ setSubmitting(true);
317
+ try {
318
+ let body_input: Record<string, unknown>;
319
+ if (bodyKind === 'pipeline') {
320
+ body_input = input;
321
+ } else if (bodyKind === 'skill') {
322
+ body_input = { project: skillProject, user_prompt: skillUserPrompt };
323
+ } else { // connector_tool
324
+ try {
325
+ body_input = JSON.parse(toolInputJson || '{}');
326
+ } catch (e) {
327
+ setErr(`Tool input is not valid JSON: ${(e as Error).message}`);
328
+ return;
329
+ }
330
+ }
331
+ const action_config: Record<string, unknown> = {};
332
+ if (actionKind === 'chat') {
333
+ action_config.session_id = chatSessionId;
334
+ if (chatPrefix) action_config.prefix = chatPrefix;
335
+ } else if (actionKind === 'email') {
336
+ const to = emailTo.split(/[,;]\s*/).map((s) => s.trim()).filter(Boolean);
337
+ action_config.to = to.length > 1 ? to : (to[0] || '');
338
+ if (emailSubjectTpl) action_config.subject_template = emailSubjectTpl;
339
+ if (emailBodyTpl) action_config.body_template = emailBodyTpl;
340
+ } else if (actionKind === 'telegram') {
341
+ if (telegramChatId.trim()) action_config.chat_id = telegramChatId.trim();
342
+ if (telegramPrefix) action_config.prefix = telegramPrefix;
343
+ }
344
+ const body: any = {
345
+ name: name.trim(),
346
+ input: body_input,
347
+ // connector_tool ignores skills (no Claude task spawned); send [] so
348
+ // PATCH doesn't preserve a stale value from before kind switch.
349
+ skills: bodyKind === 'connector_tool' ? [] : extraSkills,
350
+ action_kind: actionKind,
351
+ action_config,
352
+ schedule_kind: trigger,
353
+ // Reset stale trigger fields so a kind change wipes the old one.
354
+ schedule_interval_minutes: trigger === 'period' ? interval : null,
355
+ schedule_cron: trigger === 'cron' ? cron.trim() : null,
356
+ schedule_at: trigger === 'once' ? new Date(scheduleAt).toISOString() : null,
357
+ };
358
+ if (!editing) {
359
+ body.body_kind = bodyKind;
360
+ body.body_ref = bodyRef;
361
+ body.enabled = true;
362
+ }
363
+ const url = editing ? `/api/schedules/${encodeURIComponent(existing!.id)}` : '/api/schedules';
364
+ const r = await fetch(url, {
365
+ method: editing ? 'PATCH' : 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: JSON.stringify(body),
368
+ });
369
+ if (!r.ok) {
370
+ const j = await r.json().catch(() => ({}));
371
+ setErr(j.error || `HTTP ${r.status}`);
372
+ return;
373
+ }
374
+ onCreated();
375
+ } catch (e) {
376
+ setErr(e instanceof Error ? e.message : String(e));
377
+ } finally {
378
+ setSubmitting(false);
379
+ }
380
+ }
381
+
382
+ return (
383
+ <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[12vh]">
384
+ <div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded shadow-2xl w-[680px] max-w-[95vw] max-h-[80vh] flex flex-col">
385
+ {/* Header */}
386
+ <div className="px-4 py-3 border-b border-[var(--border)] flex items-center gap-3">
387
+ <h2 className="text-[13px] font-semibold flex-1">{editing ? `✎ Edit Schedule — ${existing?.name}` : '+ Schedule'}</h2>
388
+ <Stepper step={step} />
389
+ <button onClick={onClose} className="text-[14px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] w-6 h-6">✕</button>
390
+ </div>
391
+
392
+ {/* Body */}
393
+ <div className="flex-1 overflow-auto p-4">
394
+ {step === 1 && (
395
+ <Step1
396
+ bodyKind={bodyKind} onBodyKindChange={onBodyKindChange}
397
+ bodyRef={bodyRef} onBodyRefChange={setBodyRef}
398
+ workflows={workflows} skills={skills} connectors={connectors}
399
+ projects={projects}
400
+ skillProject={skillProject} onSkillProject={setSkillProject}
401
+ />
402
+ )}
403
+ {step === 2 && bodyKind === 'pipeline' && pipelineSchema && (
404
+ <Step2Pipeline schema={pipelineSchema} input={input} onChange={setInput} />
405
+ )}
406
+ {step === 2 && bodyKind === 'skill' && (
407
+ <Step2Skill
408
+ project={skillProject}
409
+ userPrompt={skillUserPrompt} onUserPrompt={setSkillUserPrompt}
410
+ skillName={bodyRef}
411
+ />
412
+ )}
413
+ {step === 2 && bodyKind === 'connector_tool' && (
414
+ <Step2ConnectorTool
415
+ connectors={connectors}
416
+ toolRef={bodyRef}
417
+ json={toolInputJson}
418
+ onJsonChange={(v) => {
419
+ setToolInputJson(v);
420
+ try { JSON.parse(v || '{}'); setToolInputJsonErr(''); }
421
+ catch (e) { setToolInputJsonErr((e as Error).message); }
422
+ }}
423
+ jsonErr={toolInputJsonErr}
424
+ />
425
+ )}
426
+ {step === 2 && bodyKind !== 'connector_tool' && (
427
+ <ExtraSkillsSection
428
+ skills={extraSkills}
429
+ onChange={setExtraSkills}
430
+ allSkills={allSkills}
431
+ onOpenPicker={() => setExtraSkillsPickerOpen(true)}
432
+ />
433
+ )}
434
+ {step === 3 && (
435
+ <Step3
436
+ name={name} onName={setName}
437
+ trigger={trigger} onTrigger={setTrigger}
438
+ interval={interval} onInterval={setInterval}
439
+ cron={cron} onCron={setCron}
440
+ scheduleAt={scheduleAt} onScheduleAt={setScheduleAt}
441
+ bodyKind={bodyKind} bodyRef={bodyRef}
442
+ actionKind={actionKind} onActionKind={setActionKind}
443
+ chatSessionId={chatSessionId} onChatSessionId={setChatSessionId}
444
+ chatPrefix={chatPrefix} onChatPrefix={setChatPrefix}
445
+ chatSessions={chatSessions}
446
+ emailTo={emailTo} onEmailTo={setEmailTo}
447
+ emailSubjectTpl={emailSubjectTpl} onEmailSubjectTpl={setEmailSubjectTpl}
448
+ emailBodyTpl={emailBodyTpl} onEmailBodyTpl={setEmailBodyTpl}
449
+ telegramChatId={telegramChatId} onTelegramChatId={setTelegramChatId}
450
+ telegramPrefix={telegramPrefix} onTelegramPrefix={setTelegramPrefix}
451
+ />
452
+ )}
453
+ {err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2 mt-3">{err}</div>}
454
+ </div>
455
+
456
+ {/* Footer */}
457
+ <div className="px-4 py-3 border-t border-[var(--border)] flex items-center justify-between">
458
+ <button onClick={onClose} className="text-[11px] px-3 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Cancel</button>
459
+ <div className="flex gap-2">
460
+ {step > 1 && !(editing && step === 2) && (
461
+ <button
462
+ onClick={() => setStep((step - 1) as 1 | 2 | 3)}
463
+ disabled={submitting}
464
+ className="text-[11px] px-3 py-1 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)] disabled:opacity-50"
465
+ >← Back</button>
466
+ )}
467
+ {step < 3 ? (
468
+ <button
469
+ onClick={() => setStep((step + 1) as 1 | 2 | 3)}
470
+ disabled={!canProceed()}
471
+ className="text-[11px] px-3 py-1 bg-[var(--text-primary)] text-[var(--bg-primary)] rounded disabled:opacity-50"
472
+ >Next →</button>
473
+ ) : (
474
+ <button
475
+ onClick={() => void submit()}
476
+ disabled={!canProceed() || submitting}
477
+ className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold disabled:opacity-50"
478
+ >{submitting ? (editing ? 'Saving…' : 'Creating…') : (editing ? 'Save' : 'Create')}</button>
479
+ )}
480
+ </div>
481
+ </div>
482
+ </div>
483
+ {extraSkillsPickerOpen && (
484
+ <MultiSkillPickerModal
485
+ all={allSkills.filter((s) => !s.name.startsWith('_'))}
486
+ selected={extraSkills}
487
+ onClose={() => setExtraSkillsPickerOpen(false)}
488
+ onChange={setExtraSkills}
489
+ />
490
+ )}
491
+ </div>
492
+ );
493
+ }
494
+
495
+ function Stepper({ step }: { step: 1 | 2 | 3 }) {
496
+ return (
497
+ <div className="flex items-center gap-1 text-[10px]">
498
+ {[1, 2, 3].map((n) => (
499
+ <div key={n} className="flex items-center">
500
+ <div className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-semibold ${
501
+ n < step ? 'bg-[var(--accent)] text-[var(--bg-primary)]' :
502
+ n === step ? 'bg-[var(--text-primary)] text-[var(--bg-primary)]' :
503
+ 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
504
+ }`}>
505
+ {n < step ? '✓' : n}
506
+ </div>
507
+ {n < 3 && <span className={`w-4 h-px ${n < step ? 'bg-[var(--accent)]' : 'bg-[var(--border)]'} mx-0.5`} />}
508
+ </div>
509
+ ))}
510
+ </div>
511
+ );
512
+ }
513
+
514
+ function Step1({
515
+ bodyKind, onBodyKindChange,
516
+ bodyRef, onBodyRefChange,
517
+ workflows, skills, connectors,
518
+ projects, skillProject, onSkillProject,
519
+ }: {
520
+ bodyKind: BodyKind; onBodyKindChange: (k: BodyKind) => void;
521
+ bodyRef: string; onBodyRefChange: (n: string) => void;
522
+ workflows: Workflow[]; skills: SkillItem[]; connectors: ConnectorPayload[];
523
+ projects: ProjectInfo[];
524
+ skillProject: string; onSkillProject: (n: string) => void;
525
+ }) {
526
+ return (
527
+ <div>
528
+ <div className="text-[11px] text-[var(--text-secondary)] mb-2">Step 1 / 3 — Pick what this schedule runs.</div>
529
+
530
+ {/* Body kind tabs */}
531
+ <div className="flex gap-1 mb-3 border-b border-[var(--border)]">
532
+ {(['pipeline', 'skill', 'connector_tool'] as BodyKind[]).map((k) => (
533
+ <button
534
+ key={k}
535
+ onClick={() => onBodyKindChange(k)}
536
+ className={`text-[11px] px-3 py-1.5 border-b-2 ${
537
+ bodyKind === k
538
+ ? 'border-[var(--text-primary)] font-semibold'
539
+ : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
540
+ }`}
541
+ >
542
+ {k === 'pipeline' ? '📋 Pipeline'
543
+ : k === 'skill' ? '🧩 Skill'
544
+ : '🔌 Connector Tool'}
545
+ </button>
546
+ ))}
547
+ </div>
548
+
549
+ {/* Body ref list */}
550
+ {bodyKind === 'pipeline' ? (
551
+ workflows.length === 0 ? (
552
+ <div className="text-[11px] text-[var(--text-secondary)]">Loading pipelines…</div>
553
+ ) : (
554
+ <div className="space-y-1.5">
555
+ {workflows.map((w) => (
556
+ <Card key={w.name} title={w.name} desc={w.description}
557
+ checked={bodyRef === w.name} onPick={() => onBodyRefChange(w.name)} />
558
+ ))}
559
+ </div>
560
+ )
561
+ ) : bodyKind === 'skill' ? (
562
+ // Skill body: project first (required), then skill list scoped to that project (or global).
563
+ <SkillPicker
564
+ skills={skills} projects={projects}
565
+ skillProject={skillProject} onSkillProject={onSkillProject}
566
+ bodyRef={bodyRef} onBodyRefChange={onBodyRefChange}
567
+ />
568
+ ) : (
569
+ // connector_tool — flattened plugin.tool list. Source shape is
570
+ // { connectors: [{plugin_id, name, installed, entries:[{tools:{name:tool}}] }] }
571
+ (() => {
572
+ const all = flattenConnectorTools(connectors);
573
+ if (all.length === 0) {
574
+ return (
575
+ <div className="text-[11px] text-[var(--text-secondary)]">
576
+ No installed connectors with tools. Install one from Settings → Connectors first.
577
+ </div>
578
+ );
579
+ }
580
+ // Group by plugin for nice section headers.
581
+ const byPlugin = new Map<string, { name: string; tools: typeof all }>();
582
+ for (const x of all) {
583
+ if (!byPlugin.has(x.pluginId)) byPlugin.set(x.pluginId, { name: x.pluginName, tools: [] });
584
+ byPlugin.get(x.pluginId)!.tools.push(x);
585
+ }
586
+ return (
587
+ <div className="space-y-3">
588
+ {Array.from(byPlugin.entries()).map(([pluginId, group]) => (
589
+ <div key={pluginId}>
590
+ <div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1">{group.name}</div>
591
+ <div className="space-y-1.5">
592
+ {group.tools.map(({ ref, tool }) => (
593
+ <Card key={ref}
594
+ title={ref}
595
+ desc={(tool.description || '') + (tool.destructive ? ' ⚠ destructive' : '')}
596
+ checked={bodyRef === ref}
597
+ onPick={() => onBodyRefChange(ref)} />
598
+ ))}
599
+ </div>
600
+ </div>
601
+ ))}
602
+ </div>
603
+ );
604
+ })()
605
+ )}
606
+ </div>
607
+ );
608
+ }
609
+
610
+ function SkillPicker({ skills, projects, skillProject, onSkillProject, bodyRef, onBodyRefChange }: {
611
+ skills: SkillItem[]; projects: ProjectInfo[];
612
+ skillProject: string; onSkillProject: (n: string) => void;
613
+ bodyRef: string; onBodyRefChange: (n: string) => void;
614
+ }) {
615
+ // Filter skills to those installed globally OR in the chosen project.
616
+ // If no project selected yet, show nothing in the list (prompt user to pick project first).
617
+ const visibleSkills = skillProject
618
+ ? skills.filter((s) => s.installedGlobal || (s.installedProjects || []).includes(skillProject)
619
+ || (s.installedProjects || []).some((p) => p.endsWith('/' + skillProject) || p === skillProject))
620
+ : [];
621
+ return (
622
+ <div>
623
+ <label className="block mb-3">
624
+ <div className="flex items-baseline gap-1 text-[11px] mb-0.5">
625
+ <span className="font-mono">project</span>
626
+ <span className="text-[var(--red)]">*</span>
627
+ </div>
628
+ <select
629
+ value={skillProject}
630
+ onChange={(e) => { onSkillProject(e.target.value); onBodyRefChange(''); }}
631
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
632
+ >
633
+ <option value="">— pick a project —</option>
634
+ {projects.map((p) => (
635
+ <option key={p.name} value={p.name}>{p.name}</option>
636
+ ))}
637
+ </select>
638
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
639
+ Working directory for the Claude task. Skills installed in this project + global skills will appear below.
640
+ </div>
641
+ </label>
642
+
643
+ <div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1">
644
+ Skill
645
+ {skillProject && visibleSkills.length > 0 && (
646
+ <span className="ml-2 normal-case text-[var(--text-secondary)] font-normal">
647
+ ({visibleSkills.length} available in <code className="font-mono">{skillProject}</code>)
648
+ </span>
649
+ )}
650
+ </div>
651
+ {!skillProject ? (
652
+ <div className="text-[11px] text-[var(--text-secondary)] italic">Pick a project first.</div>
653
+ ) : visibleSkills.length === 0 ? (
654
+ <div className="text-[11px] text-[var(--text-secondary)]">
655
+ No skills installed globally or in <code className="font-mono">{skillProject}</code>. Install one from Settings → Skills.
656
+ </div>
657
+ ) : (
658
+ <div className="space-y-1.5">
659
+ {visibleSkills.map((s) => (
660
+ <Card key={s.name} title={s.name} desc={s.description}
661
+ checked={bodyRef === s.name} onPick={() => onBodyRefChange(s.name)} />
662
+ ))}
663
+ </div>
664
+ )}
665
+ </div>
666
+ );
667
+ }
668
+
669
+ function Card({ title, desc, checked, onPick }: {
670
+ title: string; desc?: string | null; checked: boolean; onPick: () => void;
671
+ }) {
672
+ return (
673
+ <label
674
+ className={`block cursor-pointer rounded border p-2.5 ${
675
+ checked
676
+ ? 'border-[var(--text-primary)] bg-[var(--bg-secondary)]'
677
+ : 'border-[var(--border)] hover:bg-[var(--bg-secondary)]/50'
678
+ }`}
679
+ >
680
+ <div className="flex items-baseline gap-2">
681
+ <input type="radio" name="body-ref" checked={checked} onChange={onPick} />
682
+ <span className="text-[12px] font-mono font-semibold">{title}</span>
683
+ </div>
684
+ {desc && <div className="text-[10px] text-[var(--text-secondary)] mt-1 ml-5">{desc}</div>}
685
+ </label>
686
+ );
687
+ }
688
+
689
+ function Step2Pipeline({ schema, input, onChange }: {
690
+ schema: PipelineSchema; input: Record<string, string>; onChange: (v: Record<string, string>) => void;
691
+ }) {
692
+ function update(k: string, v: string) {
693
+ onChange({ ...input, [k]: v });
694
+ }
695
+ return (
696
+ <div>
697
+ <div className="text-[11px] text-[var(--text-secondary)] mb-2">
698
+ Step 2 / 3 — Fill pipeline input. Pipeline: <code className="font-mono">{schema.name}</code>.
699
+ </div>
700
+ {schema.description && (
701
+ <div className="text-[10px] text-[var(--text-secondary)] mb-3 italic">{schema.description}</div>
702
+ )}
703
+ <div className="space-y-3">
704
+ {schema.input.map((f) => (
705
+ <PipelineInputControl
706
+ key={f.name} field={f}
707
+ value={input[f.name] ?? (f.default != null ? String(f.default) : '')}
708
+ onChange={(v) => update(f.name, v)}
709
+ />
710
+ ))}
711
+ </div>
712
+ </div>
713
+ );
714
+ }
715
+
716
+ function PipelineInputControl({ field, value, onChange }: {
717
+ field: PipelineInputField; value: string; onChange: (v: string) => void;
718
+ }) {
719
+ const labelEl = (
720
+ <div className="flex items-baseline gap-1 text-[11px] mb-0.5">
721
+ <span className="font-mono">{field.name}</span>
722
+ {field.required && <span className="text-[var(--red)]">*</span>}
723
+ {field.label && <span className="text-[10px] text-[var(--text-secondary)]">— {field.label}</span>}
724
+ </div>
725
+ );
726
+ const descEl = field.description ? (
727
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5 whitespace-pre-wrap">{field.description}</div>
728
+ ) : null;
729
+
730
+ // enum → select
731
+ if (field.type === 'enum' && Array.isArray(field.enum) && field.enum.length > 0) {
732
+ return (
733
+ <label className="block">
734
+ {labelEl}
735
+ <select
736
+ value={value}
737
+ onChange={(e) => onChange(e.target.value)}
738
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
739
+ >
740
+ <option value="">— pick —</option>
741
+ {field.enum.map((opt) => (
742
+ <option key={opt} value={opt}>{opt}</option>
743
+ ))}
744
+ </select>
745
+ {descEl}
746
+ </label>
747
+ );
748
+ }
749
+
750
+ // boolean → checkbox; value is "true"/"false" string (pipeline templates do string substitution)
751
+ if (field.type === 'boolean') {
752
+ const checked = value === 'true' || value === '1';
753
+ return (
754
+ <label className="block">
755
+ {labelEl}
756
+ <label className="flex items-center gap-2 text-[11px]">
757
+ <input
758
+ type="checkbox"
759
+ checked={checked}
760
+ onChange={(e) => onChange(e.target.checked ? 'true' : 'false')}
761
+ />
762
+ <span className="text-[11px] text-[var(--text-secondary)]">{checked ? 'true' : 'false'}</span>
763
+ </label>
764
+ {descEl}
765
+ </label>
766
+ );
767
+ }
768
+
769
+ // number / integer
770
+ if (field.type === 'number' || field.type === 'integer') {
771
+ return (
772
+ <label className="block">
773
+ {labelEl}
774
+ <input
775
+ type="number"
776
+ step={field.type === 'integer' ? 1 : 'any'}
777
+ value={value}
778
+ onChange={(e) => onChange(e.target.value)}
779
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
780
+ />
781
+ {descEl}
782
+ </label>
783
+ );
784
+ }
785
+
786
+ // string (default) — multiline if field declares it OR fallback to heuristic
787
+ const isMultiLine = field.multiline === true ||
788
+ (field.multiline === undefined && (
789
+ /multi-line|multiline|focus|prompt|description|text|body|template/i.test(field.description || '') ||
790
+ /_text$|_focus$|_prompt$|_body$|_template$/.test(field.name)
791
+ ));
792
+ return (
793
+ <label className="block">
794
+ {labelEl}
795
+ {isMultiLine ? (
796
+ <textarea
797
+ rows={3}
798
+ value={value}
799
+ onChange={(e) => onChange(e.target.value)}
800
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
801
+ />
802
+ ) : (
803
+ <input
804
+ type="text"
805
+ value={value}
806
+ onChange={(e) => onChange(e.target.value)}
807
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
808
+ />
809
+ )}
810
+ {descEl}
811
+ </label>
812
+ );
813
+ }
814
+
815
+ function Step2Skill({ project, userPrompt, onUserPrompt, skillName }: {
816
+ project: string;
817
+ userPrompt: string; onUserPrompt: (v: string) => void; skillName: string;
818
+ }) {
819
+ return (
820
+ <div>
821
+ <div className="text-[11px] text-[var(--text-secondary)] mb-3">
822
+ Step 2 / 3 — Skill <code className="font-mono">/{skillName}</code> in
823
+ project <code className="font-mono">{project}</code>.
824
+ </div>
825
+
826
+ <label className="block">
827
+ <div className="flex items-baseline gap-1 text-[11px] mb-0.5">
828
+ <span className="font-mono">user_prompt</span>
829
+ <span className="text-[var(--red)]">*</span>
830
+ </div>
831
+ <textarea
832
+ rows={6}
833
+ value={userPrompt}
834
+ onChange={(e) => onUserPrompt(e.target.value)}
835
+ placeholder="e.g. Fetch all my open Mantis bugs, summarize by category"
836
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
837
+ />
838
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
839
+ The user message sent to Claude. The skill's SKILL.md prompts Claude on how to handle it.
840
+ </div>
841
+ </label>
842
+ </div>
843
+ );
844
+ }
845
+
846
+ function Step2ConnectorTool({ connectors, toolRef, json, onJsonChange, jsonErr }: {
847
+ connectors: ConnectorPayload[]; toolRef: string;
848
+ json: string; onJsonChange: (v: string) => void; jsonErr: string;
849
+ }) {
850
+ const found = flattenConnectorTools(connectors).find((x) => x.ref === toolRef);
851
+ const tool = found?.tool;
852
+ const params = tool?.parameters || {};
853
+ const paramNames = Object.keys(params);
854
+
855
+ // Parse json → object for the form to read. If json is invalid the
856
+ // form shows current "field" values from whatever we last parsed
857
+ // successfully (the editor is the source of truth either way).
858
+ const parsed: Record<string, unknown> = (() => {
859
+ try { const v = JSON.parse(json || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; }
860
+ catch { return {}; }
861
+ })();
862
+
863
+ // Toggle: form vs raw json. Default to form if the tool declares
864
+ // parameters; fall back to raw JSON for tools without a schema.
865
+ const [showRaw, setShowRaw] = useState(paramNames.length === 0);
866
+
867
+ // Test result panel — populated by the Test button below the form.
868
+ const [testing, setTesting] = useState(false);
869
+ const [testResult, setTestResult] = useState<null | {
870
+ ok: boolean;
871
+ is_error: boolean;
872
+ content: string;
873
+ duration_ms: number;
874
+ error?: string;
875
+ }>(null);
876
+ const pluginId = toolRef.split('.')[0] || '';
877
+ const toolName = toolRef.split('.').slice(1).join('.');
878
+ async function runTest() {
879
+ setTesting(true);
880
+ setTestResult(null);
881
+ try {
882
+ const r = await fetch('/api/connectors/tool-test', {
883
+ method: 'POST',
884
+ headers: { 'Content-Type': 'application/json' },
885
+ body: JSON.stringify({ plugin_id: pluginId, tool: toolName, input: parsed }),
886
+ });
887
+ const j = await r.json();
888
+ setTestResult(j);
889
+ } catch (e) {
890
+ setTestResult({ ok: false, is_error: true, content: '', duration_ms: 0, error: (e as Error).message });
891
+ } finally { setTesting(false); }
892
+ }
893
+
894
+ function updateField(name: string, value: unknown) {
895
+ const next = { ...parsed };
896
+ // Drop empty optional fields so they don't pollute the payload.
897
+ if (value === '' || value === null || value === undefined) delete next[name];
898
+ else next[name] = value;
899
+ onJsonChange(JSON.stringify(next, null, 2));
900
+ }
901
+
902
+ return (
903
+ <div>
904
+ <div className="text-[11px] text-[var(--text-secondary)] mb-3">
905
+ Step 2 / 3 — Tool: <code className="font-mono">{toolRef}</code>. Dispatched
906
+ directly (no LLM); the response becomes the body output.
907
+ </div>
908
+
909
+ {tool?.description && (
910
+ <div className="text-[10px] text-[var(--text-secondary)] italic mb-3 whitespace-pre-wrap">{tool.description}</div>
911
+ )}
912
+ {tool?.destructive && (
913
+ <div className="text-[10px] text-[var(--yellow)] bg-[var(--yellow)]/10 rounded p-2 mb-3">
914
+ ⚠ This tool is marked <code className="font-mono">destructive</code>. Schedule will invoke it
915
+ every trigger — make sure that's what you want.
916
+ </div>
917
+ )}
918
+
919
+ {/* mode toggle */}
920
+ {paramNames.length > 0 && (
921
+ <div className="flex gap-2 mb-3 text-[10px]">
922
+ <button
923
+ onClick={() => setShowRaw(false)}
924
+ className={`px-2 py-0.5 rounded border ${!showRaw ? 'border-[var(--text-primary)] bg-[var(--bg-secondary)]' : 'border-[var(--border)] text-[var(--text-secondary)]'}`}
925
+ >Form</button>
926
+ <button
927
+ onClick={() => setShowRaw(true)}
928
+ className={`px-2 py-0.5 rounded border ${showRaw ? 'border-[var(--text-primary)] bg-[var(--bg-secondary)]' : 'border-[var(--border)] text-[var(--text-secondary)]'}`}
929
+ >Raw JSON</button>
930
+ </div>
931
+ )}
932
+
933
+ {/* form mode */}
934
+ {!showRaw && paramNames.length > 0 && (
935
+ <div className="space-y-3">
936
+ {paramNames.map((name) => {
937
+ const p = params[name];
938
+ const cur = parsed[name];
939
+ return (
940
+ <ConnectorToolField
941
+ key={name}
942
+ name={name}
943
+ param={p}
944
+ value={cur}
945
+ onChange={(v) => updateField(name, v)}
946
+ />
947
+ );
948
+ })}
949
+ {jsonErr && (
950
+ <div className="text-[10px] text-[var(--red)]">underlying JSON is invalid: {jsonErr}</div>
951
+ )}
952
+ </div>
953
+ )}
954
+
955
+ {/* raw json mode (or fallback when tool has no parameters) */}
956
+ {(showRaw || paramNames.length === 0) && (
957
+ <label className="block">
958
+ <div className="flex items-baseline gap-1 text-[11px] mb-0.5">
959
+ <span className="font-mono">input (JSON)</span>
960
+ </div>
961
+ <textarea
962
+ rows={8}
963
+ value={json}
964
+ onChange={(e) => onJsonChange(e.target.value)}
965
+ placeholder='{ "bug_id": 1213844 }'
966
+ spellCheck={false}
967
+ className={`w-full text-[11px] font-mono px-2 py-1 border rounded bg-[var(--bg-secondary)] ${
968
+ jsonErr ? 'border-[var(--red)]' : 'border-[var(--border)]'
969
+ }`}
970
+ />
971
+ {jsonErr && (
972
+ <div className="text-[10px] text-[var(--red)] mt-0.5">JSON parse error: {jsonErr}</div>
973
+ )}
974
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
975
+ Passed verbatim as the tool's input. Leave as <code className="font-mono">{`{}`}</code> if the tool takes no args.
976
+ </div>
977
+ </label>
978
+ )}
979
+
980
+ {/* Test button + result — actually dispatch the tool with the current
981
+ input so the user can validate before saving. */}
982
+ <div className="mt-3 pt-3 border-t border-[var(--border)]">
983
+ <div className="flex items-center gap-2 mb-2">
984
+ <button
985
+ onClick={() => void runTest()}
986
+ disabled={testing || !!jsonErr}
987
+ className="text-[11px] px-3 py-1 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)] disabled:opacity-50"
988
+ >{testing ? 'Testing…' : '▶ Test'}</button>
989
+ <span className="text-[10px] text-[var(--text-secondary)]">
990
+ Dispatch the tool once with the current input. Read-only check — doesn't save the schedule.
991
+ </span>
992
+ </div>
993
+ {testResult && (
994
+ <div className={`text-[10px] rounded p-2 ${
995
+ testResult.ok ? 'bg-[var(--green)]/10' : 'bg-[var(--red)]/10'
996
+ }`}>
997
+ <div className={testResult.ok ? 'text-[var(--green)]' : 'text-[var(--red)]'}>
998
+ {testResult.ok ? '✓ ok' : '✗ failed'} · {testResult.duration_ms}ms
999
+ {testResult.error && <> · {testResult.error}</>}
1000
+ </div>
1001
+ {testResult.content && (
1002
+ <pre className="mt-1 font-mono whitespace-pre-wrap break-words max-h-60 overflow-auto text-[var(--text-primary)]">
1003
+ {testResult.content}
1004
+ </pre>
1005
+ )}
1006
+ </div>
1007
+ )}
1008
+ </div>
1009
+ </div>
1010
+ );
1011
+ }
1012
+
1013
+ function ConnectorToolField({ name, param, value, onChange }: {
1014
+ name: string; param: ConnectorToolParam; value: unknown;
1015
+ onChange: (v: unknown) => void;
1016
+ }) {
1017
+ const label = param.label || name;
1018
+ const type = (param.type || 'string').toLowerCase();
1019
+ const required = !!param.required;
1020
+ const hint = param.description;
1021
+
1022
+ const labelEl = (
1023
+ <div className="flex items-baseline gap-1 text-[11px] mb-0.5">
1024
+ <span className="font-mono">{name}</span>
1025
+ {required && <span className="text-[var(--red)]">*</span>}
1026
+ {param.label && <span className="text-[10px] text-[var(--text-secondary)]">— {label}</span>}
1027
+ </div>
1028
+ );
1029
+
1030
+ // enum → select
1031
+ if (Array.isArray(param.enum) && param.enum.length > 0) {
1032
+ const cur = value == null ? '' : String(value);
1033
+ return (
1034
+ <label className="block">
1035
+ {labelEl}
1036
+ <select
1037
+ value={cur}
1038
+ onChange={(e) => onChange(e.target.value || undefined)}
1039
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1040
+ >
1041
+ <option value="">— pick —</option>
1042
+ {param.enum.map((opt) => (
1043
+ <option key={String(opt)} value={String(opt)}>{String(opt)}</option>
1044
+ ))}
1045
+ </select>
1046
+ {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
1047
+ </label>
1048
+ );
1049
+ }
1050
+
1051
+ // boolean → checkbox
1052
+ if (type === 'boolean') {
1053
+ return (
1054
+ <label className="block">
1055
+ {labelEl}
1056
+ <label className="flex items-center gap-2 text-[11px]">
1057
+ <input
1058
+ type="checkbox"
1059
+ checked={!!value}
1060
+ onChange={(e) => onChange(e.target.checked || undefined)}
1061
+ />
1062
+ <span className="text-[11px] text-[var(--text-secondary)]">{value ? 'true' : 'false'}</span>
1063
+ </label>
1064
+ {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
1065
+ </label>
1066
+ );
1067
+ }
1068
+
1069
+ // number / integer → number input
1070
+ if (type === 'number' || type === 'integer') {
1071
+ const cur = value == null ? '' : String(value);
1072
+ return (
1073
+ <label className="block">
1074
+ {labelEl}
1075
+ <input
1076
+ type="number"
1077
+ step={type === 'integer' ? 1 : 'any'}
1078
+ value={cur}
1079
+ onChange={(e) => {
1080
+ const v = e.target.value;
1081
+ if (v === '') onChange(undefined);
1082
+ else {
1083
+ const n = type === 'integer' ? parseInt(v, 10) : parseFloat(v);
1084
+ onChange(Number.isFinite(n) ? n : undefined);
1085
+ }
1086
+ }}
1087
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1088
+ />
1089
+ {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
1090
+ </label>
1091
+ );
1092
+ }
1093
+
1094
+ // object / array → JSON textarea
1095
+ if (type === 'object' || type === 'array') {
1096
+ const cur = value == null ? '' : (typeof value === 'string' ? value : JSON.stringify(value, null, 2));
1097
+ return (
1098
+ <label className="block">
1099
+ {labelEl}
1100
+ <textarea
1101
+ rows={3}
1102
+ value={cur}
1103
+ onChange={(e) => {
1104
+ const raw = e.target.value;
1105
+ if (!raw.trim()) { onChange(undefined); return; }
1106
+ try { onChange(JSON.parse(raw)); }
1107
+ catch { onChange(raw); /* keep raw text; underlying json becomes invalid */ }
1108
+ }}
1109
+ spellCheck={false}
1110
+ placeholder={type === 'array' ? '[ ]' : '{ }'}
1111
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1112
+ />
1113
+ {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
1114
+ </label>
1115
+ );
1116
+ }
1117
+
1118
+ // string (default) → text input
1119
+ const cur = value == null ? '' : String(value);
1120
+ const isMultiLine = !!hint && hint.length > 80;
1121
+ return (
1122
+ <label className="block">
1123
+ {labelEl}
1124
+ {isMultiLine ? (
1125
+ <textarea
1126
+ rows={2}
1127
+ value={cur}
1128
+ onChange={(e) => onChange(e.target.value || undefined)}
1129
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1130
+ />
1131
+ ) : (
1132
+ <input
1133
+ type="text"
1134
+ value={cur}
1135
+ onChange={(e) => onChange(e.target.value || undefined)}
1136
+ className="w-full text-[11px] font-mono px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1137
+ />
1138
+ )}
1139
+ {hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5 whitespace-pre-wrap">{hint}</div>}
1140
+ </label>
1141
+ );
1142
+ }
1143
+
1144
+ function Step3({
1145
+ name, onName,
1146
+ trigger, onTrigger,
1147
+ interval, onInterval,
1148
+ cron, onCron,
1149
+ scheduleAt, onScheduleAt,
1150
+ bodyKind, bodyRef,
1151
+ actionKind, onActionKind,
1152
+ chatSessionId, onChatSessionId,
1153
+ chatPrefix, onChatPrefix,
1154
+ chatSessions,
1155
+ emailTo, onEmailTo,
1156
+ emailSubjectTpl, onEmailSubjectTpl,
1157
+ emailBodyTpl, onEmailBodyTpl,
1158
+ telegramChatId, onTelegramChatId,
1159
+ telegramPrefix, onTelegramPrefix,
1160
+ }: {
1161
+ name: string; onName: (v: string) => void;
1162
+ trigger: 'period' | 'cron' | 'once' | 'manual'; onTrigger: (v: 'period' | 'cron' | 'once' | 'manual') => void;
1163
+ interval: number; onInterval: (v: number) => void;
1164
+ cron: string; onCron: (v: string) => void;
1165
+ scheduleAt: string; onScheduleAt: (v: string) => void;
1166
+ bodyKind: BodyKind; bodyRef: string;
1167
+ actionKind: 'none' | 'chat' | 'email' | 'telegram'; onActionKind: (v: 'none' | 'chat' | 'email' | 'telegram') => void;
1168
+ chatSessionId: string; onChatSessionId: (v: string) => void;
1169
+ chatPrefix: string; onChatPrefix: (v: string) => void;
1170
+ chatSessions: Array<{ id: string; title: string }>;
1171
+ emailTo: string; onEmailTo: (v: string) => void;
1172
+ emailSubjectTpl: string; onEmailSubjectTpl: (v: string) => void;
1173
+ emailBodyTpl: string; onEmailBodyTpl: (v: string) => void;
1174
+ telegramChatId: string; onTelegramChatId: (v: string) => void;
1175
+ telegramPrefix: string; onTelegramPrefix: (v: string) => void;
1176
+ }) {
1177
+ return (
1178
+ <div>
1179
+ <div className="text-[11px] text-[var(--text-secondary)] mb-3">Step 3 / 3 — Name, trigger, action.</div>
1180
+
1181
+ <label className="block mb-3">
1182
+ <div className="text-[11px] mb-0.5">Name</div>
1183
+ <input
1184
+ type="text"
1185
+ value={name}
1186
+ onChange={(e) => onName(e.target.value)}
1187
+ placeholder={`${bodyKind} · ${bodyRef}`}
1188
+ className="w-full text-[11px] px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1189
+ />
1190
+ </label>
1191
+
1192
+ <div className="text-[11px] mb-1.5">Trigger</div>
1193
+ <div className="space-y-1.5 mb-3">
1194
+ <Radio label="Every N minutes" v="period" trigger={trigger} onChange={onTrigger}>
1195
+ <input
1196
+ type="number"
1197
+ min={1}
1198
+ value={interval}
1199
+ onChange={(e) => onInterval(Math.max(1, Number(e.target.value) || 1))}
1200
+ disabled={trigger !== 'period'}
1201
+ className="text-[11px] font-mono px-2 py-0.5 w-16 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
1202
+ />
1203
+ <span className="text-[11px] text-[var(--text-secondary)]">min</span>
1204
+ </Radio>
1205
+ <Radio label="Cron" v="cron" trigger={trigger} onChange={onTrigger}>
1206
+ <input
1207
+ type="text"
1208
+ value={cron}
1209
+ onChange={(e) => onCron(e.target.value)}
1210
+ disabled={trigger !== 'cron'}
1211
+ placeholder="*/15 * * * *"
1212
+ className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
1213
+ />
1214
+ </Radio>
1215
+ <Radio label="Once at" v="once" trigger={trigger} onChange={onTrigger}>
1216
+ <input
1217
+ type="datetime-local"
1218
+ value={scheduleAt}
1219
+ onChange={(e) => onScheduleAt(e.target.value)}
1220
+ disabled={trigger !== 'once'}
1221
+ className="text-[11px] font-mono px-2 py-0.5 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
1222
+ />
1223
+ </Radio>
1224
+ <Radio label="Manual only (Fire button)" v="manual" trigger={trigger} onChange={onTrigger}>
1225
+ <span className="text-[10px] text-[var(--text-secondary)]">— won't auto-trigger</span>
1226
+ </Radio>
1227
+ </div>
1228
+
1229
+ <div className="text-[11px] mb-1.5 mt-4">Action (when body finishes)</div>
1230
+ <div className="space-y-1.5 mb-3">
1231
+ <label className="flex items-center gap-2 cursor-pointer">
1232
+ <input type="radio" name="action" checked={actionKind === 'none'} onChange={() => onActionKind('none')} />
1233
+ <span className="text-[11px] w-44">None</span>
1234
+ <span className="text-[10px] text-[var(--text-secondary)]">— body self-handles output</span>
1235
+ </label>
1236
+ <label className="flex items-center gap-2 cursor-pointer">
1237
+ <input type="radio" name="action" checked={actionKind === 'chat'} onChange={() => onActionKind('chat')} />
1238
+ <span className="text-[11px] w-44">Send to Chat session</span>
1239
+ <select
1240
+ value={chatSessionId}
1241
+ onChange={(e) => onChatSessionId(e.target.value)}
1242
+ disabled={actionKind !== 'chat'}
1243
+ className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
1244
+ >
1245
+ <option value="">— pick session —</option>
1246
+ {chatSessions.map((s) => (
1247
+ <option key={s.id} value={s.id}>{s.title || s.id.slice(0, 8)}</option>
1248
+ ))}
1249
+ </select>
1250
+ </label>
1251
+ {actionKind === 'chat' && (
1252
+ <label className="flex items-center gap-2 cursor-pointer">
1253
+ <span className="text-[11px] w-44 ml-6">prefix (optional)</span>
1254
+ <input
1255
+ type="text"
1256
+ value={chatPrefix}
1257
+ onChange={(e) => onChatPrefix(e.target.value)}
1258
+ placeholder="📋 Daily report:\n\n"
1259
+ className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1260
+ />
1261
+ </label>
1262
+ )}
1263
+ <label className="flex items-center gap-2 cursor-pointer">
1264
+ <input type="radio" name="action" checked={actionKind === 'email'} onChange={() => onActionKind('email')} />
1265
+ <span className="text-[11px] w-44">Send Email (SMTP)</span>
1266
+ <input
1267
+ type="text"
1268
+ value={emailTo}
1269
+ onChange={(e) => onEmailTo(e.target.value)}
1270
+ disabled={actionKind !== 'email'}
1271
+ placeholder="to@example.com, …"
1272
+ className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
1273
+ />
1274
+ </label>
1275
+ {actionKind === 'email' && (
1276
+ <>
1277
+ <label className="flex items-center gap-2">
1278
+ <span className="text-[11px] w-44 ml-6">subject template</span>
1279
+ <input
1280
+ type="text"
1281
+ value={emailSubjectTpl}
1282
+ onChange={(e) => onEmailSubjectTpl(e.target.value)}
1283
+ placeholder="{date} — Forge schedule"
1284
+ className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1285
+ />
1286
+ </label>
1287
+ <label className="flex items-baseline gap-2">
1288
+ <span className="text-[11px] w-44 ml-6">body template</span>
1289
+ <textarea
1290
+ rows={3}
1291
+ value={emailBodyTpl}
1292
+ onChange={(e) => onEmailBodyTpl(e.target.value)}
1293
+ placeholder="{body_output}"
1294
+ className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1295
+ />
1296
+ </label>
1297
+ <div className="text-[10px] text-[var(--text-secondary)] ml-[12.4rem]">
1298
+ Placeholders: {'{date}'} (YYYY-MM-DD) and {'{body_output}'}. SMTP host configured in Settings → SMTP.
1299
+ </div>
1300
+ </>
1301
+ )}
1302
+ <label className="flex items-center gap-2 cursor-pointer">
1303
+ <input type="radio" name="action" checked={actionKind === 'telegram'} onChange={() => onActionKind('telegram')} />
1304
+ <span className="text-[11px] w-44">Send Telegram</span>
1305
+ <input
1306
+ type="text"
1307
+ value={telegramChatId}
1308
+ onChange={(e) => onTelegramChatId(e.target.value)}
1309
+ disabled={actionKind !== 'telegram'}
1310
+ placeholder="chat id (blank = settings.telegramChatId)"
1311
+ className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] disabled:opacity-50"
1312
+ />
1313
+ </label>
1314
+ {actionKind === 'telegram' && (
1315
+ <label className="flex items-center gap-2 cursor-pointer">
1316
+ <span className="text-[11px] w-44 ml-6">prefix (optional)</span>
1317
+ <input
1318
+ type="text"
1319
+ value={telegramPrefix}
1320
+ onChange={(e) => onTelegramPrefix(e.target.value)}
1321
+ placeholder="📋 Daily bugs:"
1322
+ className="text-[11px] font-mono px-2 py-0.5 flex-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1323
+ />
1324
+ </label>
1325
+ )}
1326
+ </div>
1327
+ </div>
1328
+ );
1329
+ }
1330
+
1331
+ function Radio({ label, v, trigger, onChange, children }: {
1332
+ label: string;
1333
+ v: 'period' | 'cron' | 'once' | 'manual';
1334
+ trigger: 'period' | 'cron' | 'once' | 'manual';
1335
+ onChange: (v: 'period' | 'cron' | 'once' | 'manual') => void;
1336
+ children?: React.ReactNode;
1337
+ }) {
1338
+ return (
1339
+ <label className="flex items-center gap-2 cursor-pointer">
1340
+ <input type="radio" name="trigger" value={v} checked={trigger === v} onChange={() => onChange(v)} />
1341
+ <span className="text-[11px] w-44">{label}</span>
1342
+ {children}
1343
+ </label>
1344
+ );
1345
+ }
1346
+
1347
+ // ─── Extra-skills section (Step 2) ─────────────────────────
1348
+ //
1349
+ // Multi-select for skills the dispatched body should use, in addition
1350
+ // to whatever the body itself loads (body=skill's own /skill ref or a
1351
+ // pipeline node's prompt). Forwarded as --append-system-prompt; Forge
1352
+ // auto-installs missing skills into the target project at dispatch.
1353
+
1354
+ function ExtraSkillsSection({
1355
+ skills, onChange, allSkills, onOpenPicker,
1356
+ }: {
1357
+ skills: string[];
1358
+ onChange: (v: string[]) => void;
1359
+ allSkills: SkillItem[];
1360
+ onOpenPicker: () => void;
1361
+ }) {
1362
+ const empty = allSkills.length === 0;
1363
+ return (
1364
+ <div className="mt-4 pt-3 border-t border-[var(--border)]">
1365
+ <div className="flex items-baseline gap-2 mb-1">
1366
+ <span className="text-[11px] font-semibold">Skills <span className="text-[var(--text-secondary)] font-normal">(optional)</span></span>
1367
+ </div>
1368
+ <div className="text-[10px] text-[var(--text-secondary)] mb-1.5 leading-relaxed">
1369
+ Forge skills the dispatched task should use. Auto-installed into the
1370
+ target project before each run; appended as "use /&lt;skill&gt; as appropriate"
1371
+ to the task's system prompt.
1372
+ </div>
1373
+ <div className="flex items-center gap-1.5 flex-wrap">
1374
+ {skills.length === 0 ? (
1375
+ <span className="text-[10px] text-[var(--text-secondary)]">(none selected)</span>
1376
+ ) : (
1377
+ skills.map((name) => (
1378
+ <span
1379
+ key={name}
1380
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded font-mono bg-[var(--accent)]/15 text-[var(--accent)]"
1381
+ >
1382
+ /{name}
1383
+ <button
1384
+ type="button"
1385
+ onClick={() => onChange(skills.filter((x) => x !== name))}
1386
+ title="Remove"
1387
+ className="leading-none text-[11px] hover:opacity-70"
1388
+ >×</button>
1389
+ </span>
1390
+ ))
1391
+ )}
1392
+ <button
1393
+ type="button"
1394
+ onClick={onOpenPicker}
1395
+ disabled={empty}
1396
+ title={empty ? 'No skills synced yet — open the Skills tab' : 'Pick skills'}
1397
+ className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)] disabled:opacity-50 disabled:cursor-not-allowed"
1398
+ >
1399
+ {empty ? 'No skills synced' : skills.length > 0 ? '+ Add / edit' : '+ Pick skills'}
1400
+ </button>
1401
+ </div>
1402
+ </div>
1403
+ );
1404
+ }
1405
+
1406
+ // Centered modal — search box + scrollable list of toggles. Clicking
1407
+ // outside / Esc closes. Mirrors the extension JobsTab's SkillPicker
1408
+ // shape so behavior is consistent across surfaces.
1409
+ function MultiSkillPickerModal({
1410
+ all, selected, onClose, onChange,
1411
+ }: {
1412
+ all: SkillItem[];
1413
+ selected: string[];
1414
+ onClose: () => void;
1415
+ onChange: (v: string[]) => void;
1416
+ }) {
1417
+ const [q, setQ] = useState('');
1418
+ useEffect(() => {
1419
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
1420
+ window.addEventListener('keydown', onKey);
1421
+ return () => window.removeEventListener('keydown', onKey);
1422
+ }, [onClose]);
1423
+
1424
+ const filtered = (() => {
1425
+ const needle = q.trim().toLowerCase();
1426
+ if (!needle) return all;
1427
+ return all.filter((s) => {
1428
+ const hay = `${s.name} ${s.displayName || ''} ${s.description || ''}`.toLowerCase();
1429
+ return hay.includes(needle);
1430
+ });
1431
+ })();
1432
+
1433
+ function toggle(name: string) {
1434
+ onChange(selected.includes(name) ? selected.filter((x) => x !== name) : [...selected, name]);
1435
+ }
1436
+
1437
+ return (
1438
+ <div
1439
+ onClick={onClose}
1440
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-black/45"
1441
+ >
1442
+ <div
1443
+ onClick={(e) => e.stopPropagation()}
1444
+ className="bg-[var(--bg-primary)] border border-[var(--border)] rounded shadow-2xl w-[520px] max-w-[92vw] max-h-[80vh] flex flex-col"
1445
+ >
1446
+ <div className="flex items-center px-3 py-2 border-b border-[var(--border)]">
1447
+ <div className="text-[12px] font-semibold flex-1">
1448
+ Pick skills
1449
+ <span className="ml-2 text-[10px] font-normal text-[var(--text-secondary)]">
1450
+ {selected.length} of {all.length} selected
1451
+ </span>
1452
+ </div>
1453
+ <button onClick={onClose} className="text-[11px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]">Done</button>
1454
+ </div>
1455
+ <div className="px-3 py-2 border-b border-[var(--border)]">
1456
+ <input
1457
+ autoFocus
1458
+ value={q}
1459
+ onChange={(e) => setQ(e.target.value)}
1460
+ placeholder="Search by name or description…"
1461
+ className="w-full text-[12px] px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
1462
+ />
1463
+ {selected.length > 0 && (
1464
+ <div className="flex items-center gap-1.5 flex-wrap mt-1.5">
1465
+ <span className="text-[10px] text-[var(--text-secondary)]">Selected:</span>
1466
+ {selected.map((name) => (
1467
+ <span key={name} className="px-1.5 py-px text-[10px] rounded font-mono bg-[var(--accent)]/15 text-[var(--accent)]">/{name}</span>
1468
+ ))}
1469
+ <button
1470
+ type="button"
1471
+ onClick={() => onChange([])}
1472
+ className="ml-auto text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]"
1473
+ >Clear all</button>
1474
+ </div>
1475
+ )}
1476
+ </div>
1477
+ <div className="overflow-auto flex-1">
1478
+ {filtered.length === 0 ? (
1479
+ <div className="text-[11px] text-[var(--text-secondary)] text-center py-6">No skills match "{q}"</div>
1480
+ ) : (
1481
+ filtered.map((s) => {
1482
+ const isSel = selected.includes(s.name);
1483
+ return (
1484
+ <label
1485
+ key={s.name}
1486
+ onClick={() => toggle(s.name)}
1487
+ className={`flex items-start gap-2 px-3 py-1.5 border-b border-[var(--border)] cursor-pointer hover:bg-[var(--bg-secondary)] ${isSel ? 'bg-[var(--accent)]/10' : ''}`}
1488
+ >
1489
+ <input type="checkbox" checked={isSel} readOnly className="mt-1" />
1490
+ <div className="flex-1 min-w-0">
1491
+ <div className="text-[11px] font-mono">{s.name}</div>
1492
+ {s.description && (
1493
+ <div className="text-[10px] text-[var(--text-secondary)] truncate">{s.description}</div>
1494
+ )}
1495
+ </div>
1496
+ {(s.installedGlobal || (s.installedProjects?.length ?? 0) > 0) && (
1497
+ <span className="text-[9px] text-[var(--text-secondary)] mt-0.5">installed</span>
1498
+ )}
1499
+ </label>
1500
+ );
1501
+ })
1502
+ )}
1503
+ </div>
1504
+ </div>
1505
+ </div>
1506
+ );
1507
+ }