@bamptee/aia-code 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamptee/aia-code",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "AI Architecture Assistant - orchestrate AI-assisted development workflows via CLI tools (Claude, Codex, Gemini)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,58 +1,20 @@
1
1
  import chalk from 'chalk';
2
- import fs from 'fs-extra';
3
- import path from 'node:path';
4
- import yaml from 'yaml';
5
- import { AIA_DIR, FEATURE_STEPS, QUICK_STEPS, STEP_STATUS } from '../constants.js';
6
- import { createFeature, validateFeatureName } from '../services/feature.js';
7
- import { runStep } from '../services/runner.js';
8
-
9
- async function skipEarlySteps(feature, root) {
10
- const statusFile = path.join(root, AIA_DIR, 'features', feature, 'status.yaml');
11
- const raw = await fs.readFile(statusFile, 'utf-8');
12
- const status = yaml.parse(raw);
13
-
14
- for (const step of FEATURE_STEPS) {
15
- if (!QUICK_STEPS.includes(step)) {
16
- status.steps[step] = STEP_STATUS.DONE;
17
- }
18
- }
19
- status.current_step = QUICK_STEPS[0];
20
-
21
- await fs.writeFile(statusFile, yaml.stringify(status), 'utf-8');
22
- }
2
+ import { runQuick } from '../services/quick.js';
3
+ import { QUICK_STEPS } from '../constants.js';
23
4
 
