diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ad58dd..18cd528d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **TUI goal creation result screen**: Cockpit and Goals-screen authoring now show a bounded pending, success, or failure result after submission. Successful results include the created goal ID and remain visible until Enter is pressed; failures retain every entered value for retry or allow cancellation with Escape. + +### Fixed + +- **TUI goal scope authoring**: Scope-in and scope-out values are now collected as independent repeatable items, preserving spaces, item order, and entered values when navigating backward or forward through the goal wizard. +- **TUI goal authoring layout**: Long objective values now wrap within the bounded goal wizard without expanding its background into horizontal bands, and the input cursor remains after the final wrapped character. + ## [3.20.0] - 2026-07-25 ### Added @@ -812,7 +821,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Goal update extended**: 'goal update' extended with '--nextGoalId' for latent goal chaining. - ## [1.0.0-beta.0] - 2026-01-01 ### Changed diff --git a/docs/getting-started/first-run.md b/docs/getting-started/first-run.md index ac344eaa..4750095e 100644 --- a/docs/getting-started/first-run.md +++ b/docs/getting-started/first-run.md @@ -111,7 +111,7 @@ Select option 2 to auto-approve subsequent registrations of the same type, or re End your agent session when finished, or clear context and switch to a fresh terminal for Step 3. :::tip[Best practice] -Archive all of your context related `.md` files to a location outside of your repository, and strip your `AGENTS.md` of any special instructions that can be registered with Jumbo. They will cause context bloat and potential conflicting instructions (as your project evolves) if left for agents to read. *You can always ask an agent to port them from Jumbo to an `.md` later if you want to remove Jumbo.* +Archive all of your context related `.md` files to a location outside of your repository, and strip your `AGENTS.md` of any special instructions that can be registered with Jumbo. They will cause context bloat and potential conflicting instructions (as your project evolves) if left for agents to read. _You can always ask an agent to port them from Jumbo to an `.md` later if you want to remove Jumbo._ ::: --- @@ -133,7 +133,7 @@ jumbo goal add \ "Includes unit tests for limit exceeded scenarios" ``` -If you are using the TUI, run `jumbo` and press `g` from the primed-empty Cockpit screen to open the same goal authoring flow without leaving the interface. +If you are using the TUI, run `jumbo` and press `g` from the primed-empty Cockpit screen to open the same goal authoring flow without leaving the interface. After submission, the final screen shows the request status and created goal ID; press Enter to acknowledge success, or retry or cancel if creation fails. You can also press `m` anywhere the main TUI frame owns input to open the Mega Menu. Use the arrow keys and Enter to navigate between Cockpit, Goals, Memory, and Settings. Goal menu entries such as Backlog, Active, and Archive open the Goals screen with the matching status filter already applied. diff --git a/src/presentation/tui/application-shell/App.tsx b/src/presentation/tui/application-shell/App.tsx index 3b52b0db..6b767196 100644 --- a/src/presentation/tui/application-shell/App.tsx +++ b/src/presentation/tui/application-shell/App.tsx @@ -9,7 +9,11 @@ import { SearchOverlay } from "../search/SearchOverlay.js"; import { InitFlow } from "../project-initialization/InitFlow.js"; import type { InitFlowActionControllers } from "../project-initialization/InitFlow.js"; import { GoalAuthoringFlow } from "../goals/GoalAuthoringFlow.js"; -import type { GoalAuthoringValues } from "../goals/GoalAuthoringFlow.js"; +import type { + GoalAuthoringSubmissionResult, + GoalAuthoringValues, +} from "../goals/GoalAuthoringFlow.js"; +import { GoalAuthoringRequestStatus } from "../goals/GoalAuthoringFlowConstants.js"; import { AddGoalRequestFactory } from "../goals/AddGoalRequestFactory.js"; import { DEFAULT_SCREEN_INDEX } from "../navigation/ScreenDefinitions.js"; import { ActionDispatcher } from "../action-dispatch/ActionDispatcher.js"; @@ -20,7 +24,10 @@ import type { StateReaderOptions } from "../state-reading/StateReaderOptions.js" import { useProjectContext } from "../state-reading/useProjectContext.js"; import { SubprocessManagerProvider } from "../daemon-subprocesses/SubprocessManagerProvider.js"; import { useSubprocessManager } from "../daemon-subprocesses/useSubprocessManager.js"; -import type { ISubprocessManager, SubprocessSnapshot } from "../daemon-subprocesses/ISubprocessManager.js"; +import type { + ISubprocessManager, + SubprocessSnapshot, +} from "../daemon-subprocesses/ISubprocessManager.js"; import type { NotificationDrawerNotification } from "./NotificationDrawer.js"; import type { CliUpdateController } from "../../../application/cli-metadata/update/CliUpdateController.js"; import type { CliUpdateCheckResult } from "../../../application/cli-metadata/update/CliUpdateCheckResult.js"; @@ -187,19 +194,17 @@ function AppFrame({ const subprocessManager = useSubprocessManager(); const { columns, rows } = useTerminalDimensions(); const projectContext = useProjectContext(); - const [activeScreenIndex, setActiveScreenIndex] = useState( - DEFAULT_SCREEN_INDEX, - ); + const [activeScreenIndex, setActiveScreenIndex] = + useState(DEFAULT_SCREEN_INDEX); const [initFlowOpen, setInitFlowOpen] = useState(false); const [goalAuthoringOpen, setGoalAuthoringOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [megaMenuOpen, setMegaMenuOpen] = useState(false); const [notificationDrawerOpen, setNotificationDrawerOpen] = useState(false); const [screenModalOpen, setScreenModalOpen] = useState(false); - const [goalStatusFilter, setGoalStatusFilter] = - useState(undefined); - const [goalAuthoringError, setGoalAuthoringError] = useState(null); - const [goalAuthoringWorking, setGoalAuthoringWorking] = useState(false); + const [goalStatusFilter, setGoalStatusFilter] = useState< + readonly GoalStatusType[] | undefined + >(undefined); const [lifecycleRouteOverride, setLifecycleRouteOverride] = useState(null); const [unprimedSkipped, setUnprimedSkipped] = useState(false); @@ -209,9 +214,9 @@ function AppFrame({ const [billboardAnimationComplete, setBillboardAnimationComplete] = useState( !launchAnimationEnabled, ); - const [daemonStatuses, setDaemonStatuses] = useState( - subprocessManager.getAllStatuses(), - ); + const [daemonStatuses, setDaemonStatuses] = useState< + readonly SubprocessSnapshot[] + >(subprocessManager.getAllStatuses()); const [cliUpdateCheck, setCliUpdateCheck] = useState(null); const [cliUpgradeResult, setCliUpgradeResult] = @@ -338,7 +343,6 @@ function AppFrame({ setInitFlowOpen(true); } if (goalAuthoringShortcutEnabled && (input === "g" || input === "G")) { - setGoalAuthoringError(null); setGoalAuthoringOpen(true); } if ( @@ -379,10 +383,9 @@ function AppFrame({ const handleInitComplete = useCallback( async (_values: Record) => { - await completeStateChangingOverlay( - () => setInitFlowOpen(false), - { reinstallStateReaders: true }, - ); + await completeStateChangingOverlay(() => setInitFlowOpen(false), { + reinstallStateReaders: true, + }); }, [completeStateChangingOverlay], ); @@ -392,36 +395,47 @@ function AppFrame({ }, []); const handleGoalAuthoringComplete = useCallback( - async (values: GoalAuthoringValues) => { + async ( + values: GoalAuthoringValues, + ): Promise => { const addGoalController = actionControllers?.addGoalController; if (addGoalController === undefined) { - setGoalAuthoringError(AppCopy.goalAuthoringUnavailable); - return; + return { + status: GoalAuthoringRequestStatus.FAILURE, + error: AppCopy.goalAuthoringUnavailable, + }; } - setGoalAuthoringWorking(true); - setGoalAuthoringError(null); const result = await ActionDispatcher.dispatch( addGoalController, AddGoalRequestFactory.create(values), ); - setGoalAuthoringWorking(false); if (!result.ok) { - setGoalAuthoringError(result.error.message); - return; + return { + status: GoalAuthoringRequestStatus.FAILURE, + error: result.error.message, + }; } - await completeStateChangingOverlay( - () => setGoalAuthoringOpen(false), - { lifecycleRouteOverride: ProjectLifecycle.PRIMED }, - ); + return { + status: GoalAuthoringRequestStatus.SUCCESS, + goalId: result.response.goalId, + }; }, - [actionControllers, completeStateChangingOverlay], + [actionControllers], + ); + + const handleGoalAuthoringAcknowledged = useCallback( + async (_goalId: string) => { + await completeStateChangingOverlay(() => setGoalAuthoringOpen(false), { + lifecycleRouteOverride: ProjectLifecycle.PRIMED, + }); + }, + [completeStateChangingOverlay], ); const handleGoalAuthoringCancel = useCallback(() => { - setGoalAuthoringError(null); setGoalAuthoringOpen(false); }, []); @@ -444,11 +458,7 @@ function AppFrame({ setCliUpgradeResult(result); setCliUpgradeWorking(false); }, - [ - cliUpdateCheck, - cliUpdateController, - cliUpgradeWorking, - ], + [cliUpdateCheck, cliUpdateController, cliUpgradeWorking], ); const handleBannerAnimationComplete = useCallback(() => { @@ -472,7 +482,9 @@ function AppFrame({
)} @@ -564,8 +575,8 @@ function AppFrame({ searchOpen ? [] : cockpitLaunchpadVisible - ? COCKPIT_FOOTER_SHORTCUTS - : DEFAULT_FOOTER_SHORTCUTS + ? COCKPIT_FOOTER_SHORTCUTS + : DEFAULT_FOOTER_SHORTCUTS } notifications={buildNotifications( daemonStatuses, @@ -590,7 +601,8 @@ function buildDaemonFailureNotifications( .map((status) => ({ id: `daemon-${status.name}-failed`, title: `${status.name.toUpperCase()} daemon failed`, - body: status.stderr[status.stderr.length - 1] ?? AppCopy.daemonFailureBody, + body: + status.stderr[status.stderr.length - 1] ?? AppCopy.daemonFailureBody, unread: true, })); } diff --git a/src/presentation/tui/goals/AddGoalRequestFactory.ts b/src/presentation/tui/goals/AddGoalRequestFactory.ts index 9ee5f825..a92c48a9 100644 --- a/src/presentation/tui/goals/AddGoalRequestFactory.ts +++ b/src/presentation/tui/goals/AddGoalRequestFactory.ts @@ -1,10 +1,10 @@ /** * AddGoalRequestFactory - Assembles an AddGoalRequest from GoalAuthoringValues. * - * Normalizes free-text authoring fields at the presentation-application - * boundary: blank optional text becomes undefined and whitespace/comma - * separated lists become arrays. Shared by the App overlay and GoalsScreen - * submission paths so the two cannot drift. + * Normalizes optional authoring fields at the presentation-application + * boundary. Scope arrays are copied without parsing so item content and order + * stay intact. Shared by the App overlay and GoalsScreen submission paths so + * the two cannot drift. */ import type { AddGoalRequest } from "../../../application/context/goals/add/AddGoalRequest.js"; @@ -16,8 +16,8 @@ export const AddGoalRequestFactory = { title: values.title, objective: values.objective, successCriteria: [...values.successCriteria], - scopeIn: optionalList(values.scopeIn), - scopeOut: optionalList(values.scopeOut), + scopeIn: optionalArray(values.scopeIn), + scopeOut: optionalArray(values.scopeOut), nextGoalId: optionalText(values.nextGoal), previousGoalId: optionalText(values.previousGoal), prerequisiteGoals: optionalList(values.prerequisiteGoals), @@ -40,3 +40,7 @@ function optionalList(value: string): string[] | undefined { return values.length > 0 ? values : undefined; } + +function optionalArray(values: readonly string[]): string[] | undefined { + return values.length > 0 ? [...values] : undefined; +} diff --git a/src/presentation/tui/goals/GoalAuthoringFlow.tsx b/src/presentation/tui/goals/GoalAuthoringFlow.tsx index 99297deb..74d38e91 100644 --- a/src/presentation/tui/goals/GoalAuthoringFlow.tsx +++ b/src/presentation/tui/goals/GoalAuthoringFlow.tsx @@ -1,12 +1,24 @@ import React, { useMemo, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { + BaseColors, + SemanticColors, + TuiGlyphs, +} from "../../shared/DesignTokens.js"; +import { KeyBadge } from "../ui-primitives/KeyBadge.js"; import { Wizard } from "../wizard/Wizard.js"; import type { WizardStepDefinition } from "../wizard/Wizard.js"; import { WizardFieldKind } from "../wizard/WizardConstants.js"; import { AUTHORING_PROGRESS_LABELS, + GOAL_AUTHORING_RESULT_MAX_MESSAGE_LENGTH, + GOAL_AUTHORING_RESULT_PANEL_WIDTH, GoalAuthoringCopy, GoalAuthoringCriterionValue, GoalAuthoringFieldKey, + GoalAuthoringRequestStatus, + GoalAuthoringResultCopy, + GoalAuthoringResultInteractionKey, GoalAuthoringStage, type GoalAuthoringStageValue, } from "./GoalAuthoringFlowConstants.js"; @@ -15,8 +27,8 @@ export interface GoalAuthoringValues { readonly title: string; readonly objective: string; readonly successCriteria: readonly string[]; - readonly scopeIn: string; - readonly scopeOut: string; + readonly scopeIn: readonly string[]; + readonly scopeOut: readonly string[]; readonly nextGoal: string; readonly previousGoal: string; readonly prerequisiteGoals: string; @@ -24,6 +36,22 @@ export interface GoalAuthoringValues { readonly worktree: string; } +export type GoalAuthoringSubmissionResult = + | { + readonly status: typeof GoalAuthoringRequestStatus.SUCCESS; + readonly goalId: string; + } + | { + readonly status: typeof GoalAuthoringRequestStatus.FAILURE; + readonly error: string; + }; + +type GoalAuthoringRequestResult = + | { + readonly status: typeof GoalAuthoringRequestStatus.PENDING; + } + | GoalAuthoringSubmissionResult; + const DETAILS_STEPS: readonly WizardStepDefinition[] = [ { title: GoalAuthoringCopy.details.title, @@ -43,27 +71,6 @@ const DETAILS_STEPS: readonly WizardStepDefinition[] = [ }, ] as const; -const SCOPE_STEPS: readonly WizardStepDefinition[] = [ - { - title: GoalAuthoringCopy.scope.title, - description: GoalAuthoringCopy.scope.description, - fields: [ - { - key: GoalAuthoringFieldKey.SCOPE_IN, - label: GoalAuthoringCopy.scope.fields.scopeIn, - placeholder: GoalAuthoringCopy.scope.fields.scopeInPlaceholder, - required: false, - }, - { - key: GoalAuthoringFieldKey.SCOPE_OUT, - label: GoalAuthoringCopy.scope.fields.scopeOut, - placeholder: GoalAuthoringCopy.scope.fields.scopeOutPlaceholder, - required: false, - }, - ], - }, -] as const; - const SEQUENCING_STEPS: readonly WizardStepDefinition[] = [ { title: GoalAuthoringCopy.sequencing.title, @@ -115,17 +122,21 @@ const WORKSPACE_STEPS: readonly WizardStepDefinition[] = [ ] as const; interface GoalAuthoringFlowProps { - readonly onComplete: (values: GoalAuthoringValues) => void | Promise; + readonly onComplete: ( + values: GoalAuthoringValues, + ) => Promise; + readonly onSuccessAcknowledged?: (goalId: string) => void | Promise; readonly onCancel: () => void; - readonly dispatchError?: string | null; - readonly disabled?: boolean; } +type ScopeFieldKey = + | typeof GoalAuthoringFieldKey.SCOPE_IN + | typeof GoalAuthoringFieldKey.SCOPE_OUT; + export function GoalAuthoringFlow({ onComplete, + onSuccessAcknowledged = () => {}, onCancel, - dispatchError = null, - disabled = false, }: GoalAuthoringFlowProps): React.ReactElement { const [stage, setStage] = useState( GoalAuthoringStage.DETAILS, @@ -134,9 +145,13 @@ export function GoalAuthoringFlow({ const [objective, setObjective] = useState(""); const [successCriteria, setSuccessCriteria] = useState([]); const [scopeValues, setScopeValues] = useState({ - scopeIn: "", - scopeOut: "", + scopeIn: [] as readonly string[], + scopeOut: [] as readonly string[], }); + const [scopeFieldKey, setScopeFieldKey] = useState( + GoalAuthoringFieldKey.SCOPE_IN, + ); + const [scopeEditIndex, setScopeEditIndex] = useState(0); const [sequencingValues, setSequencingValues] = useState({ previousGoal: "", nextGoal: "", @@ -148,12 +163,19 @@ export function GoalAuthoringFlow({ branch: "", worktree: "", }); + const [requestResult, setRequestResult] = + useState(null); + const [acknowledgingSuccess, setAcknowledgingSuccess] = useState(false); const criterionNumber = criteriaEditIndex + 1; const criteriaSteps = useMemo( () => buildCriteriaSteps(criterionNumber), [criterionNumber], ); + const scopeSteps = useMemo( + () => buildScopeSteps(scopeFieldKey, scopeEditIndex + 1), + [scopeEditIndex, scopeFieldKey], + ); const handleDetailsConfirm = (values: Record) => { setTitle(values[GoalAuthoringFieldKey.TITLE] ?? ""); @@ -181,10 +203,33 @@ export function GoalAuthoringFlow({ }; const handleScopeConfirm = (values: Record) => { - setScopeValues({ - scopeIn: values[GoalAuthoringFieldKey.SCOPE_IN] ?? "", - scopeOut: values[GoalAuthoringFieldKey.SCOPE_OUT] ?? "", - }); + const item = values[scopeFieldKey] ?? ""; + const nextScopeValues = { + ...scopeValues, + [scopeFieldKey]: replaceScopeItem( + scopeValues[scopeFieldKey], + scopeEditIndex, + item, + ), + }; + setScopeValues(nextScopeValues); + + if ( + values[scopeAddAnotherFieldKey(scopeFieldKey)] === + GoalAuthoringCriterionValue.YES + ) { + setScopeEditIndex(scopeEditIndex + 1); + setWizardKey((current) => current + 1); + return; + } + + if (scopeFieldKey === GoalAuthoringFieldKey.SCOPE_IN) { + setScopeFieldKey(GoalAuthoringFieldKey.SCOPE_OUT); + setScopeEditIndex(0); + setWizardKey((current) => current + 1); + return; + } + setStage(GoalAuthoringStage.SEQUENCING); }; @@ -192,19 +237,18 @@ export function GoalAuthoringFlow({ setSequencingValues({ previousGoal: values[GoalAuthoringFieldKey.PREVIOUS_GOAL] ?? "", nextGoal: values[GoalAuthoringFieldKey.NEXT_GOAL] ?? "", - prerequisiteGoals: - values[GoalAuthoringFieldKey.PREREQUISITE_GOALS] ?? "", + prerequisiteGoals: values[GoalAuthoringFieldKey.PREREQUISITE_GOALS] ?? "", }); setStage(GoalAuthoringStage.WORKSPACE); }; - const handleWorkspaceConfirm = (values: Record) => { + const handleWorkspaceConfirm = async (values: Record) => { const nextWorkspaceValues = { branch: values[GoalAuthoringFieldKey.BRANCH] ?? "", worktree: values[GoalAuthoringFieldKey.WORKTREE] ?? "", }; setWorkspaceValues(nextWorkspaceValues); - onComplete({ + const authoringValues = { title, objective, successCriteria, @@ -215,7 +259,17 @@ export function GoalAuthoringFlow({ prerequisiteGoals: sequencingValues.prerequisiteGoals, branch: nextWorkspaceValues.branch, worktree: nextWorkspaceValues.worktree, - }); + }; + + setRequestResult({ status: GoalAuthoringRequestStatus.PENDING }); + try { + setRequestResult(await onComplete(authoringValues)); + } catch (caughtError) { + setRequestResult({ + status: GoalAuthoringRequestStatus.FAILURE, + error: normalizeSubmissionError(caughtError), + }); + } }; const handleCriteriaBack = () => { @@ -229,14 +283,73 @@ export function GoalAuthoringFlow({ }; const handleScopeBack = () => { + if (scopeEditIndex > 0) { + setScopeEditIndex(scopeEditIndex - 1); + setWizardKey((current) => current + 1); + return; + } + + if (scopeFieldKey === GoalAuthoringFieldKey.SCOPE_OUT) { + setScopeFieldKey(GoalAuthoringFieldKey.SCOPE_IN); + setScopeEditIndex(Math.max(scopeValues.scopeIn.length - 1, 0)); + setWizardKey((current) => current + 1); + return; + } + setCriteriaEditIndex(Math.max(successCriteria.length - 1, 0)); setStage(GoalAuthoringStage.CRITERIA); }; const handleSequencingBack = () => { + setScopeFieldKey(GoalAuthoringFieldKey.SCOPE_OUT); + setScopeEditIndex(Math.max(scopeValues.scopeOut.length - 1, 0)); + setWizardKey((current) => current + 1); setStage(GoalAuthoringStage.SCOPE); }; + useInput((_input, key) => { + if ( + requestResult === null || + requestResult.status === GoalAuthoringRequestStatus.PENDING || + acknowledgingSuccess + ) { + return; + } + + if ( + requestResult.status === GoalAuthoringRequestStatus.FAILURE && + key.escape + ) { + onCancel(); + return; + } + + if (!key.return) { + return; + } + + if (requestResult.status === GoalAuthoringRequestStatus.FAILURE) { + setRequestResult(null); + setWizardKey((current) => current + 1); + setStage(GoalAuthoringStage.WORKSPACE); + return; + } + + setAcknowledgingSuccess(true); + void Promise.resolve(onSuccessAcknowledged(requestResult.goalId)).catch( + () => setAcknowledgingSuccess(false), + ); + }); + + if (requestResult !== null) { + return ( + + ); + } + if (stage === GoalAuthoringStage.DETAILS) { return ( ); @@ -270,8 +381,6 @@ export function GoalAuthoringFlow({ ? GoalAuthoringCriterionValue.YES : GoalAuthoringCriterionValue.NO, }} - dispatchError={dispatchError} - disabled={disabled} progressLabel={AUTHORING_PROGRESS_LABELS[GoalAuthoringStage.CRITERIA]} /> ); @@ -280,15 +389,19 @@ export function GoalAuthoringFlow({ if (stage === GoalAuthoringStage.SCOPE) { return ( ); @@ -304,8 +417,6 @@ export function GoalAuthoringFlow({ onCancel={onCancel} onBack={handleSequencingBack} initialValues={sequencingValues} - dispatchError={dispatchError} - disabled={disabled} progressLabel={AUTHORING_PROGRESS_LABELS[GoalAuthoringStage.SEQUENCING]} /> ); @@ -313,20 +424,135 @@ export function GoalAuthoringFlow({ return ( setStage(GoalAuthoringStage.SEQUENCING)} initialValues={workspaceValues} - dispatchError={dispatchError} - disabled={disabled} progressLabel={AUTHORING_PROGRESS_LABELS[GoalAuthoringStage.WORKSPACE]} /> ); } +function GoalAuthoringResultScreen({ + result, + acknowledgingSuccess, +}: { + readonly result: GoalAuthoringRequestResult; + readonly acknowledgingSuccess: boolean; +}): React.ReactElement { + const isPending = result.status === GoalAuthoringRequestStatus.PENDING; + const isSuccess = result.status === GoalAuthoringRequestStatus.SUCCESS; + const statusColor = isPending + ? SemanticColors.info + : isSuccess + ? SemanticColors.success + : SemanticColors.error; + const statusCopy = isPending + ? GoalAuthoringResultCopy.pending + : isSuccess + ? GoalAuthoringResultCopy.success + : GoalAuthoringResultCopy.failure; + + return ( + + + + {TuiGlyphs.accentBar} {GoalAuthoringResultCopy.title} + + + + + {GoalAuthoringResultCopy.statusLabel}:{" "} + + + {result.status} + + + + {statusCopy} + + + {result.status === GoalAuthoringRequestStatus.SUCCESS && ( + + + {GoalAuthoringResultCopy.goalIdLabel}:{" "} + + + {result.goalId} + + + )} + + {result.status === GoalAuthoringRequestStatus.FAILURE && ( + + + {GoalAuthoringResultCopy.errorLabel}: + + + {truncateResultMessage(result.error)} + + + )} + + {!isPending && ( + + + {!isSuccess && ( + + )} + + )} + + + ); +} + +function normalizeSubmissionError(caughtError: unknown): string { + if (caughtError instanceof Error) { + return caughtError.message; + } + + return String(caughtError); +} + +function truncateResultMessage(message: string): string { + if (message.length <= GOAL_AUTHORING_RESULT_MAX_MESSAGE_LENGTH) { + return message; + } + + return `${message.slice(0, GOAL_AUTHORING_RESULT_MAX_MESSAGE_LENGTH - 3)}...`; +} + function buildCriteriaSteps( criterionNumber: number, ): readonly WizardStepDefinition[] { @@ -350,3 +576,60 @@ function buildCriteriaSteps( }, ] as const; } + +function buildScopeSteps( + scopeFieldKey: ScopeFieldKey, + itemNumber: number, +): readonly WizardStepDefinition[] { + const isScopeIn = scopeFieldKey === GoalAuthoringFieldKey.SCOPE_IN; + return [ + { + title: `${ + isScopeIn + ? GoalAuthoringCopy.scope.scopeInTitlePrefix + : GoalAuthoringCopy.scope.scopeOutTitlePrefix + } ${itemNumber}`, + description: GoalAuthoringCopy.scope.description, + fields: [ + { + key: scopeFieldKey, + label: isScopeIn + ? GoalAuthoringCopy.scope.fields.scopeIn + : GoalAuthoringCopy.scope.fields.scopeOut, + placeholder: isScopeIn + ? GoalAuthoringCopy.scope.fields.scopeInPlaceholder + : GoalAuthoringCopy.scope.fields.scopeOutPlaceholder, + required: false, + }, + { + key: scopeAddAnotherFieldKey(scopeFieldKey), + label: isScopeIn + ? GoalAuthoringCopy.scope.fields.addAnotherScopeIn + : GoalAuthoringCopy.scope.fields.addAnotherScopeOut, + kind: WizardFieldKind.YES_NO, + defaultValue: GoalAuthoringCriterionValue.NO, + }, + ], + }, + ] as const; +} + +function scopeAddAnotherFieldKey(scopeFieldKey: ScopeFieldKey): string { + return scopeFieldKey === GoalAuthoringFieldKey.SCOPE_IN + ? GoalAuthoringFieldKey.ADD_ANOTHER_SCOPE_IN + : GoalAuthoringFieldKey.ADD_ANOTHER_SCOPE_OUT; +} + +function replaceScopeItem( + items: readonly string[], + index: number, + item: string, +): readonly string[] { + const nextItems = [...items]; + if (item.trim().length === 0) { + nextItems.splice(index, 1); + } else { + nextItems[index] = item; + } + return nextItems; +} diff --git a/src/presentation/tui/goals/GoalAuthoringFlowConstants.ts b/src/presentation/tui/goals/GoalAuthoringFlowConstants.ts index d6ec7094..c69d9b47 100644 --- a/src/presentation/tui/goals/GoalAuthoringFlowConstants.ts +++ b/src/presentation/tui/goals/GoalAuthoringFlowConstants.ts @@ -14,6 +14,8 @@ export const GoalAuthoringFieldKey = { OBJECTIVE: "objective", SCOPE_IN: "scopeIn", SCOPE_OUT: "scopeOut", + ADD_ANOTHER_SCOPE_IN: "addAnotherScopeIn", + ADD_ANOTHER_SCOPE_OUT: "addAnotherScopeOut", PREVIOUS_GOAL: "previousGoal", NEXT_GOAL: "nextGoal", PREREQUISITE_GOALS: "prerequisiteGoals", @@ -37,12 +39,14 @@ export const GoalAuthoringCopy = { }, }, scope: { - title: "Scope", - description: - "Identify the work area and boundaries that keep the goal focused.", + scopeInTitlePrefix: "Scope in item", + scopeOutTitlePrefix: "Scope out item", + description: "Add each work area or boundary as a separate item.", fields: { - scopeIn: "Scope in (optional)", - scopeOut: "Scope out (optional)", + scopeIn: "Scope in item (optional)", + scopeOut: "Scope out item (optional)", + addAnotherScopeIn: "Add another scope-in item?", + addAnotherScopeOut: "Add another scope-out item?", scopeInPlaceholder: "e.g. src/presentation/tui/goals", scopeOutPlaceholder: "e.g. src/application", }, @@ -87,6 +91,36 @@ export const GoalAuthoringCriterionValue = { NO: "no", } as const; +export const GoalAuthoringRequestStatus = { + PENDING: "pending", + SUCCESS: "success", + FAILURE: "failure", +} as const; + +export type GoalAuthoringRequestStatusValue = + (typeof GoalAuthoringRequestStatus)[keyof typeof GoalAuthoringRequestStatus]; + +export const GoalAuthoringResultInteractionKey = { + ACKNOWLEDGE: "enter", + CANCEL: "esc", +} as const; + +export const GoalAuthoringResultCopy = { + title: "Goal Request", + statusLabel: "Status", + goalIdLabel: "Goal ID", + errorLabel: "Error", + pending: "Submitting goal request...", + success: "Goal created successfully.", + failure: "Goal creation failed.", + acknowledge: "continue", + retry: "retry", + cancel: "cancel", +} as const; + +export const GOAL_AUTHORING_RESULT_PANEL_WIDTH = 88; +export const GOAL_AUTHORING_RESULT_MAX_MESSAGE_LENGTH = 512; + export const AUTHORING_PROGRESS_LABELS: Readonly< Record > = { diff --git a/src/presentation/tui/goals/GoalsScreen.tsx b/src/presentation/tui/goals/GoalsScreen.tsx index 2a9a0de0..0975fe22 100644 --- a/src/presentation/tui/goals/GoalsScreen.tsx +++ b/src/presentation/tui/goals/GoalsScreen.tsx @@ -5,7 +5,10 @@ import { SemanticColors, TuiGlyphs, } from "../../shared/DesignTokens.js"; -import { GoalStatus, type GoalStatusType } from "../../../domain/goals/Constants.js"; +import { + GoalStatus, + type GoalStatusType, +} from "../../../domain/goals/Constants.js"; import type { GoalView } from "../../../application/context/goals/GoalView.js"; import type { AddGoalRequest } from "../../../application/context/goals/add/AddGoalRequest.js"; import type { AddGoalResponse } from "../../../application/context/goals/add/AddGoalResponse.js"; @@ -19,7 +22,11 @@ import type { InvariantView } from "../../../application/context/invariants/Inva import { KeyBadge } from "../ui-primitives/KeyBadge.js"; import { HorizontalRule } from "../ui-primitives/HorizontalRule.js"; import { GoalAuthoringFlow } from "./GoalAuthoringFlow.js"; -import type { GoalAuthoringValues } from "./GoalAuthoringFlow.js"; +import type { + GoalAuthoringSubmissionResult, + GoalAuthoringValues, +} from "./GoalAuthoringFlow.js"; +import { GoalAuthoringRequestStatus } from "./GoalAuthoringFlowConstants.js"; import { AddGoalRequestFactory } from "./AddGoalRequestFactory.js"; import { ActionDispatcher } from "../action-dispatch/ActionDispatcher.js"; import type { RequestController } from "../action-dispatch/RequestController.js"; @@ -135,8 +142,6 @@ export function GoalsScreen({ const [sectionPageFlowIndex, setSectionPageFlowIndex] = useState(0); const [filterIndex, setFilterIndex] = useState(0); const [authoringOpen, setAuthoringOpen] = useState(false); - const [authoringError, setAuthoringError] = useState(null); - const [authoringWorking, setAuthoringWorking] = useState(false); const activeFilter = GOAL_STATUS_FILTERS[filterIndex]; const requestedStatusFilter = useMemo( () => @@ -165,10 +170,13 @@ export function GoalsScreen({ : selectedGoal; const activeContext = contextualGoal !== undefined && contextualGoal.goalId === selectedGoal?.id - ? goalContext.data?.contextualGoalView.context ?? EMPTY_GOAL_CONTEXT + ? (goalContext.data?.contextualGoalView.context ?? EMPTY_GOAL_CONTEXT) : EMPTY_GOAL_CONTEXT; const sections = useMemo( - () => (activeGoal === undefined ? [] : buildGoalSections(activeGoal, activeContext)), + () => + activeGoal === undefined + ? [] + : buildGoalSections(activeGoal, activeContext), [activeContext, activeGoal], ); const sectionPages = useMemo( @@ -229,7 +237,6 @@ export function GoalsScreen({ } if (input === "n" || input === "N" || input === "a" || input === "A") { - setAuthoringError(null); updateAuthoringOpen(true); return; } @@ -269,31 +276,40 @@ export function GoalsScreen({ } }); - const handleAuthoringComplete = async (values: GoalAuthoringValues) => { + const handleAuthoringComplete = async ( + values: GoalAuthoringValues, + ): Promise => { if (addGoalController === undefined) { - setAuthoringError(GoalsScreenCopy.authoringUnavailable); - return; + return { + status: GoalAuthoringRequestStatus.FAILURE, + error: GoalsScreenCopy.authoringUnavailable, + }; } - setAuthoringWorking(true); - setAuthoringError(null); const result = await ActionDispatcher.dispatch( addGoalController, AddGoalRequestFactory.create(values), ); - setAuthoringWorking(false); if (!result.ok) { - setAuthoringError(result.error.message); - return; + return { + status: GoalAuthoringRequestStatus.FAILURE, + error: result.error.message, + }; } - updateAuthoringOpen(false); + return { + status: GoalAuthoringRequestStatus.SUCCESS, + goalId: result.response.goalId, + }; + }; + + const handleAuthoringAcknowledged = async (_goalId: string) => { await goalsList.refresh(); + updateAuthoringOpen(false); }; const handleAuthoringCancel = () => { - setAuthoringError(null); updateAuthoringOpen(false); }; @@ -301,9 +317,8 @@ export function GoalsScreen({ return ( ); } @@ -320,15 +335,23 @@ export function GoalsScreen({ - {GoalsScreenCopy.showingLabel} + + {GoalsScreenCopy.showingLabel}{" "} + - {selectedGoal ? `${selectedIndex + 1}/${visibleGoals.length}` : "0/0"} + {selectedGoal + ? `${selectedIndex + 1}/${visibleGoals.length}` + : "0/0"} - {GoalsScreenCopy.stateLineLabel} - {formatFilterLabel(filterLabel)} + + {GoalsScreenCopy.stateLineLabel}{" "} + + + {formatFilterLabel(filterLabel)} + @@ -338,7 +361,9 @@ export function GoalsScreen({ {goalsList.loading && goalsList.data === null ? ( - {GoalsScreenCopy.loadingGoals} + + {GoalsScreenCopy.loadingGoals} + ) : goalsList.error !== null ? ( {goalsList.error.message} ) : activeGoal === undefined || activeSectionPage === undefined ? ( @@ -352,12 +377,16 @@ export function GoalsScreen({ totalPages={activeSectionPage.totalPages} /> {goalContext.loading && goalContext.data === null && ( - {GoalsScreenCopy.loadingContext} - + + {GoalsScreenCopy.loadingContext} + )} )} @@ -376,7 +405,7 @@ function GoalHeading({ GOAL: - {truncateText(goal.title, GOAL_BROWSER_TITLE_MAX_LENGTH)} + {truncateText(goal.title, GOAL_BROWSER_TITLE_MAX_LENGTH)} @@ -400,8 +429,8 @@ function SectionHeading({ totalPages > 1 ? `${normalizedTitle} (${pageIndex + 1}/${totalPages}):` : section.title.endsWith(":") - ? section.title - : `${section.title}:`; + ? section.title + : `${section.title}:`; return ( @@ -439,7 +468,8 @@ function GoalSectionRows({ index > 0 ? 1 : 0 - }> + } + > {row.heading !== undefined ? ( {row.heading} @@ -473,7 +503,9 @@ function GoalSectionRows({ ) : ( {row.label} - {row.value} + + {row.value} + )} @@ -536,7 +568,10 @@ function buildGoalSections( listSection( "successCriteria", GoalsScreenCopy.sections.successCriteria, - goal.criteria.map((criterion) => ({ value: criterion, marker: "bullet" })), + goal.criteria.map((criterion) => ({ + value: criterion, + marker: "bullet", + })), ), listSection( "currentProgress", @@ -585,19 +620,12 @@ function buildGoalSections( ]; } -function textSection( - key: string, - title: string, - value?: string, -): GoalSection { +function textSection(key: string, title: string, value?: string): GoalSection { return { key, title, paginated: false, - rows: - value === undefined || value.trim().length === 0 - ? [] - : [{ value }], + rows: value === undefined || value.trim().length === 0 ? [] : [{ value }], }; } diff --git a/src/presentation/tui/wizard/Wizard.tsx b/src/presentation/tui/wizard/Wizard.tsx index 64bbfe7a..7f5ce39f 100644 --- a/src/presentation/tui/wizard/Wizard.tsx +++ b/src/presentation/tui/wizard/Wizard.tsx @@ -459,7 +459,7 @@ export function Wizard({ backgroundColor={BaseColors.black} paddingX={4} paddingY={2} - minWidth={OVERLAY_MIN_WIDTH} + width={OVERLAY_MIN_WIDTH} ref={panelRef} > diff --git a/src/presentation/tui/wizard/WizardTextInput.tsx b/src/presentation/tui/wizard/WizardTextInput.tsx index b00ee0e2..6932bd20 100644 --- a/src/presentation/tui/wizard/WizardTextInput.tsx +++ b/src/presentation/tui/wizard/WizardTextInput.tsx @@ -69,6 +69,14 @@ export function WizardTextInput({ backgroundColor={INPUT_BACKGROUND} > {placeholder} + {focused && ( + + ▎ + + )} ) : ( {value} - - )} - {focused && ( - - ▎ + {focused && "▎"} )} diff --git a/tests/presentation/tui/application-shell/App.test.tsx b/tests/presentation/tui/application-shell/App.test.tsx index b2049206..56b6ab8c 100644 --- a/tests/presentation/tui/application-shell/App.test.tsx +++ b/tests/presentation/tui/application-shell/App.test.tsx @@ -9,6 +9,10 @@ import type { } from "../../../../src/presentation/tui/daemon-subprocesses/ISubprocessManager.js"; import type { AddGoalRequest } from "../../../../src/application/context/goals/add/AddGoalRequest.js"; import type { Settings } from "../../../../src/application/settings/Settings.js"; +import { + GoalAuthoringRequestStatus, + GoalAuthoringResultCopy, +} from "../../../../src/presentation/tui/goals/GoalAuthoringFlowConstants.js"; interface HeaderProps { readonly projectName: string; @@ -34,9 +38,8 @@ jest.unstable_mockModule( }), ); -const { App: ProductionApp } = await import( - "../../../../src/presentation/tui/application-shell/App.js" -); +const { App: ProductionApp } = + await import("../../../../src/presentation/tui/application-shell/App.js"); const tick = () => new Promise((resolve) => setTimeout(resolve, 10)); const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -426,16 +429,18 @@ describe("App", () => { config: stoppedDaemonSnapshot.config, stdout: [], stderr: [], - events: [{ - daemon: "refiner", - status: "working", - category: "activity", - goalId: "goal_single_navigation", - phase: "agent", - elapsedMs: 1_000, - timestampMs: 1_000, - message: "single navigation activity", - }], + events: [ + { + daemon: "refiner", + status: "working", + category: "activity", + goalId: "goal_single_navigation", + phase: "agent", + elapsedMs: 1_000, + timestampMs: 1_000, + message: "single navigation activity", + }, + ], }; const terminate = jest.fn(async (name: DaemonName) => { refinerSnapshot = { ...refinerSnapshot, name, status: "stopped" }; @@ -480,8 +485,10 @@ describe("App", () => { }; await navigateFromGoalsToCockpit(stdin, lastFrame); - await waitForFrame(lastFrame, (frame) => - frame.includes("single navigation activity") && frame.includes("6s"), + await waitForFrame( + lastFrame, + (frame) => + frame.includes("single navigation activity") && frame.includes("6s"), ); expect(manager.spawn).not.toHaveBeenCalled(); @@ -503,21 +510,69 @@ describe("App", () => { it("preserves multiple independently updating daemons across navigation", async () => { const snapshots = new Map([ - ["refiner", { - name: "refiner", status: "running", pid: 5101, - config: stoppedDaemonSnapshot.config, stdout: [], stderr: [], - events: [{ daemon: "refiner", status: "working", category: "activity", goalId: "goal_refiner", timestampMs: 7_000, message: "refiner resumed" }], - }], - ["reviewer", { - name: "reviewer", status: "running", pid: 5102, - config: stoppedDaemonSnapshot.config, stdout: [], stderr: [], - events: [{ daemon: "reviewer", status: "working", category: "activity", goalId: "goal_reviewer", timestampMs: 8_000, message: "reviewer resumed" }], - }], - ["codifier", { - name: "codifier", status: "running", pid: 5103, - config: stoppedDaemonSnapshot.config, stdout: [], stderr: [], - events: [{ daemon: "codifier", status: "working", category: "activity", goalId: "goal_codifier", timestampMs: 9_000, message: "codifier resumed" }], - }], + [ + "refiner", + { + name: "refiner", + status: "running", + pid: 5101, + config: stoppedDaemonSnapshot.config, + stdout: [], + stderr: [], + events: [ + { + daemon: "refiner", + status: "working", + category: "activity", + goalId: "goal_refiner", + timestampMs: 7_000, + message: "refiner resumed", + }, + ], + }, + ], + [ + "reviewer", + { + name: "reviewer", + status: "running", + pid: 5102, + config: stoppedDaemonSnapshot.config, + stdout: [], + stderr: [], + events: [ + { + daemon: "reviewer", + status: "working", + category: "activity", + goalId: "goal_reviewer", + timestampMs: 8_000, + message: "reviewer resumed", + }, + ], + }, + ], + [ + "codifier", + { + name: "codifier", + status: "running", + pid: 5103, + config: stoppedDaemonSnapshot.config, + stdout: [], + stderr: [], + events: [ + { + daemon: "codifier", + status: "working", + category: "activity", + goalId: "goal_codifier", + timestampMs: 9_000, + message: "codifier resumed", + }, + ], + }, + ], ]); const manager: ISubprocessManager = { spawn: jest.fn(async (name) => snapshots.get(name)!), @@ -545,16 +600,19 @@ describe("App", () => { ...snapshot, events: snapshot.events.map((event) => ({ ...event, - elapsedMs: name === "refiner" ? 7_000 : name === "reviewer" ? 8_000 : 9_000, + elapsedMs: + name === "refiner" ? 7_000 : name === "reviewer" ? 8_000 : 9_000, })), }); } await navigateFromGoalsToCockpit(stdin, lastFrame); - const frame = await waitForFrame(lastFrame, (currentFrame) => - currentFrame.includes("refiner resumed") && - currentFrame.includes("reviewer resumed") && - currentFrame.includes("codifier resumed"), + const frame = await waitForFrame( + lastFrame, + (currentFrame) => + currentFrame.includes("refiner resumed") && + currentFrame.includes("reviewer resumed") && + currentFrame.includes("codifier resumed"), ); expect(frame).toContain("7s"); @@ -836,9 +894,11 @@ describe("App", () => { await waitForFrame(lastFrame, (frame) => frame.includes("Author Goal")); stdin.write("\x1b"); - await waitForFrame(lastFrame, (frame) => - frame.includes("Ready to create your first goal.") && - !frame.includes("Author Goal"), + await waitForFrame( + lastFrame, + (frame) => + frame.includes("Ready to create your first goal.") && + !frame.includes("Author Goal"), ); expect(lastFrame()).not.toContain("Author Goal"); @@ -894,34 +954,61 @@ describe("App", () => { await tick(); stdin.write("\r"); await tick(); - stdin.write("src/presentation/tui"); + stdin.write("src/presentation tui"); await tick(); - stdin.write("\t"); + stdin.write("\r"); await tick(); - stdin.write("src/application"); + stdin.write("y"); await tick(); stdin.write("\r"); await tick(); + stdin.write("tests/presentation/tui"); + await tick(); stdin.write("\r"); await tick(); stdin.write("\r"); await tick(); + stdin.write("src/application layer"); + await tick(); stdin.write("\r"); await tick(); + stdin.write("y"); + await tick(); stdin.write("\r"); await tick(); + stdin.write("src/domain"); + await tick(); stdin.write("\r"); - await waitForFrame(lastFrame, (frame) => - !frame.includes("Ready to create your first goal."), + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + const resultFrame = await waitForFrame(lastFrame, (frame) => + frame.includes("goal_created"), ); + expect(resultFrame).toContain(GoalAuthoringRequestStatus.SUCCESS); + expect(resultFrame).toContain(GoalAuthoringResultCopy.goalIdLabel); + stdin.write("m"); + await tick(); + expect(lastFrame()).toContain("goal_created"); + expect(lastFrame()).not.toContain("Navigate"); + expect(addGoalRequests).toEqual([ { title: "Prototype Cockpit goal authoring", objective: "Open goal authoring from Cockpit", successCriteria: ["Wizard opens and closes"], - scopeIn: ["src/presentation/tui"], - scopeOut: ["src/application"], + scopeIn: ["src/presentation tui", "tests/presentation/tui"], + scopeOut: ["src/application layer", "src/domain"], nextGoalId: undefined, previousGoalId: undefined, prerequisiteGoals: undefined, @@ -929,7 +1016,10 @@ describe("App", () => { worktree: undefined, }, ]); - expect(lastFrame()).not.toContain("Author Goal"); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("EVENTS//")); + + expect(lastFrame()).not.toContain(GoalAuthoringResultCopy.title); expect(lastFrame()).not.toContain("to add a goal"); unmount(); }, 10000); @@ -975,6 +1065,10 @@ describe("App", () => { stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out")); stdin.write("\r"); await tick(); stdin.write("\r"); @@ -995,6 +1089,13 @@ describe("App", () => { () => addGoalController.handle.mock.calls.length > 0, ); + const resultFrame = await waitForFrame(lastFrame, (frame) => + frame.includes("goal_created"), + ); + expect(resultFrame).toContain(GoalAuthoringRequestStatus.SUCCESS); + expect(resultFrame).not.toContain("EVENTS//"); + + stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("EVENTS//")); expect(lastFrame()).not.toContain("to add a goal"); @@ -1085,5 +1186,4 @@ describe("App", () => { expect(lastFrame()).toContain("Navigate"); unmount(); }, 10000); - }); diff --git a/tests/presentation/tui/goals/AddGoalRequestFactory.test.ts b/tests/presentation/tui/goals/AddGoalRequestFactory.test.ts new file mode 100644 index 00000000..792be7e0 --- /dev/null +++ b/tests/presentation/tui/goals/AddGoalRequestFactory.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@jest/globals"; +import { AddGoalRequestFactory } from "../../../../src/presentation/tui/goals/AddGoalRequestFactory.js"; +import type { GoalAuthoringValues } from "../../../../src/presentation/tui/goals/GoalAuthoringFlow.js"; + +describe("AddGoalRequestFactory", () => { + it("maps multiple scope items without changing their order or content", () => { + const values = authoringValues({ + scopeIn: ["src/presentation tui", "tests/presentation/tui"], + scopeOut: ["src/application layer", "src/domain"], + }); + + const request = AddGoalRequestFactory.create(values); + + expect(request.scopeIn).toEqual([ + "src/presentation tui", + "tests/presentation/tui", + ]); + expect(request.scopeOut).toEqual(["src/application layer", "src/domain"]); + }); + + it("maps a single scope item as one array element", () => { + const request = AddGoalRequestFactory.create( + authoringValues({ + scopeIn: ["one scope in item"], + scopeOut: ["one scope out item"], + }), + ); + + expect(request.scopeIn).toEqual(["one scope in item"]); + expect(request.scopeOut).toEqual(["one scope out item"]); + }); + + it("maps empty scope arrays to undefined", () => { + const request = AddGoalRequestFactory.create(authoringValues()); + + expect(request.scopeIn).toBeUndefined(); + expect(request.scopeOut).toBeUndefined(); + }); +}); + +function authoringValues( + overrides: Partial = {}, +): GoalAuthoringValues { + return { + title: "Goal title", + objective: "Goal objective", + successCriteria: ["Goal criterion"], + scopeIn: [], + scopeOut: [], + nextGoal: "", + previousGoal: "", + prerequisiteGoals: "", + branch: "", + worktree: "", + ...overrides, + }; +} diff --git a/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx b/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx index e912acbd..1ef472a3 100644 --- a/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx +++ b/tests/presentation/tui/goals/GoalAuthoringFlow.test.tsx @@ -1,11 +1,26 @@ import React from "react"; import { describe, expect, it, jest } from "@jest/globals"; import { render } from "ink-testing-library"; +import stripAnsi from "strip-ansi"; import { GoalAuthoringFlow } from "../../../../src/presentation/tui/goals/GoalAuthoringFlow.js"; +import type { + GoalAuthoringSubmissionResult, + GoalAuthoringValues, +} from "../../../../src/presentation/tui/goals/GoalAuthoringFlow.js"; +import { + GOAL_AUTHORING_RESULT_PANEL_WIDTH, + GoalAuthoringRequestStatus, + GoalAuthoringResultCopy, +} from "../../../../src/presentation/tui/goals/GoalAuthoringFlowConstants.js"; import { WizardValidationCopy } from "../../../../src/presentation/tui/wizard/WizardConstants.js"; const tick = () => new Promise((resolve) => setTimeout(resolve, 50)); const LEFT_ARROW = "\x1B[D"; +const SUCCESSFUL_SUBMISSION: GoalAuthoringSubmissionResult = { + status: GoalAuthoringRequestStatus.SUCCESS, + goalId: "goal_created", +}; +const completeSuccessfully = async () => SUCCESSFUL_SUBMISSION; const waitForFrame = async ( lastFrame: () => string | undefined, predicate: (frame: string) => boolean, @@ -23,7 +38,10 @@ const waitForFrame = async ( describe("GoalAuthoringFlow", () => { it("renders the objective step using the wizard primitive", () => { const { lastFrame, unmount } = render( - {}} onCancel={() => {}} />, + {}} + />, ); const frame = lastFrame() ?? ""; @@ -35,9 +53,47 @@ describe("GoalAuthoringFlow", () => { unmount(); }); + it("wraps a long objective without expanding the wizard backdrop", async () => { + const objective = + "Allow full fidelity view of a Decision by extending the commands with 'jumbo decision show --id'. Today only summaries are visible via 'jumbo decisions list'"; + const { lastFrame, stdin, unmount } = render( + {}} + />, + ); + + stdin.write("Demonstration"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write(objective); + const frame = stripAnsi( + await waitForFrame( + lastFrame, + (renderedFrame) => + renderedFrame.includes("list'") && renderedFrame.includes("▎"), + ), + ); + + expect( + frame.split("\n").every((line) => line.trimStart().length <= 88), + ).toBe(true); + expect(frame.replace(/\s+/g, " ")).toContain(objective); + expect(frame.replace(/\s+/g, " ")).toContain(`list'▎`); + expect(frame).toContain("▎"); + expect(frame).toContain("Title"); + expect(frame).toContain("Objective"); + expect(frame).toContain("1/5"); + unmount(); + }); + it("includes all goal authoring steps", async () => { const { lastFrame, stdin, unmount } = render( - {}} onCancel={() => {}} />, + {}} + />, ); stdin.write("Prototype S2"); @@ -57,17 +113,21 @@ describe("GoalAuthoringFlow", () => { await tick(); stdin.write("\r"); await tick(); - expect(lastFrame()).toContain("Scope in (optional)"); + expect(lastFrame()).toContain("Scope in item (optional)"); expect(lastFrame()).toContain("3/5"); stdin.write("src/presentation/tui"); await tick(); - stdin.write("\t"); + stdin.write("\r"); await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); stdin.write("src/application"); await tick(); stdin.write("\r"); await tick(); + stdin.write("\r"); + await tick(); expect(lastFrame()).toContain("Previous goal (optional)"); expect(lastFrame()).toContain("4/5"); stdin.write("\r"); @@ -81,8 +141,8 @@ describe("GoalAuthoringFlow", () => { unmount(); }); - it("collects success criteria as an array", async () => { - const handleComplete = jest.fn(); + it("collects criteria and multiple scope items as arrays", async () => { + const handleComplete = jest.fn(async () => SUCCESSFUL_SUBMISSION); const { stdin, lastFrame, unmount } = render( {}} />, ); @@ -112,11 +172,44 @@ describe("GoalAuthoringFlow", () => { stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); - stdin.write("src/presentation/tui"); + stdin.write("src/presentation/tui goals"); await tick(); - stdin.write("\t"); + stdin.write("\r"); await tick(); - stdin.write("src/application"); + stdin.write("y"); + await tick(); + stdin.write("\r"); + await waitForFrame( + lastFrame, + (frame) => + frame.includes("Add another scope-in item?") && + !frame.includes("src/presentation/tui goals"), + ); + stdin.write("tests/presentation/tui"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => + frame.includes("Add another scope-out item?"), + ); + + stdin.write("src/application layer"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("y"); + await tick(); + stdin.write("\r"); + await waitForFrame( + lastFrame, + (frame) => + frame.includes("Add another scope-out item?") && + !frame.includes("src/application layer"), + ); + stdin.write("src/domain"); + await tick(); + stdin.write("\r"); await tick(); stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Previous goal")); @@ -147,8 +240,8 @@ describe("GoalAuthoringFlow", () => { title: "Prototype S2", objective: "Prototype the Goals screen", successCriteria: ["Renders goals", "Shows goal detail"], - scopeIn: "src/presentation/tui", - scopeOut: "src/application", + scopeIn: ["src/presentation/tui goals", "tests/presentation/tui"], + scopeOut: ["src/application layer", "src/domain"], previousGoal: "goal_previous", nextGoal: "goal_next", prerequisiteGoals: "goal_prerequisite", @@ -159,7 +252,7 @@ describe("GoalAuthoringFlow", () => { }); it("allows scope boundaries to be left blank", async () => { - const handleComplete = jest.fn(); + const handleComplete = jest.fn(async () => SUCCESSFUL_SUBMISSION); const { stdin, lastFrame, unmount } = render( {}} />, ); @@ -179,6 +272,10 @@ describe("GoalAuthoringFlow", () => { stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out")); stdin.write("\r"); await tick(); stdin.write("\r"); @@ -201,8 +298,8 @@ describe("GoalAuthoringFlow", () => { title: "Prototype S2", objective: "Prototype the Goals screen", successCriteria: ["Renders goals"], - scopeIn: "", - scopeOut: "", + scopeIn: [], + scopeOut: [], previousGoal: "", nextGoal: "", prerequisiteGoals: "", @@ -212,9 +309,12 @@ describe("GoalAuthoringFlow", () => { unmount(); }); - it("preserves earlier answers when navigating back", async () => { + it("preserves scope items when navigating backward and forward", async () => { const { stdin, lastFrame, unmount } = render( - {}} onCancel={() => {}} />, + {}} + />, ); stdin.write("Prototype S2"); @@ -231,20 +331,42 @@ describe("GoalAuthoringFlow", () => { await tick(); stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); - stdin.write("src/presentation/tui"); + stdin.write("src/presentation tui"); await tick(); - stdin.write("\t"); + stdin.write("\r"); await tick(); - stdin.write("src/application"); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); + stdin.write("src/application layer"); + await tick(); + stdin.write("\r"); await tick(); stdin.write("\r"); await waitForFrame(lastFrame, (frame) => frame.includes("Previous goal")); stdin.write(LEFT_ARROW); - await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); - expect(lastFrame()).toContain("src/presentation/tui"); - expect(lastFrame()).toContain("src/application"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); + expect(lastFrame()).toContain("src/application layer"); + + stdin.write(LEFT_ARROW); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope in item")); + expect(lastFrame()).toContain("src/presentation tui"); + + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); + expect(lastFrame()).toContain("src/application layer"); + + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Previous goal")); + stdin.write(LEFT_ARROW); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out item")); + stdin.write(LEFT_ARROW); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope in item")); stdin.write(LEFT_ARROW); await waitForFrame(lastFrame, (frame) => frame.includes("Success criterion"), @@ -252,11 +374,184 @@ describe("GoalAuthoringFlow", () => { expect(lastFrame()).toContain("Renders goals"); stdin.write(LEFT_ARROW); - await waitForFrame(lastFrame, (frame) => - frame.includes("Title") && frame.includes("Objective"), + await waitForFrame( + lastFrame, + (frame) => frame.includes("Title") && frame.includes("Objective"), ); expect(lastFrame()).toContain("Prototype S2"); expect(lastFrame()).toContain("Prototype the Goals screen"); unmount(); }); + + it("replaces the wizard with pending and keeps success visible until acknowledged", async () => { + let resolveSubmission: + | ((result: GoalAuthoringSubmissionResult) => void) + | undefined; + const submission = new Promise((resolve) => { + resolveSubmission = resolve; + }); + const onComplete = jest.fn( + async (_values: GoalAuthoringValues) => submission, + ); + const onSuccessAcknowledged = jest.fn(); + const { stdin, lastFrame, unmount } = render( + {}} + />, + ); + + await submitMinimalAuthoringFlow(stdin, lastFrame); + + const pendingFrame = await waitForFrame(lastFrame, (frame) => + frame.includes(GoalAuthoringRequestStatus.PENDING), + ); + expect(pendingFrame).toContain(GoalAuthoringResultCopy.pending); + expect(pendingFrame).not.toContain(GoalAuthoringResultCopy.goalIdLabel); + expect(pendingFrame).not.toContain("Branch (optional)"); + + resolveSubmission?.(SUCCESSFUL_SUBMISSION); + const successFrame = await waitForFrame(lastFrame, (frame) => + frame.includes(GoalAuthoringRequestStatus.SUCCESS), + ); + expect(successFrame).toContain(GoalAuthoringResultCopy.success); + expect(successFrame).toContain(SUCCESSFUL_SUBMISSION.goalId); + + await tick(); + expect(lastFrame()).toContain(SUCCESSFUL_SUBMISSION.goalId); + expect(onSuccessAcknowledged).not.toHaveBeenCalled(); + + stdin.write("\r"); + await waitForFrame( + lastFrame, + () => onSuccessAcknowledged.mock.calls.length === 1, + ); + expect(onSuccessAcknowledged).toHaveBeenCalledWith( + SUCCESSFUL_SUBMISSION.goalId, + ); + unmount(); + }); + + it("shows failure without a goal ID and retries with every value preserved", async () => { + const onComplete = jest + .fn< + (values: GoalAuthoringValues) => Promise + >() + .mockResolvedValueOnce({ + status: GoalAuthoringRequestStatus.FAILURE, + error: "normalized dispatch failure", + }) + .mockResolvedValueOnce(SUCCESSFUL_SUBMISSION); + const { stdin, lastFrame, unmount } = render( + {}} />, + ); + + await submitMinimalAuthoringFlow(stdin, lastFrame, { + branch: "feature/preserved", + worktree: "../preserved-worktree", + }); + const failureFrame = await waitForFrame(lastFrame, (frame) => + frame.includes(GoalAuthoringRequestStatus.FAILURE), + ); + expect(failureFrame).toContain("normalized dispatch failure"); + expect(failureFrame).not.toContain(GoalAuthoringResultCopy.goalIdLabel); + + stdin.write("\r"); + const retryFrame = await waitForFrame(lastFrame, (frame) => + frame.includes("Branch (optional)"), + ); + expect(retryFrame).toContain("feature/preserved"); + expect(retryFrame).toContain("../preserved-worktree"); + + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, () => onComplete.mock.calls.length === 2); + + expect(onComplete.mock.calls[1]?.[0]).toEqual( + onComplete.mock.calls[0]?.[0], + ); + expect(lastFrame()).toContain(SUCCESSFUL_SUBMISSION.goalId); + unmount(); + }); + + it("cancels from failure and bounds long result content", async () => { + const onCancel = jest.fn(); + const longError = "dispatch failure ".repeat(100); + const { stdin, lastFrame, unmount } = render( + ({ + status: GoalAuthoringRequestStatus.FAILURE, + error: longError, + })} + onCancel={onCancel} + />, + ); + + await submitMinimalAuthoringFlow(stdin, lastFrame); + const failureFrame = stripAnsi( + await waitForFrame(lastFrame, (frame) => + frame.includes(GoalAuthoringRequestStatus.FAILURE), + ), + ); + + expect(failureFrame).not.toContain(longError); + const longestRenderedLine = Math.max( + ...failureFrame.split("\n").map((line) => line.trimStart().length), + ); + expect(longestRenderedLine).toBeLessThanOrEqual( + GOAL_AUTHORING_RESULT_PANEL_WIDTH, + ); + + stdin.write("\x1b"); + await waitForFrame(lastFrame, () => onCancel.mock.calls.length === 1); + expect(onCancel).toHaveBeenCalledTimes(1); + unmount(); + }); }); + +async function submitMinimalAuthoringFlow( + stdin: ReturnType["stdin"], + lastFrame: () => string | undefined, + workspace: { + readonly branch?: string; + readonly worktree?: string; + } = {}, +): Promise { + stdin.write("Goal title"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("Goal objective"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Success criterion")); + stdin.write("Goal criterion"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope in")); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Scope out")); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Previous goal")); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write("\r"); + await waitForFrame(lastFrame, (frame) => frame.includes("Branch")); + stdin.write(workspace.branch ?? ""); + await tick(); + stdin.write("\r"); + await tick(); + stdin.write(workspace.worktree ?? ""); + await tick(); + stdin.write("\r"); +} diff --git a/tests/presentation/tui/goals/GoalAuthoringFlowConstants.test.ts b/tests/presentation/tui/goals/GoalAuthoringFlowConstants.test.ts new file mode 100644 index 00000000..1cf2be79 --- /dev/null +++ b/tests/presentation/tui/goals/GoalAuthoringFlowConstants.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "@jest/globals"; +import { + GoalAuthoringRequestStatus, + GoalAuthoringResultCopy, + GoalAuthoringResultInteractionKey, +} from "../../../../src/presentation/tui/goals/GoalAuthoringFlowConstants.js"; + +describe("GoalAuthoringFlowConstants", () => { + it("keeps request statuses stable", () => { + expect(GoalAuthoringRequestStatus).toEqual({ + PENDING: "pending", + SUCCESS: "success", + FAILURE: "failure", + }); + }); + + it("keeps result interaction keys and their copy owner-local", () => { + expect(GoalAuthoringResultInteractionKey).toEqual({ + ACKNOWLEDGE: "enter", + CANCEL: "esc", + }); + expect(GoalAuthoringResultCopy.acknowledge).toBeTruthy(); + expect(GoalAuthoringResultCopy.retry).toBeTruthy(); + expect(GoalAuthoringResultCopy.cancel).toBeTruthy(); + }); +}); diff --git a/tests/presentation/tui/goals/GoalsScreen.test.tsx b/tests/presentation/tui/goals/GoalsScreen.test.tsx index 6d6d8c86..a0835535 100644 --- a/tests/presentation/tui/goals/GoalsScreen.test.tsx +++ b/tests/presentation/tui/goals/GoalsScreen.test.tsx @@ -3,6 +3,10 @@ import { describe, expect, it, jest } from "@jest/globals"; import { render } from "ink-testing-library"; import stripAnsi from "strip-ansi"; import { GoalsScreen } from "../../../../src/presentation/tui/goals/GoalsScreen.js"; +import { + GoalAuthoringRequestStatus, + GoalAuthoringResultCopy, +} from "../../../../src/presentation/tui/goals/GoalAuthoringFlowConstants.js"; import { StateReaderProvider } from "../../../../src/presentation/tui/state-reading/StateReader.js"; import { GoalStatus } from "../../../../src/domain/goals/Constants.js"; import { @@ -45,7 +49,13 @@ async function waitUntil(condition: () => boolean): Promise { async function submitAuthoringFlow( stdin: ReturnType["stdin"], readFrame: () => string | undefined, - values: { readonly title: string; readonly objective: string; readonly criterion: string }, + values: { + readonly title: string; + readonly objective: string; + readonly criterion: string; + readonly scopeIn?: readonly string[]; + readonly scopeOut?: readonly string[]; + }, ): Promise { stdin.write("n"); await waitForFrame(readFrame, "Author Goal"); @@ -66,10 +76,18 @@ async function submitAuthoringFlow( stdin.write("\r"); await waitForFrame(readFrame, "Scope in"); - stdin.write("\r"); - await settleInput(); - stdin.write("\r"); - await waitForFrame(readFrame, "Previous goal"); + await submitScopeCollection( + stdin, + readFrame, + values.scopeIn ?? [], + "Scope out", + ); + await submitScopeCollection( + stdin, + readFrame, + values.scopeOut ?? [], + "Previous goal", + ); stdin.write("\r"); await settleInput(); @@ -84,6 +102,30 @@ async function submitAuthoringFlow( await settleInput(); } +async function submitScopeCollection( + stdin: ReturnType["stdin"], + readFrame: () => string | undefined, + items: readonly string[], + nextStageText: string, +): Promise { + const submittedItems = items.length > 0 ? items : [""]; + + for (const [index, item] of submittedItems.entries()) { + stdin.write(item); + await settleInput(); + stdin.write("\r"); + await settleInput(); + if (index < submittedItems.length - 1) { + stdin.write("y"); + await settleInput(); + } + stdin.write("\r"); + await settleInput(); + } + + await waitForFrame(readFrame, nextStageText); +} + async function navigateRightUntil( stdin: ReturnType["stdin"], readFrame: () => string | undefined, @@ -110,7 +152,10 @@ function renderGoalsScreen( readonly handledRequests?: GetGoalsRequest[]; readonly terminalWidth?: number; readonly shortcutsEnabled?: boolean; - readonly addGoalController?: RequestController; + readonly addGoalController?: RequestController< + AddGoalRequest, + AddGoalResponse + >; readonly onModalOpenChange?: (isOpen: boolean) => void; } = {}, ): ReturnType { @@ -298,7 +343,9 @@ describe("GoalsScreen", () => { it("renders every available goal-show content section", async () => { const { lastFrame, stdin, unmount } = renderGoalsScreen({ - contexts: new Map([["goal_real", createGoalContext({ componentCount: 1 })]]), + contexts: new Map([ + ["goal_real", createGoalContext({ componentCount: 1 })], + ]), }); const expectedSections = [ "OBJECTIVE:", @@ -338,7 +385,9 @@ describe("GoalsScreen", () => { expect(frame).toContain("Claimed by"); expect(frame).toContain("worker-1"); expect(frame).not.toContain("Objective."); - expect(frame.indexOf("OBJECTIVE:")).toBeLessThan(frame.indexOf("META-DATA:")); + expect(frame.indexOf("OBJECTIVE:")).toBeLessThan( + frame.indexOf("META-DATA:"), + ); expect(frame).not.toContain("WORKSPACE:"); expect(frame).not.toContain("CLAIM:"); unmount(); @@ -360,7 +409,9 @@ describe("GoalsScreen", () => { it("paginates related entity rows at six items before rendering overflow", async () => { const { lastFrame, stdin, unmount } = renderGoalsScreen({ - contexts: new Map([["goal_real", createGoalContext({ componentCount: 7 })]]), + contexts: new Map([ + ["goal_real", createGoalContext({ componentCount: 7 })], + ]), }); await waitForFrame(lastFrame, "META-DATA"); @@ -470,7 +521,11 @@ describe("GoalsScreen", () => { }); await waitForFrame(lastFrame, "META-DATA"); - const frame = await navigateRightUntil(stdin, lastFrame, "SUCCESS CRITERIA:"); + const frame = await navigateRightUntil( + stdin, + lastFrame, + "SUCCESS CRITERIA:", + ); expect(frame).not.toContain("SUCCESS CRITERIA (1/2):"); expect(frame).not.toContain("(1/2)"); unmount(); @@ -626,6 +681,8 @@ describe("GoalsScreen", () => { title: "Created goal", objective: "Dispatch the authored goal", criterion: "Goal is persisted", + scopeIn: ["src/presentation tui", "tests/presentation/tui"], + scopeOut: ["src/application layer", "src/domain"], }); await waitUntil(() => dispatchedRequests.length > 0); @@ -634,11 +691,13 @@ describe("GoalsScreen", () => { title: "Created goal", objective: "Dispatch the authored goal", successCriteria: ["Goal is persisted"], + scopeIn: ["src/presentation tui", "tests/presentation/tui"], + scopeOut: ["src/application layer", "src/domain"], }); unmount(); }); - it("closes the flow and renders the refreshed list with the created goal on success", async () => { + it("keeps success visible until acknowledgement, then refreshes and closes", async () => { const onModalOpenChange = jest.fn(); const goals: GoalView[] = []; const { lastFrame, stdin, unmount } = renderGoalsScreen({ @@ -665,6 +724,17 @@ describe("GoalsScreen", () => { criterion: "Goal is persisted", }); + const resultFrame = await waitForFrame(lastFrame, "goal_created"); + expect(resultFrame).toContain(GoalAuthoringRequestStatus.SUCCESS); + expect(resultFrame).toContain(GoalAuthoringResultCopy.goalIdLabel); + expect(resultFrame).not.toContain("Created goal"); + expect(onModalOpenChange).not.toHaveBeenLastCalledWith(false); + + stdin.write(SPACE); + await settleInput(); + expect(lastFrame()).toContain("goal_created"); + + stdin.write("\r"); const frame = await waitForFrame(lastFrame, "Created goal"); expect(frame).toContain("Created goal"); expect(frame).toContain("1/1"); @@ -690,8 +760,13 @@ describe("GoalsScreen", () => { }); await waitUntil(() => dispatched.mock.calls.length > 0); onModalOpenChange.mockClear(); - await settleInput(); + const failureFrame = await waitForFrame( + lastFrame, + GoalAuthoringRequestStatus.FAILURE, + ); + expect(failureFrame).toContain("dispatch failed"); + expect(failureFrame).not.toContain(GoalAuthoringResultCopy.goalIdLabel); expect(onModalOpenChange).not.toHaveBeenCalledWith(false); stdin.write("\x1b"); @@ -713,8 +788,12 @@ describe("GoalsScreen", () => { criterion: "Goal is persisted", }); onModalOpenChange.mockClear(); - await settleInput(); + const failureFrame = await waitForFrame( + lastFrame, + GoalAuthoringRequestStatus.FAILURE, + ); + expect(failureFrame).not.toContain(GoalAuthoringResultCopy.goalIdLabel); expect(onModalOpenChange).not.toHaveBeenCalledWith(false); stdin.write("\x1b");