@@ -12,6 +12,7 @@ import {
1212 type ServerLifecycleWelcomePayload ,
1313 type ThreadId ,
1414 type TurnId ,
15+ type UserInputQuestion ,
1516 WS_METHODS ,
1617 OrchestrationSessionStatus ,
1718 DEFAULT_SERVER_SETTINGS ,
@@ -541,12 +542,51 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
541542 } ;
542543}
543544
544- function createSnapshotWithPendingUserInput ( ) : OrchestrationReadModel {
545+ function createSnapshotWithPendingUserInput ( options ?: {
546+ questions ?: ReadonlyArray < UserInputQuestion > ;
547+ } ) : OrchestrationReadModel {
545548 const snapshot = createSnapshotForTargetUser ( {
546549 targetMessageId : "msg-user-pending-input-target" as MessageId ,
547550 targetText : "question thread" ,
548551 } ) ;
549552
553+ const questions =
554+ options ?. questions ??
555+ ( [
556+ {
557+ id : "scope" ,
558+ header : "Scope" ,
559+ question : "What should this change cover?" ,
560+ options : [
561+ {
562+ label : "Tight" ,
563+ description : "Touch only the footer layout logic." ,
564+ } ,
565+ {
566+ label : "Broad" ,
567+ description : "Also adjust the related composer controls." ,
568+ } ,
569+ ] ,
570+ multiSelect : false ,
571+ } ,
572+ {
573+ id : "risk" ,
574+ header : "Risk" ,
575+ question : "How aggressive should the imaginary plan be?" ,
576+ options : [
577+ {
578+ label : "Conservative" ,
579+ description : "Favor reliability and low-risk changes." ,
580+ } ,
581+ {
582+ label : "Balanced" ,
583+ description : "Mix quick wins with one structural improvement." ,
584+ } ,
585+ ] ,
586+ multiSelect : false ,
587+ } ,
588+ ] satisfies ReadonlyArray < UserInputQuestion > ) ;
589+
550590 return {
551591 ...snapshot ,
552592 threads : snapshot . threads . map ( ( thread ) =>
@@ -561,38 +601,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
561601 summary : "User input requested" ,
562602 payload : {
563603 requestId : "req-browser-user-input" ,
564- questions : [
565- {
566- id : "scope" ,
567- header : "Scope" ,
568- question : "What should this change cover?" ,
569- options : [
570- {
571- label : "Tight" ,
572- description : "Touch only the footer layout logic." ,
573- } ,
574- {
575- label : "Broad" ,
576- description : "Also adjust the related composer controls." ,
577- } ,
578- ] ,
579- } ,
580- {
581- id : "risk" ,
582- header : "Risk" ,
583- question : "How aggressive should the imaginary plan be?" ,
584- options : [
585- {
586- label : "Conservative" ,
587- description : "Favor reliability and low-risk changes." ,
588- } ,
589- {
590- label : "Balanced" ,
591- description : "Mix quick wins with one structural improvement." ,
592- } ,
593- ] ,
594- } ,
595- ] ,
604+ questions,
596605 } ,
597606 turnId : null ,
598607 sequence : 1 ,
@@ -2902,6 +2911,143 @@ describe("ChatView timeline estimator parity (full app)", () => {
29022911 }
29032912 } ) ;
29042913
2914+ it ( "does not trigger numeric option shortcuts while the composer is focused" , async ( ) => {
2915+ const mounted = await mountChatView ( {
2916+ viewport : WIDE_FOOTER_VIEWPORT ,
2917+ snapshot : createSnapshotWithPendingUserInput ( ) ,
2918+ } ) ;
2919+
2920+ try {
2921+ const composerEditor = await waitForComposerEditor ( ) ;
2922+ composerEditor . focus ( ) ;
2923+
2924+ const event = new KeyboardEvent ( "keydown" , {
2925+ key : "2" ,
2926+ bubbles : true ,
2927+ cancelable : true ,
2928+ } ) ;
2929+ composerEditor . dispatchEvent ( event ) ;
2930+ await waitForLayout ( ) ;
2931+
2932+ expect ( event . defaultPrevented ) . toBe ( false ) ;
2933+ expect ( document . body . textContent ) . toContain ( "What should this change cover?" ) ;
2934+ expect ( document . body . textContent ) . not . toContain (
2935+ "How aggressive should the imaginary plan be?" ,
2936+ ) ;
2937+ await waitForButtonByText ( "Next question" ) ;
2938+ } finally {
2939+ await mounted . cleanup ( ) ;
2940+ }
2941+ } ) ;
2942+
2943+ it ( "submits multi-select questionnaire answers as arrays" , async ( ) => {
2944+ const mounted = await mountChatView ( {
2945+ viewport : WIDE_FOOTER_VIEWPORT ,
2946+ snapshot : createSnapshotWithPendingUserInput ( {
2947+ questions : [
2948+ {
2949+ id : "scope" ,
2950+ header : "Scope" ,
2951+ question : "Which areas should this change cover?" ,
2952+ options : [
2953+ {
2954+ label : "Server" ,
2955+ description : "Touch server orchestration." ,
2956+ } ,
2957+ {
2958+ label : "Web" ,
2959+ description : "Touch the browser UI." ,
2960+ } ,
2961+ ] ,
2962+ multiSelect : true ,
2963+ } ,
2964+ {
2965+ id : "risk" ,
2966+ header : "Risk" ,
2967+ question : "How aggressive should the imaginary plan be?" ,
2968+ options : [
2969+ {
2970+ label : "Balanced" ,
2971+ description : "Mix quick wins with one structural improvement." ,
2972+ } ,
2973+ ] ,
2974+ multiSelect : false ,
2975+ } ,
2976+ ] ,
2977+ } ) ,
2978+ resolveRpc : ( body ) => {
2979+ if ( body . _tag === ORCHESTRATION_WS_METHODS . dispatchCommand ) {
2980+ return {
2981+ sequence : fixture . snapshot . snapshotSequence + 1 ,
2982+ } ;
2983+ }
2984+ return undefined ;
2985+ } ,
2986+ } ) ;
2987+
2988+ try {
2989+ const serverOption = await waitForButtonContainingText ( "Server" ) ;
2990+ serverOption . click ( ) ;
2991+ await waitForLayout ( ) ;
2992+
2993+ expect ( document . body . textContent ) . toContain ( "Which areas should this change cover?" ) ;
2994+
2995+ const webOption = await waitForButtonContainingText ( "Web" ) ;
2996+ webOption . click ( ) ;
2997+ await waitForLayout ( ) ;
2998+
2999+ expect ( document . body . textContent ) . toContain ( "Which areas should this change cover?" ) ;
3000+
3001+ const nextButton = await waitForButtonByText ( "Next question" ) ;
3002+ expect ( nextButton . disabled ) . toBe ( false ) ;
3003+ nextButton . click ( ) ;
3004+
3005+ await vi . waitFor (
3006+ ( ) => {
3007+ expect ( document . body . textContent ) . toContain (
3008+ "How aggressive should the imaginary plan be?" ,
3009+ ) ;
3010+ } ,
3011+ { timeout : 8_000 , interval : 16 } ,
3012+ ) ;
3013+
3014+ const balancedOption = await waitForButtonContainingText ( "Balanced" ) ;
3015+ balancedOption . click ( ) ;
3016+
3017+ const submitButton = await waitForButtonByText ( "Submit answers" ) ;
3018+ expect ( submitButton . disabled ) . toBe ( false ) ;
3019+ submitButton . click ( ) ;
3020+
3021+ await vi . waitFor (
3022+ ( ) => {
3023+ const dispatchRequest = wsRequests . find (
3024+ ( request ) =>
3025+ request . _tag === ORCHESTRATION_WS_METHODS . dispatchCommand &&
3026+ request . type === "thread.user-input.respond" ,
3027+ ) as
3028+ | {
3029+ _tag : string ;
3030+ type ?: string ;
3031+ answers ?: Record < string , unknown > ;
3032+ }
3033+ | undefined ;
3034+
3035+ expect ( dispatchRequest ) . toMatchObject ( {
3036+ _tag : ORCHESTRATION_WS_METHODS . dispatchCommand ,
3037+ type : "thread.user-input.respond" ,
3038+ answers : {
3039+ scope : [ "Server" , "Web" ] ,
3040+ risk : "Balanced" ,
3041+ } ,
3042+ } ) ;
3043+ } ,
3044+ { timeout : 8_000 , interval : 16 } ,
3045+ ) ;
3046+ } finally {
3047+ await mounted . cleanup ( ) ;
3048+ }
3049+ } ) ;
3050+
29053051 it ( "keeps plan follow-up footer actions fused and aligned after a real resize" , async ( ) => {
29063052 const mounted = await mountChatView ( {
29073053 viewport : WIDE_FOOTER_VIEWPORT ,
0 commit comments