24
5
  export function registerQuickCommand(program) {
25
6
  program
26
7
  .command('quick <name> [description]')
27
- .description('Quick story/ticket: create feature then run dev-plan implement → review')
8
+ .description(`Quick story/ticket: create feature then run ${QUICK_STEPS.join(' \u2192 ')}`)
28
9
  .option('-v, --verbose', 'Show AI thinking/tool usage')
29
10
  .option('-a, --apply', 'Force agent mode (file editing) for all steps')
30
11
  .action(async (name, description, opts) => {
31
12
  try {
32
- validateFeatureName(name);
33
-
34
- const root = process.cwd();
35
- const featureDir = path.join(root, AIA_DIR, 'features', name);
36
-
37
- if (!(await fs.pathExists(featureDir))) {
38
- await createFeature(name, root);
39
- console.log(chalk.green(`Feature "${name}" created.`));
40
- const initPath = path.join(AIA_DIR, 'features', name, 'init.md');
41
- console.log(chalk.gray(`Using ${initPath} as input.`));
42
- }
43
-
44
- await skipEarlySteps(name, root);
45
-
46
- for (const step of QUICK_STEPS) {
47
- console.log(chalk.cyan(`\n--- Running ${step} ---\n`));
48
- await runStep(step, name, {
49
- description,
50
- verbose: opts.verbose,
51
- apply: opts.apply,
52
- root,
53
- });
54
- }
55
-
13
+ await runQuick(name, {
14
+ description,
15
+ verbose: opts.verbose,
16
+ apply: opts.apply,
17
+ });
56
18
  console.log(chalk.green(`\nQuick pipeline completed for "${name}".`));
57
19
  } catch (err) {
58
20
  console.error(chalk.red(err.message));
@@ -1,6 +1,6 @@
1
1
  import { runCli } from './cli-runner.js';
2
2
 
3
- export async function generate(prompt, model, { verbose = false, apply = false } = {}) {
3
+ export async function generate(prompt, model, { verbose = false, apply = false, onData } = {}) {
4
4
  const args = ['-p'];
5
5
  if (model) {
6
6
  args.push('--model', model);
@@ -13,5 +13,5 @@ export async function generate(prompt, model, { verbose = false, apply = false }
13
13
  }
14
14
  args.push('-');
15
15
 
16
- return runCli('claude', args, { stdin: prompt, verbose: verbose || apply, apply });
16
+ return runCli('claude', args, { stdin: prompt, verbose: verbose || apply, apply, onData });
17
17
  }
@@ -4,7 +4,7 @@ import chalk from 'chalk';
4
4
  const DEFAULT_IDLE_TIMEOUT_MS = 180_000;
5
5
  const AGENT_IDLE_TIMEOUT_MS = 600_000;
6
6
 
7
- export function runCli(command, args, { stdin: stdinData, verbose = false, apply = false, idleTimeoutMs } = {}) {
7
+ export function runCli(command, args, { stdin: stdinData, verbose = false, apply = false, idleTimeoutMs, onData } = {}) {
8
8
  if (!idleTimeoutMs) {
9
9
  idleTimeoutMs = apply ? AGENT_IDLE_TIMEOUT_MS : DEFAULT_IDLE_TIMEOUT_MS;
10
10
  }
@@ -41,6 +41,7 @@ export function runCli(command, args, { stdin: stdinData, verbose = false, apply
41
41
  const text = data.toString();
42
42
  process.stdout.write(text);
43
43
  chunks.push(text);
44
+ if (onData) onData({ type: 'stdout', text });
44
45
  resetTimer();
45
46
  });
46
47
 
@@ -50,6 +51,7 @@ export function runCli(command, args, { stdin: stdinData, verbose = false, apply
50
51
  if (verbose) {
51
52
  process.stderr.write(chalk.gray(text));
52
53
  }
54
+ if (onData) onData({ type: 'stderr', text });
53
55
  resetTimer();
54
56
  });
55
57
 
@@ -1,6 +1,6 @@
1
1
  import { runCli } from './cli-runner.js';
2
2
 
3
- export async function generate(prompt, model, { verbose = false, apply = false } = {}) {
3
+ export async function generate(prompt, model, { verbose = false, apply = false, onData } = {}) {
4
4
  const args = [];
5
5
  if (model) {
6
6
  args.push('-m', model);
@@ -10,5 +10,5 @@ export async function generate(prompt, model, { verbose = false, apply = false }
10
10
  }
11
11
  args.push('-');
12
12
 
13
- return runCli('gemini', args, { stdin: prompt, verbose: verbose || apply, apply });
13
+ return runCli('gemini', args, { stdin: prompt, verbose: verbose || apply, apply, onData });
14
14
  }
@@ -1,6 +1,6 @@
1
1
  import { runCli } from './cli-runner.js';
2
2
 
3
- export async function generate(prompt, model, { verbose = false, apply = false } = {}) {
3
+ export async function generate(prompt, model, { verbose = false, apply = false, onData } = {}) {
4
4
  const args = ['exec'];
5
5
  if (model) {
6
6
  args.push('-c', `model="${model}"`);
@@ -10,5 +10,5 @@ export async function generate(prompt, model, { verbose = false, apply = false }
10
10
  }
11
11
  args.push('-');
12
12
 
13
- return runCli('codex', args, { stdin: prompt, verbose: verbose || apply, apply });
13
+ return runCli('codex', args, { stdin: prompt, verbose: verbose || apply, apply, onData });
14
14
  }
@@ -1,12 +1,12 @@
1
1
  import chalk from 'chalk';
2
2
  import { resolveModelAlias } from '../providers/registry.js';
3
3
 
4
- export async function callModel(model, prompt, { verbose = false, apply = false } = {}) {
4
+ export async function callModel(model, prompt, { verbose = false, apply = false, onData } = {}) {
5
5
  const resolved = resolveModelAlias(model);
6
6
  const displayName = resolved.model ?? `${model} (CLI default)`;
7
7
  const mode = apply ? 'agent' : 'print';
8
8
 
9
9
  console.log(chalk.yellow(`[AI] Calling ${displayName} (${mode} mode)...`));
10
10
 
11
- return resolved.provider.generate(prompt, resolved.model, { verbose, apply });
11
+ return resolved.provider.generate(prompt, resolved.model, { verbose, apply, onData });
12
12
  }
@@ -0,0 +1,40 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import yaml from 'yaml';
4
+ import { AIA_DIR, FEATURE_STEPS, QUICK_STEPS, STEP_STATUS } from '../constants.js';
5
+ import { createFeature, validateFeatureName } from './feature.js';
6
+ import { runStep } from './runner.js';
7
+
8
+ export async function skipEarlySteps(feature, root) {
9
+ const statusFile = path.join(root, AIA_DIR, 'features', feature, 'status.yaml');
10
+ const raw = await fs.readFile(statusFile, 'utf-8');
11
+ const status = yaml.parse(raw);
12
+
13
+ for (const step of FEATURE_STEPS) {
14
+ if (!QUICK_STEPS.includes(step)) {
15
+ status.steps[step] = STEP_STATUS.DONE;
16
+ }
17
+ }
18
+ status.current_step = QUICK_STEPS[0];
19
+
20
+ await fs.writeFile(statusFile, yaml.stringify(status), 'utf-8');
21
+ }
22
+
23
+ export async function runQuick(name, { description, verbose = false, apply = false, root = process.cwd(), onData } = {}) {
24
+ validateFeatureName(name);
25
+
26
+ const featureDir = path.join(root, AIA_DIR, 'features', name);
27
+ const created = !(await fs.pathExists(featureDir));
28
+
29
+ if (created) {
30
+ await createFeature(name, root);
31
+ }
32
+
33
+ await skipEarlySteps(name, root);
34
+
35
+ for (const step of QUICK_STEPS) {
36
+ await runStep(step, name, { description, verbose, apply, root, onData });
37
+ }
38
+
39
+ return { created };
40
+ }
@@ -8,7 +8,7 @@ import { callModel } from './model-call.js';
8
8
  import { loadStatus, updateStepStatus } from './status.js';
9
9
  import { logExecution } from '../logger.js';
10
10
 
11
- export async function runStep(step, feature, { description, verbose = false, apply = false, root = process.cwd() } = {}) {
11
+ export async function runStep(step, feature, { description, verbose = false, apply = false, root = process.cwd(), onData } = {}) {
12
12
  if (!FEATURE_STEPS.includes(step)) {
13
13
  throw new Error(`Unknown step "${step}". Valid steps: ${FEATURE_STEPS.join(', ')}`);
14
14
  }
@@ -30,7 +30,7 @@ export async function runStep(step, feature, { description, verbose = false, app
30
30
  const prompt = await buildPrompt(feature, step, { description, root });
31
31
 
32
32
  const start = performance.now();
33
- const output = await callModel(model, prompt, { verbose, apply: shouldApply });
33
+ const output = await callModel(model, prompt, { verbose, apply: shouldApply, onData });
34
34
  const duration = performance.now() - start;
35
35
 
36
36
  const outputPath = path.join(root, AIA_DIR, 'features', feature, `${step}.md`);
@@ -4,8 +4,22 @@ import { AIA_DIR } from '../../constants.js';
4
4
  import { loadStatus, updateStepStatus, resetStep } from '../../services/status.js';
5
5
  import { createFeature, validateFeatureName } from '../../services/feature.js';
6
6
  import { runStep } from '../../services/runner.js';
7
+ import { runQuick } from '../../services/quick.js';
7
8
  import { json, error } from '../router.js';
8
9
 
10
+ function sseHeaders(res) {
11
+ res.writeHead(200, {
12
+ 'Content-Type': 'text/event-stream',
13
+ 'Cache-Control': 'no-cache',
14
+ 'Connection': 'keep-alive',
15
+ 'Access-Control-Allow-Origin': '*',
16
+ });
17
+ }
18
+
19
+ function sseSend(res, event, data) {
20
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
21
+ }
22
+
9
23
  export function registerFeatureRoutes(router) {
10
24
  // List all features
11
25
  router.get('/api/features', async (req, res, { root }) => {
@@ -70,20 +84,77 @@ export function registerFeatureRoutes(router) {
70
84
  }
71
85
  });
72
86
 
73
- // Run a step (blocking - returns when done)
87
+ // Run a step with SSE streaming
74
88
  router.post('/api/features/:name/run/:step', async (req, res, { params, root, parseBody }) => {
75
89
  const body = await parseBody();
90
+ sseHeaders(res);
91
+ sseSend(res, 'status', { step: params.step, status: 'started' });
92
+
93
+ const onData = ({ type, text }) => {
94
+ try { sseSend(res, 'log', { type, text }); } catch {}
95
+ };
96
+
76
97
  try {
77
98
  const output = await runStep(params.step, params.name, {
78
99
  description: body.description,
79
- verbose: false,
100
+ verbose: true,
80
101
  apply: body.apply || false,
81
102
  root,
103
+ onData,
82
104
  });
83
- json(res, { ok: true, output });
105
+ sseSend(res, 'done', { step: params.step, output: output.slice(0, 500) });
84
106
  } catch (err) {
85
- error(res, err.message, 400);
107
+ sseSend(res, 'error', { message: err.message });
108
+ }
109
+ res.end();
110
+ });
111
+
112
+ // Quick ticket with SSE streaming (dev-plan -> implement -> review)
113
+ router.post('/api/features/:name/quick', async (req, res, { params, root, parseBody }) => {
114
+ const body = await parseBody();
115
+ sseHeaders(res);
116
+ sseSend(res, 'status', { status: 'started', mode: 'quick' });
117
+
118
+ const onData = ({ type, text }) => {
119
+ try { sseSend(res, 'log', { type, text }); } catch {}
120
+ };
121
+
122
+ try {
123
+ await runQuick(params.name, {
124
+ description: body.description,
125
+ apply: body.apply || false,
126
+ root,
127
+ onData,
128
+ });
129
+ sseSend(res, 'done', { status: 'completed' });
130
+ } catch (err) {
131
+ sseSend(res, 'error', { message: err.message });
132
+ }
133
+ res.end();
134
+ });
135
+
136
+ // Quick ticket with SSE streaming (create + run)
137
+ router.post('/api/quick', async (req, res, { root, parseBody }) => {
138
+ const body = await parseBody();
139
+ sseHeaders(res);
140
+ sseSend(res, 'status', { status: 'started', mode: 'quick', name: body.name });
141
+
142
+ const onData = ({ type, text }) => {
143
+ try { sseSend(res, 'log', { type, text }); } catch {}
144
+ };
145
+
146
+ try {
147
+ await runQuick(body.name, {
148
+ description: body.description,
149
+ apply: body.apply || false,
150
+ root,
151
+ onData,
152
+ });
153
+ sseSend(res, 'done', { status: 'completed', name: body.name });
154
+ } catch (err) {
155
+ sseSend(res, 'error', { message: err.message });
86
156
  }
157
+ res.end();
87
158
  });
88
159
 
89
160
  // Reset a step
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { api } from '/main.js';
2
+ import { api, streamPost } from '/main.js';
3
3
 
4
4
  const STATUS_CLASSES = {
5
5
  done: 'step-done',
@@ -79,6 +79,94 @@ function NewFeatureForm({ onCreated }) {
79
79
  );
80
80
  }
81
81
 
82
+ function LogViewer({ logs }) {
83
+ const ref = React.useRef(null);
84
+ React.useEffect(() => {
85
+ if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
86
+ }, [logs]);
87
+
88
+ if (!logs.length) return null;
89
+ return React.createElement('pre', {
90
+ ref,
91
+ className: 'bg-black/50 border border-aia-border rounded p-3 text-xs text-slate-400 overflow-auto max-h-48 whitespace-pre-wrap',
92
+ }, logs.join(''));
93
+ }
94
+
95
+ function QuickTicketForm({ onDone }) {
96
+ const [name, setName] = React.useState('');
97
+ const [description, setDescription] = React.useState('');
98
+ const [apply, setApply] = React.useState(false);
99
+ const [running, setRunning] = React.useState(false);
100
+ const [err, setErr] = React.useState('');
101
+ const [logs, setLogs] = React.useState([]);
102
+
103
+ async function handleSubmit(e) {
104
+ e.preventDefault();
105
+ setErr('');
106
+ setRunning(true);
107
+ setLogs([]);
108
+
109
+ const res = await streamPost('/quick', { name, description, apply }, {
110
+ onLog: (text) => setLogs(prev => [...prev, text]),
111
+ onStatus: (data) => setLogs(prev => [...prev, `[${data.status}] ${data.name || ''}\n`]),
112
+ });
113
+
114
+ if (res.ok) {
115
+ setName('');
116
+ setDescription('');
117
+ setLogs([]);
118
+ onDone();
119
+ } else {
120
+ setErr(res.error);
121
+ }
122
+ setRunning(false);
123
+ }
124
+
125
+ return React.createElement('form', {
126
+ onSubmit: handleSubmit,
127
+ className: 'bg-aia-card border border-amber-500/30 rounded-lg p-4 space-y-3',
128
+ },
129
+ React.createElement('h3', { className: 'text-sm font-semibold text-amber-400' }, 'Quick Ticket'),
130
+ React.createElement('p', { className: 'text-xs text-slate-500' }, 'dev-plan \u2192 implement \u2192 review'),
131
+ React.createElement('div', { className: 'flex gap-2' },
132
+ React.createElement('input', {
133
+ type: 'text',
134
+ value: name,
135
+ onChange: e => setName(e.target.value),
136
+ placeholder: 'ticket-name',
137
+ disabled: running,
138
+ className: 'bg-slate-900 border border-aia-border rounded px-3 py-1.5 text-sm text-slate-200 placeholder-slate-500 focus:border-amber-400 focus:outline-none flex-shrink-0',
139
+ }),
140
+ React.createElement('input', {
141
+ type: 'text',
142
+ value: description,
143
+ onChange: e => setDescription(e.target.value),
144
+ placeholder: 'Short description (optional)',
145
+ disabled: running,
146
+ className: 'bg-slate-900 border border-aia-border rounded px-3 py-1.5 text-sm text-slate-200 placeholder-slate-500 focus:border-amber-400 focus:outline-none flex-1',
147
+ }),
148
+ ),
149
+ React.createElement('div', { className: 'flex items-center gap-4' },
150
+ React.createElement('label', { className: 'flex items-center gap-2 text-xs text-slate-400 cursor-pointer' },
151
+ React.createElement('input', {
152
+ type: 'checkbox',
153
+ checked: apply,
154
+ onChange: e => setApply(e.target.checked),
155
+ disabled: running,
156
+ }),
157
+ 'Agent mode (--apply)'
158
+ ),
159
+ React.createElement('button', {
160
+ type: 'submit',
161
+ disabled: running || !name,
162
+ className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30 rounded px-4 py-1.5 text-sm hover:bg-amber-500/30 disabled:opacity-40',
163
+ }, running ? 'Running...' : 'Run Quick Ticket'),
164
+ ),
165
+ React.createElement(LogViewer, { logs }),
166
+ err && React.createElement('p', { className: 'text-red-400 text-xs' }, err),
167
+ );
168
+ }
169
+
82
170
  export function Dashboard() {
83
171
  const [features, setFeatures] = React.useState([]);
84
172
  const [loading, setLoading] = React.useState(true);
@@ -93,11 +181,15 @@ export function Dashboard() {
93
181
 
94
182
  React.useEffect(() => { load(); }, []);
95
183
 
96
- return React.createElement('div', null,
97
- React.createElement('div', { className: 'flex items-center justify-between mb-6' },
184
+ return React.createElement('div', { className: 'space-y-6' },
185
+ React.createElement('div', { className: 'flex items-center justify-between' },
98
186
  React.createElement('h1', { className: 'text-xl font-bold text-slate-100' }, 'Features'),
99
187
  React.createElement(NewFeatureForm, { onCreated: load }),
100
188
  ),
189
+
190
+ // Quick ticket
191
+ React.createElement(QuickTicketForm, { onDone: load }),
192
+
101
193
  loading
102
194
  ? React.createElement('p', { className: 'text-slate-500' }, 'Loading...')
103
195
  : features.length === 0
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { api } from '/main.js';
2
+ import { api, streamPost } from '/main.js';
3
3
 
4
4
  const STATUS_CLASSES = {
5
5
  done: 'step-done',
@@ -65,23 +65,103 @@ function FileEditor({ name, filename, onSaved }) {
65
65
  );
66
66
  }
67
67
 
68
+ function QuickRunButton({ name, onDone }) {
69
+ const [running, setRunning] = React.useState(false);
70
+ const [description, setDescription] = React.useState('');
71
+ const [expanded, setExpanded] = React.useState(false);
72
+ const [err, setErr] = React.useState(null);
73
+ const [logs, setLogs] = React.useState([]);
74
+
75
+ async function run() {
76
+ setRunning(true);
77
+ setErr(null);
78
+ setLogs([]);
79
+
80
+ const res = await streamPost(`/features/${name}/quick`, { description }, {
81
+ onLog: (text) => setLogs(prev => [...prev, text]),
82
+ onStatus: (data) => setLogs(prev => [...prev, `[${data.status}] ${data.mode || ''}\n`]),
83
+ });
84
+
85
+ if (res.ok) {
86
+ if (onDone) onDone();
87
+ } else {
88
+ setErr(res.error);
89
+ }
90
+ setRunning(false);
91
+ }
92
+
93
+ if (!expanded) {
94
+ return React.createElement('button', {
95
+ onClick: () => setExpanded(true),
96
+ className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30 rounded px-3 py-1.5 text-xs hover:bg-amber-500/30',
97
+ }, 'Quick Ticket (dev-plan \u2192 implement \u2192 review)');
98
+ }
99
+
100
+ return React.createElement('div', { className: 'bg-slate-900 border border-amber-500/30 rounded p-4 space-y-3' },
101
+ React.createElement('h4', { className: 'text-sm font-semibold text-amber-400' }, 'Quick Ticket'),
102
+ React.createElement('p', { className: 'text-xs text-slate-500' }, 'Skips early steps, runs dev-plan \u2192 implement \u2192 review'),
103
+ React.createElement('input', {
104
+ type: 'text',
105
+ value: description,
106
+ onChange: e => setDescription(e.target.value),
107
+ placeholder: 'Optional description...',
108
+ disabled: running,
109
+ className: 'w-full bg-aia-card border border-aia-border rounded px-3 py-1.5 text-sm text-slate-200 placeholder-slate-500 focus:border-amber-400 focus:outline-none',
110
+ }),
111
+ React.createElement('div', { className: 'flex gap-2' },
112
+ React.createElement('button', {
113
+ onClick: run,
114
+ disabled: running,
115
+ className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30 rounded px-4 py-1.5 text-sm hover:bg-amber-500/30 disabled:opacity-40',
116
+ }, running ? 'Running...' : 'Run Quick'),
117
+ React.createElement('button', {
118
+ onClick: () => setExpanded(false),
119
+ disabled: running,
120
+ className: 'text-slate-500 hover:text-slate-300 text-xs',
121
+ }, 'Cancel'),
122
+ ),
123
+ React.createElement(LogViewer, { logs }),
124
+ err && React.createElement('p', { className: 'text-red-400 text-xs' }, err),
125
+ );
126
+ }
127
+
128
+ function LogViewer({ logs }) {
129
+ const ref = React.useRef(null);
130
+ React.useEffect(() => {
131
+ if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
132
+ }, [logs]);
133
+
134
+ if (!logs.length) return null;
135
+ return React.createElement('pre', {
136
+ ref,
137
+ className: 'bg-black/50 border border-aia-border rounded p-3 text-xs text-slate-400 overflow-auto max-h-64 whitespace-pre-wrap',
138
+ }, logs.join(''));
139
+ }
140
+
68
141
  function RunPanel({ name, step, onDone }) {
69
142
  const [description, setDescription] = React.useState('');
70
143
  const [apply, setApply] = React.useState(false);
71
144
  const [running, setRunning] = React.useState(false);
72
145
  const [result, setResult] = React.useState(null);
73
- const [error, setError] = React.useState(null);
146
+ const [err, setErr] = React.useState(null);
147
+ const [logs, setLogs] = React.useState([]);
74
148
 
75
149
  async function run() {
76
150
  setRunning(true);
77
151
  setResult(null);
78
- setError(null);
79
- try {
80
- const data = await api.post(`/features/${name}/run/${step}`, { description, apply });
152
+ setErr(null);
153
+ setLogs([]);
154
+
155
+ const res = await streamPost(`/features/${name}/run/${step}`, { description, apply }, {
156
+ onLog: (text) => setLogs(prev => [...prev, text]),
157
+ onStatus: (data) => setLogs(prev => [...prev, `[${data.status}] ${data.step || ''}\n`]),
158
+ });
159
+
160
+ if (res.ok) {
81
161
  setResult('Step completed.');
82
162
  if (onDone) onDone();
83
- } catch (e) {
84
- setError(e.message);
163
+ } else {
164
+ setErr(res.error);
85
165
  }
86
166
  setRunning(false);
87
167
  }
@@ -91,7 +171,7 @@ function RunPanel({ name, step, onDone }) {
91
171
  await api.post(`/features/${name}/reset/${step}`);
92
172
  if (onDone) onDone();
93
173
  } catch (e) {
94
- setError(e.message);
174
+ setErr(e.message);
95
175
  }
96
176
  }
97
177
 
@@ -102,6 +182,7 @@ function RunPanel({ name, step, onDone }) {
102
182
  value: description,
103
183
  onChange: e => setDescription(e.target.value),
104
184
  placeholder: 'Optional description...',
185
+ disabled: running,
105
186
  className: 'w-full bg-aia-card border border-aia-border rounded px-3 py-1.5 text-sm text-slate-200 placeholder-slate-500 focus:border-aia-accent focus:outline-none',
106
187
  }),
107
188
  React.createElement('div', { className: 'flex items-center gap-4' },
@@ -110,6 +191,7 @@ function RunPanel({ name, step, onDone }) {
110
191
  type: 'checkbox',
111
192
  checked: apply,
112
193
  onChange: e => setApply(e.target.checked),
194
+ disabled: running,
113
195
  className: 'rounded',
114
196
  }),
115
197
  'Agent mode (--apply)'
@@ -125,9 +207,9 @@ function RunPanel({ name, step, onDone }) {
125
207
  className: 'text-slate-500 hover:text-slate-300 text-xs',
126
208
  }, 'Reset'),
127
209
  ),
128
- running && React.createElement('p', { className: 'text-amber-400 text-xs animate-pulse' }, 'Step is running... This may take a few minutes.'),
210
+ React.createElement(LogViewer, { logs }),
129
211
  result && React.createElement('p', { className: 'text-emerald-400 text-xs' }, result),
130
- error && React.createElement('p', { className: 'text-red-400 text-xs' }, error),
212
+ err && React.createElement('p', { className: 'text-red-400 text-xs' }, err),
131
213
  );
132
214
  }
133
215
 
@@ -160,6 +242,9 @@ export function FeatureDetail({ name }) {
160
242
  feature.current_step && React.createElement('span', { className: 'text-xs bg-aia-accent/20 text-aia-accent px-2 py-0.5 rounded' }, feature.current_step),
161
243
  ),
162
244
 
245
+ // Quick run
246
+ React.createElement(QuickRunButton, { name, onDone: load }),
247
+
163
248
  // Pipeline
164
249
  React.createElement('div', { className: 'flex flex-wrap gap-2' },
165
250
  ...Object.entries(steps).map(([step, status]) =>
@@ -31,6 +31,53 @@ export const api = {
31
31
  },
32
32
  };
33
33
 
34
+ // --- SSE stream helper ---
35
+ // POST with SSE response. Calls onLog(text) for each log chunk, returns { ok, error }.
36
+ export async function streamPost(path, body, { onLog, onStatus }) {
37
+ const res = await fetch(`/api${path}`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify(body),
41
+ });
42
+
43
+ const reader = res.body.getReader();
44
+ const decoder = new TextDecoder();
45
+ let buffer = '';
46
+ let result = { ok: true };
47
+
48
+ while (true) {
49
+ const { done, value } = await reader.read();
50
+ if (done) break;
51
+ buffer += decoder.decode(value, { stream: true });
52
+
53
+ const lines = buffer.split('\n');
54
+ buffer = lines.pop(); // keep incomplete line
55
+
56
+ let eventType = null;
57
+ for (const line of lines) {
58
+ if (line.startsWith('event: ')) {
59
+ eventType = line.slice(7);
60
+ } else if (line.startsWith('data: ') && eventType) {
61
+ try {
62
+ const data = JSON.parse(line.slice(6));
63
+ if (eventType === 'log' && onLog) {
64
+ onLog(data.text, data.type);
65
+ } else if (eventType === 'status' && onStatus) {
66
+ onStatus(data);
67
+ } else if (eventType === 'error') {
68
+ result = { ok: false, error: data.message };
69
+ } else if (eventType === 'done') {
70
+ result = { ok: true, ...data };
71
+ }
72
+ } catch {}
73
+ eventType = null;
74
+ }
75
+ }
76
+ }
77
+
78
+ return result;
79
+ }
80
+
34
81
  // --- Simple hash router ---
35
82
  function useHashRoute() {
36
83
  const [route, setRoute] = React.useState(window.location.hash || '#/');