@contentful/experience-design-system-cli 2.12.0 → 2.12.1-dev-build-bae15c7.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 +214 -357
- package/dist/package.json +1 -1
- package/dist/src/analyze/extract/astro.js +9 -0
- package/dist/src/analyze/extract/react.js +23 -0
- package/dist/src/analyze/extract/stencil.js +3 -0
- package/dist/src/analyze/extract/vue.js +6 -0
- package/dist/src/analyze/extract/web-components.js +10 -0
- package/dist/src/analyze/pre-classify.d.ts +1 -1
- package/dist/src/analyze/pre-classify.js +3 -3
- package/dist/src/analyze/select/persistence.d.ts +13 -0
- package/dist/src/analyze/select/persistence.js +38 -0
- package/dist/src/analyze/select/preview-annotations.d.ts +26 -0
- package/dist/src/analyze/select/preview-annotations.js +62 -0
- package/dist/src/analyze/select/tui/App.js +5 -2
- package/dist/src/analyze/select/tui/components/ComponentRationalePanel.d.ts +30 -0
- package/dist/src/analyze/select/tui/components/ComponentRationalePanel.js +137 -0
- package/dist/src/analyze/select/tui/components/FieldEditor.d.ts +58 -1
- package/dist/src/analyze/select/tui/components/FieldEditor.js +637 -161
- package/dist/src/analyze/select/tui/components/FinalizeDialog.js +1 -1
- package/dist/src/analyze/select/tui/components/JsonPanel.js +5 -1
- package/dist/src/analyze/select/tui/components/RationalePanel.d.ts +34 -0
- package/dist/src/analyze/select/tui/components/RationalePanel.js +92 -0
- package/dist/src/analyze/select/tui/components/Sidebar.d.ts +13 -1
- package/dist/src/analyze/select/tui/components/Sidebar.js +27 -3
- package/dist/src/analyze/select/tui/hooks/scroll-offset.d.ts +29 -0
- package/dist/src/analyze/select/tui/hooks/scroll-offset.js +26 -0
- package/dist/src/analyze/select-agent/command.d.ts +2 -0
- package/dist/src/analyze/select-agent/command.js +260 -45
- package/dist/src/analyze/select-agent/show-rationale.d.ts +35 -0
- package/dist/src/analyze/select-agent/show-rationale.js +92 -0
- package/dist/src/apply/command.js +7 -3
- package/dist/src/apply/tui/ServerApplyView.d.ts +3 -1
- package/dist/src/apply/tui/ServerApplyView.js +3 -2
- package/dist/src/credentials-store.d.ts +5 -0
- package/dist/src/credentials-store.js +7 -1
- package/dist/src/generate/agent-runner.d.ts +14 -0
- package/dist/src/generate/agent-runner.js +16 -0
- package/dist/src/generate/command.d.ts +6 -0
- package/dist/src/generate/command.js +45 -10
- package/dist/src/generate/progress.d.ts +9 -0
- package/dist/src/generate/progress.js +11 -0
- package/dist/src/generate/prompt-builder.d.ts +8 -0
- package/dist/src/generate/prompt-builder.js +22 -11
- package/dist/src/import/agent-model-resolve.d.ts +23 -0
- package/dist/src/import/agent-model-resolve.js +35 -0
- package/dist/src/import/auto-filter-resolve.d.ts +12 -0
- package/dist/src/import/auto-filter-resolve.js +16 -0
- package/dist/src/import/command.js +248 -8
- package/dist/src/import/orchestrator.d.ts +2 -0
- package/dist/src/import/orchestrator.js +11 -3
- package/dist/src/import/print-prompt.d.ts +35 -0
- package/dist/src/import/print-prompt.js +31 -0
- package/dist/src/import/tui/CustomPromptBanner.d.ts +11 -0
- package/dist/src/import/tui/CustomPromptBanner.js +7 -0
- package/dist/src/import/tui/WizardApp.d.ts +112 -8
- package/dist/src/import/tui/WizardApp.js +820 -333
- package/dist/src/import/tui/auto-filter-error.d.ts +2 -0
- package/dist/src/import/tui/auto-filter-error.js +29 -0
- package/dist/src/import/tui/final-review-host.d.ts +15 -0
- package/dist/src/import/tui/final-review-host.js +20 -0
- package/dist/src/import/tui/merge-ai-decisions.d.ts +5 -0
- package/dist/src/import/tui/merge-ai-decisions.js +22 -0
- package/dist/src/import/tui/push-decision-gate-helpers.d.ts +8 -0
- package/dist/src/import/tui/push-decision-gate-helpers.js +8 -0
- package/dist/src/import/tui/push-progress.d.ts +23 -0
- package/dist/src/import/tui/push-progress.js +14 -0
- package/dist/src/import/tui/run-print-files-helpers.d.ts +26 -0
- package/dist/src/import/tui/run-print-files-helpers.js +8 -0
- package/dist/src/import/tui/run-teaser.d.ts +5 -0
- package/dist/src/import/tui/run-teaser.js +9 -0
- package/dist/src/import/tui/runLivePreview.d.ts +35 -0
- package/dist/src/import/tui/runLivePreview.js +73 -0
- package/dist/src/import/tui/runScopeGate.d.ts +22 -0
- package/dist/src/import/tui/runScopeGate.js +28 -0
- package/dist/src/import/tui/scope-gate-host.d.ts +21 -0
- package/dist/src/import/tui/scope-gate-host.js +20 -0
- package/dist/src/import/tui/spawn-generate.d.ts +33 -0
- package/dist/src/import/tui/spawn-generate.js +53 -0
- package/dist/src/import/tui/steps/CredentialsStep.d.ts +25 -1
- package/dist/src/import/tui/steps/CredentialsStep.js +31 -3
- package/dist/src/import/tui/steps/DoneStep.d.ts +6 -1
- package/dist/src/import/tui/steps/DoneStep.js +10 -4
- package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +29 -2
- package/dist/src/import/tui/steps/GenerateReviewStep.js +412 -48
- package/dist/src/import/tui/steps/PushDecisionGateStep.d.ts +19 -0
- package/dist/src/import/tui/steps/PushDecisionGateStep.js +76 -0
- package/dist/src/import/tui/steps/PushingStep.d.ts +10 -0
- package/dist/src/import/tui/steps/PushingStep.js +40 -0
- package/dist/src/import/tui/steps/ScopeGateStep.d.ts +23 -0
- package/dist/src/import/tui/steps/ScopeGateStep.js +250 -0
- package/dist/src/import/tui/steps/TokenInputStep.js +20 -3
- package/dist/src/import/tui/useLivePreview.d.ts +35 -0
- package/dist/src/import/tui/useLivePreview.js +120 -0
- package/dist/src/import/tui/wizard-generate-progress.d.ts +21 -0
- package/dist/src/import/tui/wizard-generate-progress.js +25 -0
- package/dist/src/import/tui/wizard-save-flow.d.ts +37 -0
- package/dist/src/import/tui/wizard-save-flow.js +20 -0
- package/dist/src/import/tui/wizard-state-transitions.d.ts +80 -0
- package/dist/src/import/tui/wizard-state-transitions.js +79 -0
- package/dist/src/lib/contentful-urls.d.ts +34 -0
- package/dist/src/lib/contentful-urls.js +41 -0
- package/dist/src/program.js +2 -0
- package/dist/src/runs/export-helpers.d.ts +30 -0
- package/dist/src/runs/export-helpers.js +59 -0
- package/dist/src/runs/fingerprint.d.ts +47 -0
- package/dist/src/runs/fingerprint.js +75 -0
- package/dist/src/runs/ls-command.d.ts +12 -0
- package/dist/src/runs/ls-command.js +137 -0
- package/dist/src/runs/modify-launcher.d.ts +24 -0
- package/dist/src/runs/modify-launcher.js +36 -0
- package/dist/src/runs/path-prompt.d.ts +8 -0
- package/dist/src/runs/path-prompt.js +72 -0
- package/dist/src/runs/push-creds-prompt.d.ts +25 -0
- package/dist/src/runs/push-creds-prompt.js +39 -0
- package/dist/src/runs/push-helpers.d.ts +29 -0
- package/dist/src/runs/push-helpers.js +69 -0
- package/dist/src/runs/replay-helpers.d.ts +67 -0
- package/dist/src/runs/replay-helpers.js +148 -0
- package/dist/src/runs/resolve-run-target.d.ts +12 -0
- package/dist/src/runs/resolve-run-target.js +45 -0
- package/dist/src/runs/run-picker-mount.d.ts +46 -0
- package/dist/src/runs/run-picker-mount.js +72 -0
- package/dist/src/runs/run-picker.d.ts +17 -0
- package/dist/src/runs/run-picker.js +145 -0
- package/dist/src/runs/save-conflict.d.ts +8 -0
- package/dist/src/runs/save-conflict.js +56 -0
- package/dist/src/runs/save-path-resolver.d.ts +55 -0
- package/dist/src/runs/save-path-resolver.js +71 -0
- package/dist/src/runs/staleness.d.ts +38 -0
- package/dist/src/runs/staleness.js +140 -0
- package/dist/src/runs/store.d.ts +78 -0
- package/dist/src/runs/store.js +136 -0
- package/dist/src/session/cache-keys.d.ts +15 -0
- package/dist/src/session/cache-keys.js +38 -0
- package/dist/src/session/db.d.ts +89 -3
- package/dist/src/session/db.js +447 -104
- package/dist/src/setup/auto-filter-prompt.d.ts +13 -0
- package/dist/src/setup/auto-filter-prompt.js +24 -0
- package/dist/src/setup/command.d.ts +7 -0
- package/dist/src/setup/command.js +58 -2
- package/dist/src/types.d.ts +6 -0
- package/package.json +2 -2
- package/skills/generate-components.md +54 -6
- package/skills/select-components.md +13 -4
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { useImmediateInput } from '../../../analyze/select/tui/hooks/useImmediateInput.js';
|
|
5
|
+
const OPTIONS = [
|
|
6
|
+
{ value: 'both', label: 'Save AND push', shortcut: 'b' },
|
|
7
|
+
{ value: 'push-only', label: 'Push only', shortcut: 'p' },
|
|
8
|
+
{ value: 'save-only', label: 'Save only', shortcut: 's' },
|
|
9
|
+
];
|
|
10
|
+
const SAVE_ONLY_INDEX = OPTIONS.findIndex((o) => o.value === 'save-only');
|
|
11
|
+
export function PushDecisionGateStep({ summary, context, onChoice, onQuit, pushDisabled = false, }) {
|
|
12
|
+
// When push is disabled, the cursor defaults to "Save only" (the only
|
|
13
|
+
// selectable row). When push is enabled, "Save AND push" is the default —
|
|
14
|
+
// matches the existing scope-gate UX.
|
|
15
|
+
const [cursor, setCursor] = useState(pushDisabled ? SAVE_ONLY_INDEX : 0);
|
|
16
|
+
function isSelectable(index) {
|
|
17
|
+
if (!pushDisabled)
|
|
18
|
+
return true;
|
|
19
|
+
return OPTIONS[index].value === 'save-only';
|
|
20
|
+
}
|
|
21
|
+
useImmediateInput((input, key) => {
|
|
22
|
+
if (key.return) {
|
|
23
|
+
if (!isSelectable(cursor))
|
|
24
|
+
return;
|
|
25
|
+
onChoice(OPTIONS[cursor].value);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (input === 'b') {
|
|
29
|
+
if (pushDisabled)
|
|
30
|
+
return;
|
|
31
|
+
onChoice('both');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (input === 'p') {
|
|
35
|
+
if (pushDisabled)
|
|
36
|
+
return;
|
|
37
|
+
onChoice('push-only');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (input === 's') {
|
|
41
|
+
onChoice('save-only');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (input === 'j' || key.downArrow) {
|
|
45
|
+
setCursor((c) => {
|
|
46
|
+
// Walk forward to the next selectable row. If none, stay put.
|
|
47
|
+
for (let i = c + 1; i < OPTIONS.length; i++) {
|
|
48
|
+
if (isSelectable(i))
|
|
49
|
+
return i;
|
|
50
|
+
}
|
|
51
|
+
return c;
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (input === 'k' || key.upArrow) {
|
|
56
|
+
setCursor((c) => {
|
|
57
|
+
for (let i = c - 1; i >= 0; i--) {
|
|
58
|
+
if (isSelectable(i))
|
|
59
|
+
return i;
|
|
60
|
+
}
|
|
61
|
+
return c;
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (input === 'q' || key.escape) {
|
|
66
|
+
onQuit();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsx(Text, { color: "green", children: "\u2713 Generation complete" }), _jsx(Text, { dimColor: true, children: summary }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: context }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: OPTIONS.map((opt, i) => {
|
|
71
|
+
const selected = i === cursor;
|
|
72
|
+
const disabled = pushDisabled && opt.value !== 'save-only';
|
|
73
|
+
const suffix = disabled ? ' (unavailable — credentials skipped)' : '';
|
|
74
|
+
return (_jsxs(Text, { color: selected ? 'cyan' : undefined, dimColor: disabled, children: [selected ? '›' : ' ', " [", opt.shortcut, "] ", opt.label, suffix] }, opt.value));
|
|
75
|
+
}) }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[Enter] Confirm" }), _jsx(Text, { dimColor: true, children: "[j/k] Move" }), _jsx(Text, { dimColor: true, children: "[q] Quit" })] })] }));
|
|
76
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { PushExpected, PushProgress } from '../push-progress.js';
|
|
3
|
+
type PushingStepProps = {
|
|
4
|
+
stepNumber: number;
|
|
5
|
+
totalSteps: number;
|
|
6
|
+
expected: PushExpected | null;
|
|
7
|
+
progress: PushProgress;
|
|
8
|
+
};
|
|
9
|
+
export declare function PushingStep({ stepNumber, totalSteps, expected, progress }: PushingStepProps): React.ReactElement;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
5
|
+
const ACTION_LABELS = {
|
|
6
|
+
create: 'Creating',
|
|
7
|
+
update: 'Updating',
|
|
8
|
+
remove: 'Deleting',
|
|
9
|
+
};
|
|
10
|
+
function ActionRow({ label, expectedCount, progress, }) {
|
|
11
|
+
// Per spec: do NOT fake proportional tallies. Left side stays "?/N" until
|
|
12
|
+
// terminal status surfaces real per-action counts.
|
|
13
|
+
const left = progress && progress.kind === 'progress' ? '?' : '?';
|
|
14
|
+
return (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { children: [" ", label] }), _jsxs(Text, { dimColor: true, children: [left, "/", expectedCount] })] }));
|
|
15
|
+
}
|
|
16
|
+
function EntitySection({ title, counts, progress, }) {
|
|
17
|
+
const visibleActions = ['create', 'update', 'remove'].filter((k) => counts[k] > 0);
|
|
18
|
+
if (visibleActions.length === 0)
|
|
19
|
+
return null;
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { bold: true, children: title }), visibleActions.map((k) => (_jsx(ActionRow, { label: ACTION_LABELS[k], expectedCount: counts[k], progress: progress }, k)))] }));
|
|
21
|
+
}
|
|
22
|
+
export function PushingStep({ stepNumber, totalSteps, expected, progress }) {
|
|
23
|
+
const [frame, setFrame] = useState(0);
|
|
24
|
+
const [elapsed, setElapsed] = useState(0);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const spinner = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
|
|
27
|
+
const timer = setInterval(() => setElapsed((s) => s + 1), 1000);
|
|
28
|
+
return () => {
|
|
29
|
+
clearInterval(spinner);
|
|
30
|
+
clearInterval(timer);
|
|
31
|
+
};
|
|
32
|
+
}, []);
|
|
33
|
+
const mins = Math.floor(elapsed / 60);
|
|
34
|
+
const secs = elapsed % 60;
|
|
35
|
+
const elapsedStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
36
|
+
const operationId = progress && progress.kind === 'queued' ? progress.operationId : null;
|
|
37
|
+
const showGlobal = progress && progress.kind === 'progress';
|
|
38
|
+
const showCurrent = progress && progress.kind === 'progress' && progress.current ? progress.current : null;
|
|
39
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { dimColor: true, children: '─'.repeat(40) }), _jsxs(Box, { gap: 1, children: [_jsxs(Text, { bold: true, children: ["Step ", stepNumber, " of ", totalSteps] }), _jsx(Text, { bold: true, children: "\u2014" }), _jsx(Text, { bold: true, children: "Push to Contentful" })] }), _jsx(Text, { dimColor: true, children: '─'.repeat(40) })] }), _jsx(Text, { children: "Writing component types and design tokens to your Contentful space..." }), operationId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Operation:" }), _jsx(Text, { children: operationId })] })), expected && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(EntitySection, { title: "Component types", counts: expected.componentTypes, progress: progress }), _jsx(EntitySection, { title: "Design tokens", counts: expected.designTokens, progress: progress })] })), showGlobal && progress && progress.kind === 'progress' && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { dimColor: true, children: [progress.processed, "/", progress.total, " entities"] })] })), showCurrent && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Now processing:" }), _jsx(Text, { children: showCurrent })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Elapsed: ", elapsedStr] }) })] }));
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export type ScopeComponent = {
|
|
3
|
+
name: string;
|
|
4
|
+
componentId: string;
|
|
5
|
+
aiDecision?: 'accepted' | 'rejected' | 'failed' | null;
|
|
6
|
+
aiReason?: string | null;
|
|
7
|
+
};
|
|
8
|
+
export type ScopeGateStepProps = {
|
|
9
|
+
components: ScopeComponent[];
|
|
10
|
+
onConfirm: (decisions: {
|
|
11
|
+
accepted: string[];
|
|
12
|
+
rejected: string[];
|
|
13
|
+
}) => void;
|
|
14
|
+
onQuit: () => void;
|
|
15
|
+
aiFilterStatus?: 'idle' | 'running' | 'complete' | 'cancelled' | 'failed';
|
|
16
|
+
aiFilterProgress?: {
|
|
17
|
+
done: number;
|
|
18
|
+
total: number;
|
|
19
|
+
} | null;
|
|
20
|
+
aiFilterError?: string | null;
|
|
21
|
+
onCancelAutoFilter?: () => void;
|
|
22
|
+
};
|
|
23
|
+
export declare function ScopeGateStep({ components, onConfirm, onQuit, aiFilterStatus, aiFilterProgress, aiFilterError, onCancelAutoFilter, }: ScopeGateStepProps): React.ReactElement;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import React, { useMemo, useState } from 'react';
|
|
4
|
+
import { useImmediateInput } from '../../../analyze/select/tui/hooks/useImmediateInput.js';
|
|
5
|
+
const VISIBLE_COUNT = 10;
|
|
6
|
+
const REASON_DISPLAY_MAX = 60;
|
|
7
|
+
// Pilot-2026-06-25 R2: AI-flagged rows render a subtle cyan `*` glyph before
|
|
8
|
+
// the name (in place of the Round-1 `[AI]` text badge). The marker persists
|
|
9
|
+
// regardless of whether the operator later toggles the row INCLUDED — manual
|
|
10
|
+
// decision wins; the marker is informational only.
|
|
11
|
+
const AI_MARKER = '*';
|
|
12
|
+
// Indentation prefix for the wrapped reason on the focused AI row. Aligns
|
|
13
|
+
// the reason under the row label so the eye reads it as a continuation.
|
|
14
|
+
const REASON_WRAP_INDENT = ' ';
|
|
15
|
+
function truncateReason(reason) {
|
|
16
|
+
if (reason === null || reason === undefined || reason === '')
|
|
17
|
+
return '<no reason given>';
|
|
18
|
+
if (reason.length <= REASON_DISPLAY_MAX)
|
|
19
|
+
return reason;
|
|
20
|
+
return reason.slice(0, REASON_DISPLAY_MAX - 1).trimEnd() + '…';
|
|
21
|
+
}
|
|
22
|
+
function isAiFlagged(row) {
|
|
23
|
+
// INTEG-4318: `failed` means the LLM omitted a decision for this component
|
|
24
|
+
// (e.g. batch under-emit). Surface these in the AI-recommended-exclusions
|
|
25
|
+
// section so the operator sees them and can override — silent inclusion was
|
|
26
|
+
// the bug.
|
|
27
|
+
return row.aiDecision === 'rejected' || row.aiDecision === 'failed';
|
|
28
|
+
}
|
|
29
|
+
export function ScopeGateStep({ components, onConfirm, onQuit, aiFilterStatus = 'idle', aiFilterProgress = null, aiFilterError = null, onCancelAutoFilter, }) {
|
|
30
|
+
// Pilot-2026-06-25: scope-gate UX overhaul — single unified list.
|
|
31
|
+
//
|
|
32
|
+
// Inverted mental model: we now track which rows are INCLUDED rather than
|
|
33
|
+
// which are EXCLUDED. The underlying delta-on-prop pattern survives so
|
|
34
|
+
// operator decisions stay sticky across streaming AI updates:
|
|
35
|
+
// - userExcluded: rows the operator explicitly toggled OFF
|
|
36
|
+
// - userUnExcluded: rows the operator explicitly toggled ON
|
|
37
|
+
// Effective INCLUDED for a row:
|
|
38
|
+
// if userExcluded.has(name): false
|
|
39
|
+
// else if userUnExcluded.has(name): true
|
|
40
|
+
// else: row.aiDecision !== 'rejected' // default: follow AI, default true if AI silent
|
|
41
|
+
const [userExcluded, setUserExcluded] = useState(new Set());
|
|
42
|
+
const [userUnExcluded, setUserUnExcluded] = useState(new Set());
|
|
43
|
+
const [cursor, setCursor] = useState(0);
|
|
44
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
45
|
+
// Pilot-2026-06-23: `s` opens a side panel showing the full reject_reason
|
|
46
|
+
// (untruncated) for an AI-flagged row under the cursor. With the focused-row
|
|
47
|
+
// wrap landed, the panel is mostly redundant — kept for screen-reader /
|
|
48
|
+
// overflow cases per spec.
|
|
49
|
+
const [reasonPanelOpen, setReasonPanelOpen] = useState(false);
|
|
50
|
+
// Pilot-2026-06-25 R2: two-section render. AI-flagged rows go to a top
|
|
51
|
+
// section ("AI recommended exclusions"); the rest fall into "Components".
|
|
52
|
+
// Within each section we preserve prop (extraction) order. Cursor walks
|
|
53
|
+
// the unified flat list [...aiList, ...componentsList].
|
|
54
|
+
const aiList = components.filter(isAiFlagged);
|
|
55
|
+
const componentsList = components.filter((c) => !isAiFlagged(c));
|
|
56
|
+
const flatList = [...aiList, ...componentsList];
|
|
57
|
+
const isIncluded = (row) => {
|
|
58
|
+
// Pilot-2026-06-25 invariant: operator decisions are ALWAYS sticky over
|
|
59
|
+
// streaming AI updates. If the operator explicitly toggled this row
|
|
60
|
+
// (userExcluded or userUnExcluded), that wins regardless of any later
|
|
61
|
+
// aiDecision the auto-filter writes via prop updates. The [AI] badge
|
|
62
|
+
// still appears on the row — it's informational, not authoritative.
|
|
63
|
+
if (userExcluded.has(row.name))
|
|
64
|
+
return false;
|
|
65
|
+
if (userUnExcluded.has(row.name))
|
|
66
|
+
return true;
|
|
67
|
+
// INTEG-4318: exclude on 'rejected' AND 'failed'. Missing/null aiDecision
|
|
68
|
+
// (auto-filter not run, or component never seen) still defaults to
|
|
69
|
+
// included so --no-auto-filter and skip-credentials flows are unchanged.
|
|
70
|
+
return row.aiDecision !== 'rejected' && row.aiDecision !== 'failed';
|
|
71
|
+
};
|
|
72
|
+
const partition = () => {
|
|
73
|
+
const accepted = [];
|
|
74
|
+
const rejected = [];
|
|
75
|
+
for (const c of flatList) {
|
|
76
|
+
if (isIncluded(c))
|
|
77
|
+
accepted.push(c.name);
|
|
78
|
+
else
|
|
79
|
+
rejected.push(c.name);
|
|
80
|
+
}
|
|
81
|
+
return { accepted, rejected };
|
|
82
|
+
};
|
|
83
|
+
const toggleFocused = () => {
|
|
84
|
+
const target = flatList[cursor];
|
|
85
|
+
if (!target)
|
|
86
|
+
return;
|
|
87
|
+
const currentlyIn = isIncluded(target);
|
|
88
|
+
if (currentlyIn) {
|
|
89
|
+
// Flip to EXCLUDED.
|
|
90
|
+
setUserExcluded((prev) => {
|
|
91
|
+
if (prev.has(target.name))
|
|
92
|
+
return prev;
|
|
93
|
+
const next = new Set(prev);
|
|
94
|
+
next.add(target.name);
|
|
95
|
+
return next;
|
|
96
|
+
});
|
|
97
|
+
setUserUnExcluded((prev) => {
|
|
98
|
+
if (!prev.has(target.name))
|
|
99
|
+
return prev;
|
|
100
|
+
const next = new Set(prev);
|
|
101
|
+
next.delete(target.name);
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// Flip to INCLUDED.
|
|
107
|
+
setUserUnExcluded((prev) => {
|
|
108
|
+
if (prev.has(target.name))
|
|
109
|
+
return prev;
|
|
110
|
+
const next = new Set(prev);
|
|
111
|
+
next.add(target.name);
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
setUserExcluded((prev) => {
|
|
115
|
+
if (!prev.has(target.name))
|
|
116
|
+
return prev;
|
|
117
|
+
const next = new Set(prev);
|
|
118
|
+
next.delete(target.name);
|
|
119
|
+
return next;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
useImmediateInput((input, key) => {
|
|
124
|
+
if (input === 'q' || key.escape) {
|
|
125
|
+
// Feature 3: while auto-filter is running, q cancels the LLM run instead
|
|
126
|
+
// of quitting the wizard. After completion (or if no auto-filter ran), q
|
|
127
|
+
// falls back to the existing wizard-quit behavior.
|
|
128
|
+
if (aiFilterStatus === 'running' && onCancelAutoFilter) {
|
|
129
|
+
onCancelAutoFilter();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Esc also closes the reason panel without quitting.
|
|
133
|
+
if (key.escape && reasonPanelOpen) {
|
|
134
|
+
setReasonPanelOpen(false);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
onQuit();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (input === 'f' || input === 'F') {
|
|
141
|
+
onConfirm(partition());
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (input === 's') {
|
|
145
|
+
setReasonPanelOpen((prev) => !prev);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// `a`, space, and `r` are all aliases for "toggle focused row INCLUDED ↔ EXCLUDED".
|
|
149
|
+
// `r` kept for muscle-memory; it no longer means "reject".
|
|
150
|
+
if (input === 'a' || input === ' ' || input === 'r') {
|
|
151
|
+
toggleFocused();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (input === 'A') {
|
|
155
|
+
// Pilot-2026-06-25 R2: toggle-all operates ONLY on the Components
|
|
156
|
+
// section. The AI section's defaults stay put — operators rarely want
|
|
157
|
+
// a mass-include of AI-flagged rows via one keystroke. Toggle behavior:
|
|
158
|
+
// if any Components row is excluded, include them all; else exclude all.
|
|
159
|
+
const compNames = componentsList.map((c) => c.name);
|
|
160
|
+
const anyCompExcluded = componentsList.some((c) => !isIncluded(c));
|
|
161
|
+
if (anyCompExcluded) {
|
|
162
|
+
setUserUnExcluded((prev) => {
|
|
163
|
+
const next = new Set(prev);
|
|
164
|
+
for (const n of compNames)
|
|
165
|
+
next.add(n);
|
|
166
|
+
return next;
|
|
167
|
+
});
|
|
168
|
+
setUserExcluded((prev) => {
|
|
169
|
+
const next = new Set(prev);
|
|
170
|
+
for (const n of compNames)
|
|
171
|
+
next.delete(n);
|
|
172
|
+
return next;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
setUserExcluded((prev) => {
|
|
177
|
+
const next = new Set(prev);
|
|
178
|
+
for (const n of compNames)
|
|
179
|
+
next.add(n);
|
|
180
|
+
return next;
|
|
181
|
+
});
|
|
182
|
+
setUserUnExcluded((prev) => {
|
|
183
|
+
const next = new Set(prev);
|
|
184
|
+
for (const n of compNames)
|
|
185
|
+
next.delete(n);
|
|
186
|
+
return next;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (key.upArrow || input === 'k') {
|
|
192
|
+
const len = flatList.length;
|
|
193
|
+
if (len === 0)
|
|
194
|
+
return;
|
|
195
|
+
setCursor((c) => {
|
|
196
|
+
const next = c <= 0 ? 0 : c - 1;
|
|
197
|
+
setScrollOffset((prev) => Math.min(prev, next));
|
|
198
|
+
return next;
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (key.downArrow || input === 'j') {
|
|
203
|
+
const len = flatList.length;
|
|
204
|
+
if (len === 0)
|
|
205
|
+
return;
|
|
206
|
+
setCursor((c) => {
|
|
207
|
+
const next = c >= len - 1 ? len - 1 : c + 1;
|
|
208
|
+
setScrollOffset((prev) => (next >= prev + VISIBLE_COUNT ? next - VISIBLE_COUNT + 1 : prev));
|
|
209
|
+
return next;
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
const total = flatList.length;
|
|
215
|
+
const includedCount = useMemo(() => flatList.filter((c) => isIncluded(c)).length, [components, userExcluded, userUnExcluded]);
|
|
216
|
+
const hasAnyAi = flatList.some(isAiFlagged);
|
|
217
|
+
const visibleEnd = Math.min(scrollOffset + VISIBLE_COUNT, total);
|
|
218
|
+
const visible = flatList.slice(scrollOffset, visibleEnd);
|
|
219
|
+
const above = scrollOffset;
|
|
220
|
+
const below = Math.max(0, total - visibleEnd);
|
|
221
|
+
// ── Banner / status helpers ───────────────────────────────────────────────
|
|
222
|
+
const showRunningHeader = aiFilterStatus === 'running' && aiFilterProgress !== null && aiFilterProgress.total > 0;
|
|
223
|
+
const showCancelledBanner = aiFilterStatus === 'cancelled';
|
|
224
|
+
const showFailedBanner = aiFilterStatus === 'failed';
|
|
225
|
+
const allRejected = aiFilterStatus === 'complete' && total > 0 && flatList.every((c) => !isIncluded(c));
|
|
226
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsx(Text, { color: "green", children: "\u2713 Extraction complete" }), _jsxs(Text, { dimColor: true, children: ["Found ", total, " component", total === 1 ? '' : 's', ". Pick which ones to import. Generation runs only on the included set."] }), showRunningHeader && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "cyan", children: ["[AI filtering (", aiFilterProgress.done, "/", aiFilterProgress.total, ")\u2026] ", _jsx(Text, { dimColor: true, children: "[q] cancels" })] }) })), showCancelledBanner && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["AI auto-filter cancelled", aiFilterProgress ? ` at ${aiFilterProgress.done}/${aiFilterProgress.total}` : '', ". Review remaining manually."] }) })), showFailedBanner && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["AI auto-filter failed: ", aiFilterError ?? 'unknown error', ". Continuing without AI suggestions."] }) })), reasonPanelOpen && flatList[cursor] !== undefined && isAiFlagged(flatList[cursor]) && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: `AI rejection reason: ${flatList[cursor].name}` }), _jsx(Text, { children: flatList[cursor].aiReason ?? '<no reason given>' }), _jsx(Text, { dimColor: true, children: "[s] close \u00B7 [Esc] close" })] })), allRejected ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: "AI excluded all components \u2014 press [a] to override or [q] to quit" }) })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [above > 0 && _jsxs(Text, { dimColor: true, children: ["\u2191 ", above, " above"] }), visible.map((c, vi) => {
|
|
227
|
+
const i = vi + scrollOffset;
|
|
228
|
+
const isCursor = i === cursor;
|
|
229
|
+
const included = isIncluded(c);
|
|
230
|
+
const aiFlagged = isAiFlagged(c);
|
|
231
|
+
const prefix = isCursor ? '›' : ' ';
|
|
232
|
+
// R2: color-glyphs replace word labels. Green [✓] for included,
|
|
233
|
+
// red [✗] for excluded. The component name follows the same
|
|
234
|
+
// color UNLESS the row is the cursor row, in which case the
|
|
235
|
+
// name flips to cyan (state glyph keeps its red/green).
|
|
236
|
+
const stateGlyph = included ? '[✓]' : '[✗]';
|
|
237
|
+
const stateColor = included ? 'green' : 'red';
|
|
238
|
+
// R2: cyan `*` glyph replaces the verbose `[AI]` badge.
|
|
239
|
+
const aiMarkerNode = aiFlagged ? _jsx(Text, { color: "cyan", children: `${AI_MARKER} ` }) : null;
|
|
240
|
+
const inlineReason = !isCursor && aiFlagged ? ` ${truncateReason(c.aiReason)}` : '';
|
|
241
|
+
const showAiHeader = aiList.length > 0 && i === 0;
|
|
242
|
+
const showComponentsHeader = componentsList.length > 0 && i === aiList.length;
|
|
243
|
+
const header = showAiHeader ? (_jsx(Text, { bold: true, children: `AI recommended exclusions (${aiList.length})` }, `hdr-ai-${i}`)) : showComponentsHeader ? (_jsx(Text, { bold: true, children: `Components (${componentsList.length})` }, `hdr-comp-${i}`)) : null;
|
|
244
|
+
if (isCursor) {
|
|
245
|
+
const wrapReason = aiFlagged && c.aiReason !== null && c.aiReason !== undefined && c.aiReason.length > 0;
|
|
246
|
+
return (_jsxs(React.Fragment, { children: [header, _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: `${prefix} ` }), aiMarkerNode, _jsx(Text, { color: stateColor, children: stateGlyph }), _jsx(Text, { color: "cyan", children: ` ${c.name}` })] }), wrapReason && _jsx(Text, { dimColor: true, children: `${REASON_WRAP_INDENT}${c.aiReason}` })] }, c.componentId));
|
|
247
|
+
}
|
|
248
|
+
return (_jsxs(React.Fragment, { children: [header, _jsxs(Text, { children: [_jsx(Text, { children: `${prefix} ` }), aiMarkerNode, _jsx(Text, { color: stateColor, children: stateGlyph }), _jsx(Text, { color: stateColor, children: ` ${c.name}` }), inlineReason !== '' && _jsx(Text, { dimColor: true, children: inlineReason })] })] }, c.componentId));
|
|
249
|
+
}), below > 0 && _jsxs(Text, { dimColor: true, children: ["\u2193 ", below, " below"] })] })), _jsxs(Box, { gap: 3, marginTop: 1, children: [includedCount > 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: includedCount }), _jsxs(Text, { dimColor: true, children: ["/", total, " included"] })] })) : (_jsx(Text, { color: "yellow", children: "none included" })), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[j/k]" }), " ", _jsx(Text, { dimColor: true, children: "move" })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[a/space]" }), " ", _jsx(Text, { dimColor: true, children: "toggle" })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[A]" }), " ", _jsx(Text, { dimColor: true, children: "toggle all" })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[f]" }), " ", _jsx(Text, { dimColor: true, children: "continue" })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[q]" }), " ", _jsx(Text, { dimColor: true, children: "quit" })] }), hasAnyAi && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "[s]" }), " ", _jsx(Text, { dimColor: true, children: "AI reason" })] })), hasAnyAi && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "*" }), " ", _jsx(Text, { dimColor: true, children: "originally excluded by AI" })] }))] })] }));
|
|
250
|
+
}
|
|
@@ -9,6 +9,7 @@ export function TokenInputStep({ onConfirm, onSkip, onQuit }) {
|
|
|
9
9
|
const [cursorVisible, setCursorVisible] = useState(true);
|
|
10
10
|
const [error, setError] = useState(null);
|
|
11
11
|
const [resolvedPath, setResolvedPath] = useState(null);
|
|
12
|
+
const [typingMode, setTypingMode] = useState(false);
|
|
12
13
|
useEffect(() => {
|
|
13
14
|
const interval = setInterval(() => setCursorVisible((v) => !v), 500);
|
|
14
15
|
return () => clearInterval(interval);
|
|
@@ -45,11 +46,26 @@ export function TokenInputStep({ onConfirm, onSkip, onQuit }) {
|
|
|
45
46
|
onConfirm(normalized);
|
|
46
47
|
return;
|
|
47
48
|
}
|
|
48
|
-
if (
|
|
49
|
+
if (key.tab) {
|
|
50
|
+
setTypingMode(false);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (key.escape) {
|
|
54
|
+
if (typingMode) {
|
|
55
|
+
setTypingMode(false);
|
|
56
|
+
setInputValue('');
|
|
57
|
+
setError(null);
|
|
58
|
+
setResolvedPath(null);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
onQuit();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!typingMode && input === 's') {
|
|
49
65
|
onSkip();
|
|
50
66
|
return;
|
|
51
67
|
}
|
|
52
|
-
if (
|
|
68
|
+
if (!typingMode && input === 'q') {
|
|
53
69
|
onQuit();
|
|
54
70
|
return;
|
|
55
71
|
}
|
|
@@ -60,11 +76,12 @@ export function TokenInputStep({ onConfirm, onSkip, onQuit }) {
|
|
|
60
76
|
return;
|
|
61
77
|
}
|
|
62
78
|
if (input && !key.ctrl && !key.meta) {
|
|
79
|
+
setTypingMode(true);
|
|
63
80
|
setInputValue((v) => v + input);
|
|
64
81
|
setError(null);
|
|
65
82
|
setResolvedPath(null);
|
|
66
83
|
}
|
|
67
84
|
});
|
|
68
85
|
const displayValue = inputValue + (cursorVisible ? '█' : ' ');
|
|
69
|
-
return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Design tokens" }), _jsx(Text, { dimColor: true, children: "Point me to your raw token file (e.g. ~/design-tokens/tokens.json). You can use ~, relative, or absolute paths. Claude will map it to DTCG format." }), _jsxs(Box, { flexDirection: "column", marginTop: 1, gap: 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "?" }), _jsx(Text, { children: "Token path (file or directory):" }), _jsx(Text, { children: displayValue })] }), resolvedPath && resolvedPath !== inputValue.trim() && !error && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2192 ", resolvedPath] }) })), error && (_jsxs(Box, { marginTop: 0, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["\u2717 ", error] }), resolvedPath && _jsxs(Text, { dimColor: true, children: [" Resolved to: ", resolvedPath] })] }))] }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[Enter]
|
|
86
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Design tokens" }), _jsx(Text, { dimColor: true, children: "Point me to your raw token file (e.g. ~/design-tokens/tokens.json). You can use ~, relative, or absolute paths. Claude will map it to DTCG format." }), _jsxs(Box, { flexDirection: "column", marginTop: 1, gap: 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "?" }), _jsx(Text, { children: "Token path (file or directory):" }), _jsx(Text, { children: displayValue })] }), resolvedPath && resolvedPath !== inputValue.trim() && !error && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2192 ", resolvedPath] }) })), error && (_jsxs(Box, { marginTop: 0, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["\u2717 ", error] }), resolvedPath && _jsxs(Text, { dimColor: true, children: [" Resolved to: ", resolvedPath] })] }))] }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[Enter] Submit / Skip if empty" }), _jsx(Text, { dimColor: true, children: "[Tab] Exit typing" }), _jsx(Text, { dimColor: true, children: "[Esc] Clear & exit" }), _jsx(Text, { dimColor: true, children: "[s] Skip" }), _jsx(Text, { dimColor: true, children: "[q] Quit" })] })] }));
|
|
70
87
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ServerPreviewResponse } from '@contentful/experience-design-system-types';
|
|
2
|
+
export type UseLivePreviewOptions = {
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
tokensPath: string;
|
|
6
|
+
spaceId: string;
|
|
7
|
+
environmentId: string;
|
|
8
|
+
cmaToken: string;
|
|
9
|
+
host: string;
|
|
10
|
+
onResult: (response: ServerPreviewResponse | null) => void;
|
|
11
|
+
debounceMs?: number;
|
|
12
|
+
};
|
|
13
|
+
export type LivePreviewStatus = 'idle' | 'running';
|
|
14
|
+
export type UseLivePreviewReturn = {
|
|
15
|
+
trigger: () => void;
|
|
16
|
+
status: LivePreviewStatus;
|
|
17
|
+
disabled: boolean;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Hook that owns the post-save live-preview side effect for the wizard's
|
|
21
|
+
* final-review step. Centralizes:
|
|
22
|
+
*
|
|
23
|
+
* - 500ms debounce so rapid Ctrl+S spam collapses to a single API call.
|
|
24
|
+
* - Generation tag on every fire so a stale in-flight call's response is
|
|
25
|
+
* discarded if a newer fire has already kicked off (in lieu of an
|
|
26
|
+
* AbortController for v1).
|
|
27
|
+
* - Disable-for-session on 401/403: subsequent triggers no-op until the
|
|
28
|
+
* wizard exits. One-line stderr warning. No modal, no re-prompt — the
|
|
29
|
+
* operator is mid-edit and we must not interrupt the flow.
|
|
30
|
+
* - Status state for the sidebar spinner (`'idle' | 'running'`).
|
|
31
|
+
*
|
|
32
|
+
* Cred-missing / disabled / not-enabled cases short-circuit BEFORE the timer
|
|
33
|
+
* is set so we don't accumulate dead timers.
|
|
34
|
+
*/
|
|
35
|
+
export declare function useLivePreview(opts: UseLivePreviewOptions): UseLivePreviewReturn;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { ApiError } from '../../apply/api-client.js';
|
|
3
|
+
import { runLivePreview } from './runLivePreview.js';
|
|
4
|
+
const DEFAULT_DEBOUNCE_MS = 500;
|
|
5
|
+
/**
|
|
6
|
+
* Hook that owns the post-save live-preview side effect for the wizard's
|
|
7
|
+
* final-review step. Centralizes:
|
|
8
|
+
*
|
|
9
|
+
* - 500ms debounce so rapid Ctrl+S spam collapses to a single API call.
|
|
10
|
+
* - Generation tag on every fire so a stale in-flight call's response is
|
|
11
|
+
* discarded if a newer fire has already kicked off (in lieu of an
|
|
12
|
+
* AbortController for v1).
|
|
13
|
+
* - Disable-for-session on 401/403: subsequent triggers no-op until the
|
|
14
|
+
* wizard exits. One-line stderr warning. No modal, no re-prompt — the
|
|
15
|
+
* operator is mid-edit and we must not interrupt the flow.
|
|
16
|
+
* - Status state for the sidebar spinner (`'idle' | 'running'`).
|
|
17
|
+
*
|
|
18
|
+
* Cred-missing / disabled / not-enabled cases short-circuit BEFORE the timer
|
|
19
|
+
* is set so we don't accumulate dead timers.
|
|
20
|
+
*/
|
|
21
|
+
export function useLivePreview(opts) {
|
|
22
|
+
const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
23
|
+
const [status, setStatus] = useState('idle');
|
|
24
|
+
const [disabled, setDisabled] = useState(false);
|
|
25
|
+
const timerRef = useRef(null);
|
|
26
|
+
const generationRef = useRef(0);
|
|
27
|
+
const latestRef = useRef(0);
|
|
28
|
+
const inflightRef = useRef(0);
|
|
29
|
+
// Mirror props in a ref so the trigger callback can stay stable.
|
|
30
|
+
const optsRef = useRef(opts);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
optsRef.current = opts;
|
|
33
|
+
});
|
|
34
|
+
// Mirror disabled into a ref so the timer callback sees the latest value
|
|
35
|
+
// without redefining `trigger` (which would defeat its memoization).
|
|
36
|
+
const disabledRef = useRef(disabled);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
disabledRef.current = disabled;
|
|
39
|
+
}, [disabled]);
|
|
40
|
+
// Clear any pending timer on unmount.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
return () => {
|
|
43
|
+
if (timerRef.current) {
|
|
44
|
+
clearTimeout(timerRef.current);
|
|
45
|
+
timerRef.current = null;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
const trigger = useCallback(() => {
|
|
50
|
+
const current = optsRef.current;
|
|
51
|
+
if (!current.enabled)
|
|
52
|
+
return;
|
|
53
|
+
if (disabledRef.current)
|
|
54
|
+
return;
|
|
55
|
+
if (!current.spaceId || !current.environmentId || !current.cmaToken)
|
|
56
|
+
return;
|
|
57
|
+
if (timerRef.current) {
|
|
58
|
+
clearTimeout(timerRef.current);
|
|
59
|
+
timerRef.current = null;
|
|
60
|
+
}
|
|
61
|
+
timerRef.current = setTimeout(() => {
|
|
62
|
+
timerRef.current = null;
|
|
63
|
+
void fire();
|
|
64
|
+
}, debounceMs);
|
|
65
|
+
}, [debounceMs]);
|
|
66
|
+
const fire = async () => {
|
|
67
|
+
const current = optsRef.current;
|
|
68
|
+
if (disabledRef.current)
|
|
69
|
+
return;
|
|
70
|
+
generationRef.current += 1;
|
|
71
|
+
const generation = generationRef.current;
|
|
72
|
+
latestRef.current = generation;
|
|
73
|
+
inflightRef.current += 1;
|
|
74
|
+
setStatus('running');
|
|
75
|
+
try {
|
|
76
|
+
const result = await runLivePreview({
|
|
77
|
+
sessionId: current.sessionId,
|
|
78
|
+
tokensPath: current.tokensPath,
|
|
79
|
+
spaceId: current.spaceId,
|
|
80
|
+
environmentId: current.environmentId,
|
|
81
|
+
cmaToken: current.cmaToken,
|
|
82
|
+
host: current.host,
|
|
83
|
+
generation,
|
|
84
|
+
});
|
|
85
|
+
// Discard stale responses (generation tag).
|
|
86
|
+
if (result.generation !== latestRef.current)
|
|
87
|
+
return;
|
|
88
|
+
current.onResult(result.response);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
92
|
+
setDisabled(true);
|
|
93
|
+
disabledRef.current = true;
|
|
94
|
+
try {
|
|
95
|
+
process.stderr.write(`live-preview: ${err.status}, disabling for this session\n`);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// best-effort
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
103
|
+
try {
|
|
104
|
+
process.stderr.write(`live-preview: ${msg}\n`);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// best-effort
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
inflightRef.current -= 1;
|
|
113
|
+
if (inflightRef.current <= 0) {
|
|
114
|
+
inflightRef.current = 0;
|
|
115
|
+
setStatus('idle');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return { trigger, status, disabled };
|
|
120
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parser for the generate-step stderr stream.
|
|
3
|
+
*
|
|
4
|
+
* Two line shapes can carry progress info:
|
|
5
|
+
*
|
|
6
|
+
* 1. `progress=generate:<done>/<total>:<name>` — emitted at terminal completion
|
|
7
|
+
* of each component in `runAllComponents`. `done` is the count of completed
|
|
8
|
+
* components and is monotonically non-decreasing.
|
|
9
|
+
* 2. `[<index+1>/<total>] <name>` — legacy line emitted at the START of each
|
|
10
|
+
* component (and again on retry). With concurrency > 1, the index reflects
|
|
11
|
+
* the worker's starting position, not completions, so it MUST NOT drive
|
|
12
|
+
* `done` — only `current` (the name shown to the user).
|
|
13
|
+
*
|
|
14
|
+
* Given a chunk and the previous state, returns the updated state.
|
|
15
|
+
*/
|
|
16
|
+
export type GenerateProgressState = {
|
|
17
|
+
done: number;
|
|
18
|
+
total: number;
|
|
19
|
+
current: string;
|
|
20
|
+
} | null;
|
|
21
|
+
export declare function parseGenerateStderrChunk(chunk: string, prev: GenerateProgressState): GenerateProgressState;
|