@@ -21,6 +21,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
2121import { render } from "vitest-browser-react" ;
2222
2323import { useComposerDraftStore } from "../composerDraftStore" ;
24+ import {
25+ INLINE_TERMINAL_CONTEXT_PLACEHOLDER ,
26+ type TerminalContextDraft ,
27+ } from "../lib/terminalContext" ;
2428import { isMacPlatform } from "../lib/utils" ;
2529import { getRouter } from "../router" ;
2630import { useStore } from "../store" ;
@@ -150,6 +154,25 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe
150154 } ;
151155}
152156
157+ function createTerminalContext ( input : {
158+ id : string ;
159+ terminalLabel : string ;
160+ lineStart : number ;
161+ lineEnd : number ;
162+ text : string ;
163+ } ) : TerminalContextDraft {
164+ return {
165+ id : input . id ,
166+ threadId : THREAD_ID ,
167+ terminalId : `terminal-${ input . id } ` ,
168+ terminalLabel : input . terminalLabel ,
169+ lineStart : input . lineStart ,
170+ lineEnd : input . lineEnd ,
171+ text : input . text ,
172+ createdAt : NOW_ISO ,
173+ } ;
174+ }
175+
153176function createSnapshotForTargetUser ( options : {
154177 targetMessageId : MessageId ;
155178 targetText : string ;
@@ -531,6 +554,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
531554 ) ;
532555}
533556
557+ async function waitForSendButton ( ) : Promise < HTMLButtonElement > {
558+ return waitForElement (
559+ ( ) => document . querySelector < HTMLButtonElement > ( 'button[aria-label="Send message"]' ) ,
560+ "Unable to find send button." ,
561+ ) ;
562+ }
563+
534564async function waitForInteractionModeButton (
535565 expectedLabel : "Chat" | "Plan" ,
536566) : Promise < HTMLButtonElement > {
@@ -1011,6 +1041,166 @@ describe("ChatView timeline estimator parity (full app)", () => {
10111041 }
10121042 } ) ;
10131043
1044+ it ( "keeps backspaced terminal context pills removed when a new one is added" , async ( ) => {
1045+ const removedLabel = "Terminal 1 lines 1-2" ;
1046+ const addedLabel = "Terminal 2 lines 9-10" ;
1047+ useComposerDraftStore . getState ( ) . addTerminalContext (
1048+ THREAD_ID ,
1049+ createTerminalContext ( {
1050+ id : "ctx-removed" ,
1051+ terminalLabel : "Terminal 1" ,
1052+ lineStart : 1 ,
1053+ lineEnd : 2 ,
1054+ text : "bun i\nno changes" ,
1055+ } ) ,
1056+ ) ;
1057+
1058+ const mounted = await mountChatView ( {
1059+ viewport : DEFAULT_VIEWPORT ,
1060+ snapshot : createSnapshotForTargetUser ( {
1061+ targetMessageId : "msg-user-terminal-pill-backspace" as MessageId ,
1062+ targetText : "terminal pill backspace target" ,
1063+ } ) ,
1064+ } ) ;
1065+
1066+ try {
1067+ await vi . waitFor (
1068+ ( ) => {
1069+ expect ( document . body . textContent ) . toContain ( removedLabel ) ;
1070+ } ,
1071+ { timeout : 8_000 , interval : 16 } ,
1072+ ) ;
1073+
1074+ const composerEditor = await waitForComposerEditor ( ) ;
1075+ composerEditor . focus ( ) ;
1076+ composerEditor . dispatchEvent (
1077+ new KeyboardEvent ( "keydown" , {
1078+ key : "Backspace" ,
1079+ bubbles : true ,
1080+ cancelable : true ,
1081+ } ) ,
1082+ ) ;
1083+
1084+ await vi . waitFor (
1085+ ( ) => {
1086+ expect ( useComposerDraftStore . getState ( ) . draftsByThreadId [ THREAD_ID ] ) . toBeUndefined ( ) ;
1087+ expect ( document . body . textContent ) . not . toContain ( removedLabel ) ;
1088+ } ,
1089+ { timeout : 8_000 , interval : 16 } ,
1090+ ) ;
1091+
1092+ useComposerDraftStore . getState ( ) . addTerminalContext (
1093+ THREAD_ID ,
1094+ createTerminalContext ( {
1095+ id : "ctx-added" ,
1096+ terminalLabel : "Terminal 2" ,
1097+ lineStart : 9 ,
1098+ lineEnd : 10 ,
1099+ text : "git status\nOn branch main" ,
1100+ } ) ,
1101+ ) ;
1102+
1103+ await vi . waitFor (
1104+ ( ) => {
1105+ const draft = useComposerDraftStore . getState ( ) . draftsByThreadId [ THREAD_ID ] ;
1106+ expect ( draft ?. terminalContexts . map ( ( context ) => context . id ) ) . toEqual ( [ "ctx-added" ] ) ;
1107+ expect ( document . body . textContent ) . toContain ( addedLabel ) ;
1108+ expect ( document . body . textContent ) . not . toContain ( removedLabel ) ;
1109+ } ,
1110+ { timeout : 8_000 , interval : 16 } ,
1111+ ) ;
1112+ } finally {
1113+ await mounted . cleanup ( ) ;
1114+ }
1115+ } ) ;
1116+
1117+ it ( "disables send when the composer only contains an expired terminal pill" , async ( ) => {
1118+ const expiredLabel = "Terminal 1 line 4" ;
1119+ useComposerDraftStore . getState ( ) . addTerminalContext (
1120+ THREAD_ID ,
1121+ createTerminalContext ( {
1122+ id : "ctx-expired-only" ,
1123+ terminalLabel : "Terminal 1" ,
1124+ lineStart : 4 ,
1125+ lineEnd : 4 ,
1126+ text : "" ,
1127+ } ) ,
1128+ ) ;
1129+
1130+ const mounted = await mountChatView ( {
1131+ viewport : DEFAULT_VIEWPORT ,
1132+ snapshot : createSnapshotForTargetUser ( {
1133+ targetMessageId : "msg-user-expired-pill-disabled" as MessageId ,
1134+ targetText : "expired pill disabled target" ,
1135+ } ) ,
1136+ } ) ;
1137+
1138+ try {
1139+ await vi . waitFor (
1140+ ( ) => {
1141+ expect ( document . body . textContent ) . toContain ( expiredLabel ) ;
1142+ } ,
1143+ { timeout : 8_000 , interval : 16 } ,
1144+ ) ;
1145+
1146+ const sendButton = await waitForSendButton ( ) ;
1147+ expect ( sendButton . disabled ) . toBe ( true ) ;
1148+ } finally {
1149+ await mounted . cleanup ( ) ;
1150+ }
1151+ } ) ;
1152+
1153+ it ( "warns when sending text while omitting expired terminal pills" , async ( ) => {
1154+ const expiredLabel = "Terminal 1 line 4" ;
1155+ useComposerDraftStore . getState ( ) . addTerminalContext (
1156+ THREAD_ID ,
1157+ createTerminalContext ( {
1158+ id : "ctx-expired-send-warning" ,
1159+ terminalLabel : "Terminal 1" ,
1160+ lineStart : 4 ,
1161+ lineEnd : 4 ,
1162+ text : "" ,
1163+ } ) ,
1164+ ) ;
1165+ useComposerDraftStore
1166+ . getState ( )
1167+ . setPrompt ( THREAD_ID , `yoo${ INLINE_TERMINAL_CONTEXT_PLACEHOLDER } waddup` ) ;
1168+
1169+ const mounted = await mountChatView ( {
1170+ viewport : DEFAULT_VIEWPORT ,
1171+ snapshot : createSnapshotForTargetUser ( {
1172+ targetMessageId : "msg-user-expired-pill-warning" as MessageId ,
1173+ targetText : "expired pill warning target" ,
1174+ } ) ,
1175+ } ) ;
1176+
1177+ try {
1178+ await vi . waitFor (
1179+ ( ) => {
1180+ expect ( document . body . textContent ) . toContain ( expiredLabel ) ;
1181+ } ,
1182+ { timeout : 8_000 , interval : 16 } ,
1183+ ) ;
1184+
1185+ const sendButton = await waitForSendButton ( ) ;
1186+ expect ( sendButton . disabled ) . toBe ( false ) ;
1187+ sendButton . click ( ) ;
1188+
1189+ await vi . waitFor (
1190+ ( ) => {
1191+ expect ( document . body . textContent ) . toContain (
1192+ "Expired terminal context omitted from message" ,
1193+ ) ;
1194+ expect ( document . body . textContent ) . not . toContain ( expiredLabel ) ;
1195+ expect ( document . body . textContent ) . toContain ( "yoowaddup" ) ;
1196+ } ,
1197+ { timeout : 8_000 , interval : 16 } ,
1198+ ) ;
1199+ } finally {
1200+ await mounted . cleanup ( ) ;
1201+ }
1202+ } ) ;
1203+
10141204 it ( "shows a pointer cursor for the running stop button" , async ( ) => {
10151205 const mounted = await mountChatView ( {
10161206 viewport : DEFAULT_VIEWPORT ,
0 commit comments