11import assert from "node:assert/strict" ;
2+ import { readFileSync } from "node:fs" ;
23import test from "node:test" ;
4+ import { Script } from "node:vm" ;
35import { createJiti } from "jiti" ;
6+ import ts from "typescript" ;
47
58const jiti = createJiti ( import . meta. url , {
69 jsx : { runtime : "automatic" } ,
@@ -13,6 +16,79 @@ const { ModelSelector } = await jiti.import("./ModelSelector.tsx");
1316const { clearDraft, getDraft, mergeRestoredSubmissionDraft, mergeRestoredSubmissionText, rekeyDraft, setDraft } = await jiti . import ( "@/lib/draft-store.ts" ) ;
1417const { I18nProvider } = await jiti . import ( "@/hooks/useI18n" ) ;
1518
19+ test ( "follow-up shortcuts preserve newline, IME, mobile and completion behavior" , ( ) => {
20+ const source = ts . createSourceFile ( "ChatInput.tsx" , readFileSync ( new URL ( "./ChatInput.tsx" , import . meta. url ) , "utf8" ) , ts . ScriptTarget . Latest , true , ts . ScriptKind . TSX ) ;
21+ function findHandler ( node ) {
22+ if ( ts . isVariableDeclaration ( node ) && node . name . getText ( source ) === "handleKeyDown" ) {
23+ return node . initializer . arguments [ 0 ] ;
24+ }
25+ return ts . forEachChild ( node , findHandler ) ;
26+ }
27+ // Execute the component's actual callback without mounting the rest of the UI.
28+ const script = new Script ( ts . transpileModule ( findHandler ( source ) . getText ( source ) , {
29+ compilerOptions : { target : ts . ScriptTarget . ES2020 } ,
30+ } ) . outputText ) ;
31+ const cases = [
32+ [ "Enter steers" , { } , { } , "steer" ] ,
33+ [ "Alt+Enter follows up" , { altKey : true } , { } , "followup" ] ,
34+ [ "idle Alt+Enter sends" , { altKey : true } , { isStreaming : false } , "send" ] ,
35+ [ "Shift+Enter inserts a newline" , { shiftKey : true } , { } , "native" ] ,
36+ [ "Alt+Shift+Enter keeps native behavior" , { altKey : true , shiftKey : true } , { } , "native" ] ,
37+ [ "composition ref blocks sending" , { altKey : true } , { isComposingRef : { current : true } } , "native" ] ,
38+ [ "native composition blocks sending" , { altKey : true , nativeEvent : { isComposing : true } } , { } , "native" ] ,
39+ [ "IME keyCode blocks sending" , { altKey : true , nativeEvent : { keyCode : 229 } } , { } , "native" ] ,
40+ [ "composition grace blocks sending" , { altKey : true } , { lastCompositionEndAtRef : { current : 950 } } , "prevented" ] ,
41+ [ "mobile Alt+Enter keeps native behavior" , { altKey : true } , { isMobile : true } , "native" ] ,
42+ [ "mobile composition grace cannot send" , { altKey : true } , { isMobile : true , lastCompositionEndAtRef : { current : 950 } } , "native" ] ,
43+ [ "mobile Ctrl+Alt+Enter follows up" , { altKey : true , ctrlKey : true } , { isMobile : true } , "followup" ] ,
44+ [ "mobile Cmd+Alt+Enter follows up" , { altKey : true , metaKey : true } , { isMobile : true } , "followup" ] ,
45+ [ "mobile modified Enter respects composition grace" , { altKey : true , ctrlKey : true } , { isMobile : true , lastCompositionEndAtRef : { current : 950 } } , "prevented" ] ,
46+ [ "Enter falls back to follow-up" , { } , { onSteer : undefined } , "followup" ] ,
47+ [ "Alt+Enter falls back to steer" , { altKey : true } , { onFollowUp : undefined } , "steer" ] ,
48+ [ "slash completion takes priority" , { altKey : true } , { slashMenuOpen : true , slashQuery : "help" } , "slash" ] ,
49+ [ "available built-in commands take priority" , { altKey : true } , { slashMenuOpen : true , slashQuery : "copy" , value : "/copy" , displayedSlashCommands : [ { name : "copy" , source : "builtin" , availableWhileStreaming : true } ] } , "send" ] ,
50+ [ "file completion takes priority" , { altKey : true } , { atMenuOpen : true , atQuery : { } } , "file" ] ,
51+ [ "history selection takes priority" , { altKey : true } , { historyMenuOpen : true } , "history" ] ,
52+ ] ;
53+ for ( const [ name , keys , state , expected ] of cases ) {
54+ let action = "native" ;
55+ const handler = script . runInNewContext ( {
56+ Date : { now : ( ) => 1000 } ,
57+ COMPOSITION_END_ENTER_GRACE_MS : 100 ,
58+ isMobile : false , isStreaming : true ,
59+ isComposingRef : { current : false } , lastCompositionEndAtRef : { current : 0 } ,
60+ historyMenuOpen : false , inputHistory : [ "previous" ] , historyActiveIndex : 0 ,
61+ slashMenuOpen : false , slashQuery : null , displayedSlashCommands : [ { } ] , slashActiveIndex : 0 ,
62+ atMenuOpen : false , atQuery : null , atMatches : [ { } ] , atActiveIndex : 0 ,
63+ onSteer ( ) { } , onFollowUp ( ) { } ,
64+ sendQueued ( mode ) { action = mode ; } , handleSend ( ) { action = "send" ; } ,
65+ applySlashCommand ( ) { action = "slash" ; } ,
66+ isExactSlashCommand, value : "" , setSlashMenuOpen ( ) { } ,
67+ applyAtCompletion ( ) { action = "file" ; } ,
68+ applyHistoryInput ( ) { action = "history" ; } ,
69+ ...state ,
70+ } ) ;
71+ handler ( {
72+ key : "Enter" , shiftKey : false , altKey : false , ctrlKey : false , metaKey : false ,
73+ nativeEvent : { isComposing : false , keyCode : 13 } ,
74+ preventDefault ( ) { action = "prevented" ; } ,
75+ ...keys ,
76+ } ) ;
77+ assert . equal ( action , expected , name ) ;
78+ }
79+ } ) ;
80+
81+ test ( "shows the follow-up shortcut in the button tooltip" , ( ) => {
82+ const html = renderToStaticMarkup (
83+ React . createElement ( I18nProvider , null , React . createElement ( ChatInput , {
84+ onSend ( ) { } , onAbort ( ) { } , onFollowUp ( ) { } , isStreaming : true ,
85+ } ) ) ,
86+ ) ;
87+
88+ assert . match ( html , / t i t l e = " Q u e u e t h i s m e s s a g e a f t e r t h e a g e n t f i n i s h e s \( A l t \/ O p t i o n \+ E n t e r \) " / ) ;
89+ assert . match ( html , / a r i a - k e y s h o r t c u t s = " A l t \+ E n t e r " / ) ;
90+ } ) ;
91+
1692test ( "renders the upstream model error" , ( ) => {
1793 const html = renderToStaticMarkup (
1894 React . createElement (
0 commit comments