diff --git a/.gitignore b/.gitignore
index 7e0a349..176cb21 100644
--- a/.gitignore
+++ b/.gitignore
@@ -117,3 +117,4 @@ pnpm-debug.log*
# vercel
.vercel/
+test-results/
diff --git a/README.md b/README.md
index a2ff3cd..58236f2 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,9 @@ Build high-signal agent and instruction files from community-proven best practic
4. Answer topic prompts across general, architecture, performance, security, commits, and more—or lean on the recommended defaults when you need a fast decision.
5. Review a completion summary that highlights what made it into your file and which areas still need decisions.
+## Architecture docs
+- [Scan → Wizard Flow](docs/scan-flow.md) – how repository scans feed conventions, defaults, and final instruction files.
+
## Community knowledge base
- Every topic originates from the developer community—playbooks, real-world retrospectives, and shared tooling habits.
- JSON entries in `data/` capture those insights: each answer carries labels, examples, pros/cons, tags, and authoritative `docs` links.
diff --git a/app/api/scan-generate/[fileId]/route.ts b/app/api/scan-generate/[fileId]/route.ts
new file mode 100644
index 0000000..5ad9148
--- /dev/null
+++ b/app/api/scan-generate/[fileId]/route.ts
@@ -0,0 +1,40 @@
+import { NextRequest, NextResponse } from "next/server"
+
+import { buildResponsesFromScan } from "@/lib/scan-to-wizard"
+import { renderTemplate } from "@/lib/template-render"
+import { getMimeTypeForFormat } from "@/lib/wizard-utils"
+import type { RepoScanSummary } from "@/types/repo-scan"
+
+type RouteContext = {
+ params: Promise<{ fileId: string }>
+}
+
+export async function POST(request: NextRequest, context: RouteContext) {
+ try {
+ const { fileId } = await context.params
+ const payload = (await request.json()) as { scan?: RepoScanSummary | null; format?: string | null }
+
+ if (!payload?.scan) {
+ return NextResponse.json({ error: "Missing scan payload" }, { status: 400 })
+ }
+
+ const { stack, responses, defaultedResponseMeta } = await buildResponsesFromScan(payload.scan)
+ responses.outputFile = fileId
+
+ const rendered = await renderTemplate({
+ responses,
+ frameworkFromPath: stack,
+ fileNameFromPath: fileId,
+ defaultedResponses: defaultedResponseMeta,
+ })
+
+ return NextResponse.json({
+ fileName: rendered.fileName,
+ content: rendered.content,
+ mimeType: getMimeTypeForFormat(payload.format ?? undefined) ?? null,
+ })
+ } catch (error) {
+ console.error("Failed to generate instructions from scan", error)
+ return NextResponse.json({ error: "Failed to generate instructions from scan" }, { status: 500 })
+ }
+}
diff --git a/app/api/scan-repo/route.ts b/app/api/scan-repo/route.ts
index b4408a4..e0d4513 100644
--- a/app/api/scan-repo/route.ts
+++ b/app/api/scan-repo/route.ts
@@ -6,6 +6,9 @@ import type {
RepoScanSummary,
RepoStructureSummary,
} from "@/types/repo-scan"
+import { collectConventionValues, normalizeConventionValue } from "@/lib/convention-values"
+import { loadStackConventions } from "@/lib/conventions"
+import { inferStackFromScan } from "@/lib/scan-to-wizard"
const GITHUB_API_BASE_URL = "https://api.github.com"
const GITHUB_HOSTNAMES = new Set(["github.com", "www.github.com"])
@@ -112,7 +115,10 @@ const detectStructure = (paths: string[]): RepoStructureSummary => {
}
}
-const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: string[]; testing: string[]; frameworks: string[] } => {
+const detectTooling = async (
+ paths: string[],
+ pkg: PackageJson | null,
+): Promise<{ tooling: string[]; testing: string[]; frameworks: string[] }> => {
const tooling = new Set()
const testing = new Set()
const frameworks = new Set()
@@ -121,7 +127,10 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str
const matchers: Array<{ pattern: RegExp; value: string; target: Set }> = [
{ pattern: /^requirements\.txt$/, value: "pip", target: tooling },
- { pattern: /^pyproject\.toml$/, value: "Poetry", target: tooling },
+ { pattern: /^poetry\.lock$/, value: "Poetry", target: tooling },
+ { pattern: /^pipfile$/, value: "Pipenv", target: tooling },
+ { pattern: /^pipfile\.lock$/, value: "Pipenv", target: tooling },
+ { pattern: /^pyproject\.toml$/, value: "PyProject", target: tooling },
{ pattern: /pom\.xml$/, value: "Maven", target: tooling },
{ pattern: /build\.gradle(\.kts)?$/, value: "Gradle", target: tooling },
{ pattern: /(^|\/)dockerfile$/, value: "Docker", target: tooling },
@@ -138,6 +147,15 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str
{ pattern: /vite\.config\.(js|cjs|mjs|ts)?$/, value: "Vite", target: tooling },
{ pattern: /rollup\.config\.(js|cjs|mjs|ts)?$/, value: "Rollup", target: tooling },
{ pattern: /tailwind\.config\.(js|cjs|mjs|ts)?$/, value: "Tailwind CSS", target: tooling },
+ { pattern: /(^|\/)ruff\.toml$/, value: "Ruff", target: tooling },
+ { pattern: /(^|\/)\.ruff\.toml$/, value: "Ruff", target: tooling },
+ { pattern: /(^|\/)black\.toml$/, value: "black", target: tooling },
+ { pattern: /(^|\/)\.flake8$/, value: "flake8", target: tooling },
+ { pattern: /(^|\/)flake8\.cfg$/, value: "flake8", target: tooling },
+ { pattern: /(^|\/)mypy\.ini$/, value: "mypy", target: tooling },
+ { pattern: /(^|\/)\.mypy\.ini$/, value: "mypy", target: tooling },
+ { pattern: /(^|\/)setup\.cfg$/, value: "setup.cfg", target: tooling },
+ { pattern: /(^|\/)\.pre-commit-config\.ya?ml$/, value: "pre-commit", target: tooling },
{ pattern: /jest\.config\.(js|cjs|mjs|ts|json)?$/, value: "Jest", target: testing },
{ pattern: /vitest\.(config|setup)/, value: "Vitest", target: testing },
{ pattern: /(^|\/)cypress\//, value: "Cypress", target: testing },
@@ -145,6 +163,9 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str
{ pattern: /playwright\.config\.(js|cjs|mjs|ts)?$/, value: "Playwright", target: testing },
{ pattern: /karma\.conf(\.js)?$/, value: "Karma", target: testing },
{ pattern: /mocha\./, value: "Mocha", target: testing },
+ { pattern: /(^|\/)pytest\.ini$/, value: "pytest", target: testing },
+ { pattern: /(^|\/)conftest\.py$/, value: "pytest", target: testing },
+ { pattern: /(^|\/)tox\.ini$/, value: "tox", target: testing },
]
for (const { pattern, value, target } of matchers) {
@@ -259,6 +280,8 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str
}
}
+ await detectPythonTestingSignals(paths, pkg, testing)
+
return {
tooling: dedupeAndSort(tooling),
testing: dedupeAndSort(testing),
@@ -266,6 +289,79 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str
}
}
+type TestingConventionValues = {
+ unit: string[]
+ e2e: string[]
+}
+
+const testingConventionCache = new Map()
+
+const getTestingConventionValues = async (stackId: string): Promise => {
+ const normalized = stackId.trim().toLowerCase()
+ if (testingConventionCache.has(normalized)) {
+ return testingConventionCache.get(normalized)!
+ }
+
+ const { conventions } = await loadStackConventions(normalized)
+ const values: TestingConventionValues = {
+ unit: collectConventionValues(conventions, "testingUT"),
+ e2e: collectConventionValues(conventions, "testingE2E"),
+ }
+ testingConventionCache.set(normalized, values)
+ return values
+}
+
+const findConventionValue = (values: string[], target: string): string | null => {
+ const normalizedTarget = normalizeConventionValue(target)
+ return values.find((value) => normalizeConventionValue(value) === normalizedTarget) ?? null
+}
+
+const BEHAVE_DEPENDENCIES = ["behave", "behave-django", "behave-webdriver"]
+
+export const detectPythonTestingSignals = async (
+ paths: string[],
+ pkg: PackageJson | null,
+ testing: Set,
+): Promise => {
+ const { unit } = await getTestingConventionValues("python")
+ if (unit.length === 0) {
+ return
+ }
+
+ const behaveValue = findConventionValue(unit, "behave")
+ const unittestValue = findConventionValue(unit, "unittest")
+
+ if (!behaveValue && !unittestValue) {
+ return
+ }
+
+ const lowerCasePaths = paths.map((path) => path.toLowerCase())
+
+ if (behaveValue) {
+ const hasFeaturesDir = lowerCasePaths.some((path) => path.startsWith("features/") || path.includes("/features/"))
+ const hasStepsDir = lowerCasePaths.some((path) => path.includes("/steps/"))
+ const hasEnvironment = lowerCasePaths.some((path) => path.endsWith("/environment.py") || path.endsWith("environment.py"))
+ const hasDependency = pkg ? dependencyHas(pkg, BEHAVE_DEPENDENCIES) : false
+
+ if (hasDependency || (hasFeaturesDir && (hasStepsDir || hasEnvironment))) {
+ testing.add(behaveValue)
+ }
+ }
+
+ if (unittestValue) {
+ const hasUnitFiles = lowerCasePaths.some((path) => {
+ if (!/(^|\/)(tests?|testcases|specs)\//.test(path)) {
+ return false
+ }
+ return /(^|\/)(test_[^/]+|[^/]+_test)\.py$/.test(path)
+ })
+
+ if (hasUnitFiles) {
+ testing.add(unittestValue)
+ }
+ }
+}
+
const readPackageJson = async (
owner: string,
repo: string,
@@ -753,7 +849,7 @@ export async function GET(request: NextRequest): Promise(summary)
} catch (error) {
console.error("Unexpected error while scanning repository", error)
diff --git a/app/existing/[repoUrl]/repo-scan-client.tsx b/app/existing/[repoUrl]/repo-scan-client.tsx
index 726b22b..1c1b85a 100644
--- a/app/existing/[repoUrl]/repo-scan-client.tsx
+++ b/app/existing/[repoUrl]/repo-scan-client.tsx
@@ -16,6 +16,8 @@ import RepoScanLoader from "@/components/repo-scan-loader"
import type { GeneratedFileResult } from "@/types/output"
const buildQuery = (url: string) => `/api/scan-repo?url=${encodeURIComponent(url)}`
+const CONVENTIONS_DOC_URL =
+ process.env.NEXT_PUBLIC_CONVENTIONS_URL ?? "https://github.com/devcontext-ai/devcontext/tree/main/conventions"
const formatList = (values: string[]) => (values.length > 0 ? values.join(", ") : "Not detected")
@@ -99,9 +101,11 @@ export default function RepoScanClient({ initialRepoUrl }: RepoScanClientProps)
return []
}
- return Object.entries(scanResult.structure).map(([key, value]) => ({
+ const relevantKeys = scanResult.conventions?.structureRelevant ?? ["src", "components", "tests", "apps", "packages"]
+
+ return relevantKeys.map((key) => ({
key,
- value,
+ value: scanResult.structure[key as keyof RepoScanSummary["structure"]] ?? false,
}))
}, [scanResult])
@@ -224,6 +228,28 @@ export default function RepoScanClient({ initialRepoUrl }: RepoScanClientProps)
+ {scanResult.conventions && !scanResult.conventions.hasCustomConventions ? (
+
+
+
+ We don’t have conventions for {scanResult.conventions.stack} yet.
+
+
+ Add a new conventions/{scanResult.conventions.stack}.json file to customize detection and defaults.
+
+
+
+ View conventions directory
+
+
+
+
+ ) : null}
diff --git a/app/new/stack/stack-summary-page.tsx b/app/new/stack/stack-summary-page.tsx
index 9996172..80ab27c 100644
--- a/app/new/stack/stack-summary-page.tsx
+++ b/app/new/stack/stack-summary-page.tsx
@@ -39,6 +39,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
const [responses, setResponses] = useState
(null)
const [freeTextResponses, setFreeTextResponses] = useState({})
const [autoFilledMap, setAutoFilledMap] = useState>({})
+ const [defaultedMap, setDefaultedMap] = useState>({})
const [stackLabel, setStackLabel] = useState(null)
const [autoFillNotice, setAutoFillNotice] = useState(null)
const [isLoading, setIsLoading] = useState(true)
@@ -66,6 +67,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
responses: defaultResponses,
freeTextResponses: defaultFreeTextResponses,
autoFilledMap: defaultsMap,
+ defaultedMap: defaultsAppliedMap,
stackLabel: label,
} =
await buildDefaultSummaryData(stackId)
@@ -78,6 +80,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
setResponses(defaultResponses)
setFreeTextResponses(defaultFreeTextResponses)
setAutoFilledMap(defaultsMap)
+ setDefaultedMap(defaultsAppliedMap)
setStackLabel(label)
setAutoFillNotice("We applied the recommended defaults for you. Tweak any section before generating.")
@@ -87,6 +90,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
responses: defaultResponses,
freeTextResponses: defaultFreeTextResponses,
autoFilledMap: defaultsMap,
+ defaultedMap: defaultsAppliedMap,
updatedAt: Date.now(),
})
} else {
@@ -103,6 +107,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
setResponses(null)
setFreeTextResponses({})
setAutoFilledMap({})
+ setDefaultedMap({})
setStackLabel(computedLabel)
setAutoFillNotice(null)
setErrorMessage("We couldn't find saved answers for this stack. Complete the wizard to generate your own summary.")
@@ -118,6 +123,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
setResponses(normalizedResponses)
setFreeTextResponses(storedState.freeTextResponses ?? {})
setAutoFilledMap(storedState.autoFilledMap ?? {})
+ setDefaultedMap(storedState.defaultedMap ?? {})
setStackLabel(storedState.stackLabel ?? computedLabel)
setAutoFillNotice(null)
}
@@ -153,9 +159,10 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
responses,
freeTextResponses,
autoFilledMap,
+ defaultedMap,
false
)
- }, [wizardSteps, responses, freeTextResponses, autoFilledMap])
+ }, [wizardSteps, responses, freeTextResponses, autoFilledMap, defaultedMap])
const handleGenerate = useCallback(
async (fileOption: FileOutputConfig) => {
@@ -237,8 +244,12 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
const nextAutoFilledMap = { ...autoFilledMap }
delete nextAutoFilledMap[question.id]
+ const nextDefaultedMap = { ...defaultedMap }
+ delete nextDefaultedMap[question.id]
+
setFreeTextResponses(nextFreeText)
setAutoFilledMap(nextAutoFilledMap)
+ setDefaultedMap(nextDefaultedMap)
setAutoFillNotice(null)
if (stackId) {
@@ -248,19 +259,21 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
responses,
freeTextResponses: nextFreeText,
autoFilledMap: nextAutoFilledMap,
+ defaultedMap: nextDefaultedMap,
updatedAt: Date.now(),
})
}
handleCloseEdit()
},
- [responses, freeTextResponses, autoFilledMap, stackId, stackLabel, summaryHeader, handleCloseEdit]
+ [responses, freeTextResponses, autoFilledMap, defaultedMap, stackId, stackLabel, summaryHeader, handleCloseEdit]
)
const applyAnswerUpdate = useCallback(
(question: WizardQuestion, answer: WizardAnswer) => {
const currentResponses: Responses = responses ? { ...responses } : {}
const currentAutoMap = { ...autoFilledMap }
+ const currentDefaultedMap = { ...defaultedMap }
const prevValue = currentResponses[question.id]
let nextValue: Responses[keyof Responses] | undefined
@@ -283,8 +296,10 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
}
delete currentAutoMap[question.id]
+ delete currentDefaultedMap[question.id]
setResponses(currentResponses)
setAutoFilledMap(currentAutoMap)
+ setDefaultedMap(currentDefaultedMap)
setAutoFillNotice(null)
if (stackId) {
@@ -294,6 +309,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
responses: currentResponses,
freeTextResponses,
autoFilledMap: currentAutoMap,
+ defaultedMap: currentDefaultedMap,
updatedAt: Date.now(),
})
}
@@ -302,7 +318,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
handleCloseEdit()
}
},
- [responses, freeTextResponses, autoFilledMap, stackId, stackLabel, summaryHeader, handleCloseEdit]
+ [responses, freeTextResponses, autoFilledMap, defaultedMap, stackId, stackLabel, summaryHeader, handleCloseEdit]
)
if (isLoading) {
diff --git a/app/page.tsx b/app/page.tsx
index c6ac8f8..3715200 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -10,7 +10,7 @@ import { absoluteUrl } from "@/lib/site-metadata"
const title = "DevContext | Repo-aware AI Coding Guidelines Assistant"
const description =
- "Generate AI-ready Copilot instructions, Cursor rules, and developer onboarding docs with a GitHub-aware coding guidelines workflow."
+ "Build from scratch with a guided wizard or scan a public GitHub repo to generate AI-ready Copilot instructions, Cursor rules, and developer onboarding docs."
export const metadata: Metadata = {
title,
diff --git a/components/Hero.tsx b/components/Hero.tsx
index 006f5e1..f128273 100644
--- a/components/Hero.tsx
+++ b/components/Hero.tsx
@@ -74,7 +74,7 @@ export function Hero() {
className="mx-auto w-full max-w-[420px] md:max-w-[520px]"
variants={itemVariants}
>
-
+
@@ -91,14 +91,14 @@ export function Hero() {
className="mx-auto max-w-4xl text-4xl font-semibold tracking-tight text-foreground md:text-6xl md:leading-tight"
variants={itemVariants}
>
- Repo-aware AI coding guidelines assistant for Copilot & Cursor
+ Wizard or Repo Scan. Your context, ready.
- Generate AI-ready Copilot instructions, Cursor rules, and developer onboarding docs in minutes—start from curated stacks or drop a repo into the GitHub analyzer.
+ Start from scratch with a guided wizard or analyze a public GitHub repo. DevContext detects languages, frameworks, tooling, and tests to generate Copilot instructions, Cursor rules, and onboarding docs you can edit and export.
@@ -187,10 +187,10 @@ export function Hero() {
>
- Scan a GitHub repository
+ Scan a public GitHub repository
- Paste an owner/repo or URL and we'll prefill the wizard with detected tech, tooling, and guardrail suggestions.
+ Paste an owner/repo or URL; we'll prefill the wizard with detected languages, frameworks, tooling, and tests.
diff --git a/components/Logo/Logo.tsx b/components/Logo/Logo.tsx
index 8e038b0..dc81e80 100644
--- a/components/Logo/Logo.tsx
+++ b/components/Logo/Logo.tsx
@@ -11,13 +11,14 @@ export default function Logo({ width = 350, height = 350 }: LogoProps) {
);
-}
\ No newline at end of file
+}
diff --git a/components/instructions-wizard.tsx b/components/instructions-wizard.tsx
index 67ee1bb..6952470 100644
--- a/components/instructions-wizard.tsx
+++ b/components/instructions-wizard.tsx
@@ -30,6 +30,7 @@ import { WizardAnswerGrid } from "./wizard-answer-grid"
import { WizardConfirmationDialog } from "./wizard-confirmation-dialog"
const suffixSteps = getSuffixSteps()
+const buildStepSignature = (step: WizardStep) => `${step.id}::${step.questions.map((question) => question.id).join("|")}`
export function InstructionsWizard({
initialStackId,
@@ -46,6 +47,7 @@ export function InstructionsWizard({
initialStackId ? { [STACK_QUESTION_ID]: initialStackId } : {}
)
const [freeTextResponses, setFreeTextResponses] = useState
({})
+ const [freeTextDrafts, setFreeTextDrafts] = useState>({})
const [dynamicSteps, setDynamicSteps] = useState(() =>
initialStackStep ? [initialStackStep] : []
)
@@ -65,6 +67,9 @@ export function InstructionsWizard({
const hasAppliedInitialStack = useRef(
initialStackStep && initialStackId ? initialStackId : null
)
+ const lastAppliedStackStepSignature = useRef(
+ initialStackStep ? buildStepSignature(initialStackStep) : null
+ )
const [activeStackLabel, setActiveStackLabel] = useState(initialStackLabel)
const wizardSteps = useMemo(() => [stacksStep, ...dynamicSteps, ...suffixSteps], [dynamicSteps])
@@ -87,7 +92,7 @@ export function InstructionsWizard({
const filterInputId = currentQuestion ? `answer-filter-${currentQuestion.id}` : "answer-filter"
const currentAnswerValue = currentQuestion ? responses[currentQuestion.id] : undefined
- const currentFreeTextValue = useMemo(() => {
+ const savedFreeTextValue = useMemo(() => {
if (!currentQuestion) {
return ""
}
@@ -95,12 +100,24 @@ export function InstructionsWizard({
const value = freeTextResponses[currentQuestion.id]
return typeof value === "string" ? value : ""
}, [currentQuestion, freeTextResponses])
+
+ const draftFreeTextValue = useMemo(() => {
+ if (!currentQuestion) {
+ return ""
+ }
+
+ const value = freeTextDrafts[currentQuestion.id]
+ return typeof value === "string" ? value : ""
+ }, [currentQuestion, freeTextDrafts])
+
+ const currentFreeTextValue =
+ draftFreeTextValue.length > 0 ? draftFreeTextValue : savedFreeTextValue
const freeTextConfig = currentQuestion?.freeText ?? null
const freeTextInputId = currentQuestion ? `free-text-${currentQuestion.id}` : "free-text"
const canSubmitFreeText = Boolean(freeTextConfig?.enabled && currentFreeTextValue.trim().length > 0)
- const hasSavedCustomFreeText = Boolean(freeTextConfig?.enabled && currentFreeTextValue.trim().length > 0)
+ const hasSavedCustomFreeText = Boolean(freeTextConfig?.enabled && savedFreeTextValue.trim().length > 0)
- const savedCustomFreeTextValue = currentFreeTextValue.trim()
+ const savedCustomFreeTextValue = savedFreeTextValue.trim()
const defaultAnswer = useMemo(
() => currentQuestion?.answers.find((answer) => answer.isDefault) ?? null,
@@ -190,12 +207,36 @@ export function InstructionsWizard({
const applyStackStep = useCallback(
(step: WizardStep, label: string | null, options?: { skipFastTrackPrompt?: boolean; stackId?: string }) => {
const skipFastTrackPrompt = options?.skipFastTrackPrompt ?? false
- const nextStackId = options?.stackId
+ const nextStackId = options?.stackId ?? null
+ const stepSignature = buildStepSignature(step)
+ const previousSignature = lastAppliedStackStepSignature.current
+ const isSameStep = previousSignature === stepSignature
setActiveStackLabel(label)
- setDynamicSteps([step])
setIsStackFastTrackPromptVisible(!skipFastTrackPrompt && step.questions.length > 0)
+ if (isSameStep) {
+ if (nextStackId) {
+ setResponses((prev) => {
+ if (prev[STACK_QUESTION_ID] === nextStackId) {
+ return prev
+ }
+
+ return {
+ ...prev,
+ [STACK_QUESTION_ID]: nextStackId,
+ }
+ })
+ }
+
+ lastAppliedStackStepSignature.current = stepSignature
+ return
+ }
+
+ const questionIds = new Set(step.questions.map((question) => question.id))
+
+ setDynamicSteps([step])
+
setResponses((prev) => {
const next: Responses = { ...prev }
@@ -203,12 +244,10 @@ export function InstructionsWizard({
next[STACK_QUESTION_ID] = nextStackId
}
- step.questions.forEach((question) => {
- if (question.id === STACK_QUESTION_ID) {
- return
+ questionIds.forEach((questionId) => {
+ if (questionId !== STACK_QUESTION_ID && questionId in next) {
+ delete next[questionId]
}
-
- delete next[question.id]
})
return next
@@ -222,13 +261,27 @@ export function InstructionsWizard({
let didMutate = false
const next = { ...prev }
- step.questions.forEach((question) => {
- if (question.id === STACK_QUESTION_ID) {
- return
+ questionIds.forEach((questionId) => {
+ if (questionId !== STACK_QUESTION_ID && questionId in next) {
+ delete next[questionId]
+ didMutate = true
}
+ })
+
+ return didMutate ? next : prev
+ })
+
+ setFreeTextDrafts((prev) => {
+ if (Object.keys(prev).length === 0) {
+ return prev
+ }
+
+ let didMutate = false
+ const next = { ...prev }
- if (next[question.id] !== undefined) {
- delete next[question.id]
+ questionIds.forEach((questionId) => {
+ if (questionId !== STACK_QUESTION_ID && questionId in next) {
+ delete next[questionId]
didMutate = true
}
})
@@ -239,6 +292,8 @@ export function InstructionsWizard({
setCurrentStepIndex(1)
setCurrentQuestionIndex(0)
setAutoFilledQuestionMap({})
+
+ lastAppliedStackStepSignature.current = stepSignature
},
[]
)
@@ -415,6 +470,7 @@ export function InstructionsWizard({
})
setFreeTextResponses((prev) => (Object.keys(prev).length > 0 ? {} : prev))
+ setFreeTextDrafts((prev) => (Object.keys(prev).length > 0 ? {} : prev))
markQuestionsAutoFilled(autoFilledIds)
setIsStackFastTrackPromptVisible(false)
@@ -517,7 +573,7 @@ export function InstructionsWizard({
const { value } = event.target
let didChange = false
- setFreeTextResponses((prev) => {
+ setFreeTextDrafts((prev) => {
const existing = prev[question.id]
if (value.length === 0) {
@@ -569,7 +625,9 @@ export function InstructionsWizard({
) => {
const allowAutoAdvance = options?.allowAutoAdvance ?? true
const trimmedValue = rawValue.trim()
- const existingValue = typeof freeTextResponses[question.id] === "string" ? freeTextResponses[question.id] : ""
+ const draftValue = typeof freeTextDrafts[question.id] === "string" ? freeTextDrafts[question.id] : ""
+ const savedValue = typeof freeTextResponses[question.id] === "string" ? freeTextResponses[question.id] : ""
+ const existingValue = savedValue
if (trimmedValue === existingValue) {
if (allowAutoAdvance && trimmedValue.length > 0 && !hasSelectionForQuestion(question)) {
@@ -578,6 +636,18 @@ export function InstructionsWizard({
}, 0)
}
+ if (draftValue.length > 0) {
+ setFreeTextDrafts((prev) => {
+ if (!(question.id in prev)) {
+ return prev
+ }
+
+ const next = { ...prev }
+ delete next[question.id]
+ return next
+ })
+ }
+
return
}
@@ -598,6 +668,26 @@ export function InstructionsWizard({
}
})
+ setFreeTextDrafts((prev) => {
+ if (trimmedValue.length === 0) {
+ if (!(question.id in prev)) {
+ return prev
+ }
+
+ const next = { ...prev }
+ delete next[question.id]
+ return next
+ }
+
+ if (!(question.id in prev)) {
+ return prev
+ }
+
+ const next = { ...prev }
+ delete next[question.id]
+ return next
+ })
+
clearAutoFilledFlag(question.id)
if (allowAutoAdvance && trimmedValue.length > 0 && !hasSelectionForQuestion(question)) {
@@ -660,6 +750,7 @@ export function InstructionsWizard({
const stackIdToClear = selectedStackId
setResponses({})
setFreeTextResponses({})
+ setFreeTextDrafts({})
setDynamicSteps([])
setCurrentStepIndex(0)
setCurrentQuestionIndex(0)
@@ -856,6 +947,7 @@ export function InstructionsWizard({