@@ -22,6 +22,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
2222import { render } from "vitest-browser-react" ;
2323
2424import { useComposerDraftStore } from "../composerDraftStore" ;
25+ import {
26+ INLINE_TERMINAL_CONTEXT_PLACEHOLDER ,
27+ type TerminalContextDraft ,
28+ } from "../lib/terminalContext" ;
2529import { isMacPlatform } from "../lib/utils" ;
2630import { getRouter } from "../router" ;
2731import { useStore } from "../store" ;
@@ -157,6 +161,25 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe
157161 } ;
158162}
159163
164+ function createTerminalContext ( input : {
165+ id : string ;
166+ terminalLabel : string ;
167+ lineStart : number ;
168+ lineEnd : number ;
169+ text : string ;
170+ } ) : TerminalContextDraft {
171+ return {
172+ id : input . id ,
173+ threadId : THREAD_ID ,
174+ terminalId : `terminal-${ input . id } ` ,
175+ terminalLabel : input . terminalLabel ,
176+ lineStart : input . lineStart ,
177+ lineEnd : input . lineEnd ,
178+ text : input . text ,
179+ createdAt : NOW_ISO ,
180+ } ;
181+ }
182+
160183function createSnapshotForTargetUser ( options : {
161184 targetMessageId : MessageId ;
162185 targetText : string ;
@@ -571,6 +594,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
571594 ) ;
572595}
573596
597+ async function waitForSendButton ( ) : Promise < HTMLButtonElement > {
598+ return waitForElement (
599+ ( ) => document . querySelector < HTMLButtonElement > ( 'button[aria-label="Send message"]' ) ,
600+ "Unable to find send button." ,
601+ ) ;
602+ }
603+
574604async function waitForInteractionModeButton (
575605 expectedLabel : "Chat" | "Plan" ,
576606) : Promise < HTMLButtonElement > {
@@ -1051,6 +1081,166 @@ describe("ChatView timeline estimator parity (full app)", () => {
10511081 }
10521082 } ) ;
10531083
1084+ it ( "keeps backspaced terminal context pills removed when a new one is added" , async ( ) => {
1085+ const removedLabel = "Terminal 1 lines 1-2" ;
1086+ const addedLabel = "Terminal 2 lines 9-10" ;
1087+ useComposerDraftStore . getState ( ) . addTerminalContext (
1088+ THREAD_ID ,
1089+ createTerminalContext ( {
1090+ id : "ctx-removed" ,
1091+ terminalLabel : "Terminal 1" ,
1092+ lineStart : 1 ,
1093+ lineEnd : 2 ,
1094+ text : "bun i\nno changes" ,
1095+ } ) ,
1096+ ) ;
1097+
1098+ const mounted = await mountChatView ( {
1099+ viewport : DEFAULT_VIEWPORT ,
1100+ snapshot : createSnapshotForTargetUser ( {
1101+ targetMessageId : "msg-user-terminal-pill-backspace" as MessageId ,
1102+ targetText : "terminal pill backspace target" ,
1103+ } ) ,
1104+ } ) ;
1105+
1106+ try {
1107+ await vi . waitFor (
1108+ ( ) => {
1109+ expect ( document . body . textContent ) . toContain ( removedLabel ) ;
1110+ } ,
1111+ { timeout : 8_000 , interval : 16 } ,
1112+ ) ;
1113+
1114+ const composerEditor = await waitForComposerEditor ( ) ;
1115+ composerEditor . focus ( ) ;
1116+ composerEditor . dispatchEvent (
1117+ new KeyboardEvent ( "keydown" , {
1118+ key : "Backspace" ,
1119+ bubbles : true ,
1120+ cancelable : true ,
1121+ } ) ,
1122+ ) ;
1123+
1124+ await vi . waitFor (
1125+ ( ) => {
1126+ expect ( useComposerDraftStore . getState ( ) . draftsByThreadId [ THREAD_ID ] ) . toBeUndefined ( ) ;
1127+ expect ( document . body . textContent ) . not . toContain ( removedLabel ) ;
1128+ } ,
1129+ { timeout : 8_000 , interval : 16 } ,
1130+ ) ;
1131+
1132+ useComposerDraftStore . getState ( ) . addTerminalContext (
1133+ THREAD_ID ,
1134+ createTerminalContext ( {
1135+ id : "ctx-added" ,
1136+ terminalLabel : "Terminal 2" ,
1137+ lineStart : 9 ,
1138+ lineEnd : 10 ,
1139+ text : "git status\nOn branch main" ,
1140+ } ) ,
1141+ ) ;
1142+
1143+ await vi . waitFor (
1144+ ( ) => {
1145+ const draft = useComposerDraftStore . getState ( ) . draftsByThreadId [ THREAD_ID ] ;
1146+ expect ( draft ?. terminalContexts . map ( ( context ) => context . id ) ) . toEqual ( [ "ctx-added" ] ) ;
1147+ expect ( document . body . textContent ) . toContain ( addedLabel ) ;
1148+ expect ( document . body . textContent ) . not . toContain ( removedLabel ) ;
1149+ } ,
1150+ { timeout : 8_000 , interval : 16 } ,
1151+ ) ;
1152+ } finally {
1153+ await mounted . cleanup ( ) ;
1154+ }
1155+ } ) ;
1156+
1157+ it ( "disables send when the composer only contains an expired terminal pill" , async ( ) => {
1158+ const expiredLabel = "Terminal 1 line 4" ;
1159+ useComposerDraftStore . getState ( ) . addTerminalContext (
1160+ THREAD_ID ,
1161+ createTerminalContext ( {
1162+ id : "ctx-expired-only" ,
1163+ terminalLabel : "Terminal 1" ,
1164+ lineStart : 4 ,
1165+ lineEnd : 4 ,
1166+ text : "" ,
1167+ } ) ,
1168+ ) ;
1169+
1170+ const mounted = await mountChatView ( {
1171+ viewport : DEFAULT_VIEWPORT ,
1172+ snapshot : createSnapshotForTargetUser ( {
1173+ targetMessageId : "msg-user-expired-pill-disabled" as MessageId ,
1174+ targetText : "expired pill disabled target" ,
1175+ } ) ,
1176+ } ) ;
1177+
1178+ try {
1179+ await vi . waitFor (
1180+ ( ) => {
1181+ expect ( document . body . textContent ) . toContain ( expiredLabel ) ;
1182+ } ,
1183+ { timeout : 8_000 , interval : 16 } ,
1184+ ) ;
1185+
1186+ const sendButton = await waitForSendButton ( ) ;
1187+ expect ( sendButton . disabled ) . toBe ( true ) ;
1188+ } finally {
1189+ await mounted . cleanup ( ) ;
1190+ }
1191+ } ) ;
1192+
1193+ it ( "warns when sending text while omitting expired terminal pills" , async ( ) => {
1194+ const expiredLabel = "Terminal 1 line 4" ;
1195+ useComposerDraftStore . getState ( ) . addTerminalContext (
1196+ THREAD_ID ,
1197+ createTerminalContext ( {
1198+ id : "ctx-expired-send-warning" ,
1199+ terminalLabel : "Terminal 1" ,
1200+ lineStart : 4 ,
1201+ lineEnd : 4 ,
1202+ text : "" ,
1203+ } ) ,
1204+ ) ;
1205+ useComposerDraftStore
1206+ . getState ( )
1207+ . setPrompt ( THREAD_ID , `yoo${ INLINE_TERMINAL_CONTEXT_PLACEHOLDER } waddup` ) ;
1208+
1209+ const mounted = await mountChatView ( {
1210+ viewport : DEFAULT_VIEWPORT ,
1211+ snapshot : createSnapshotForTargetUser ( {
1212+ targetMessageId : "msg-user-expired-pill-warning" as MessageId ,
1213+ targetText : "expired pill warning target" ,
1214+ } ) ,
1215+ } ) ;
1216+
1217+ try {
1218+ await vi . waitFor (
1219+ ( ) => {
1220+ expect ( document . body . textContent ) . toContain ( expiredLabel ) ;
1221+ } ,
1222+ { timeout : 8_000 , interval : 16 } ,
1223+ ) ;
1224+
1225+ const sendButton = await waitForSendButton ( ) ;
1226+ expect ( sendButton . disabled ) . toBe ( false ) ;
1227+ sendButton . click ( ) ;
1228+
1229+ await vi . waitFor (
1230+ ( ) => {
1231+ expect ( document . body . textContent ) . toContain (
1232+ "Expired terminal context omitted from message" ,
1233+ ) ;
1234+ expect ( document . body . textContent ) . not . toContain ( expiredLabel ) ;
1235+ expect ( document . body . textContent ) . toContain ( "yoowaddup" ) ;
1236+ } ,
1237+ { timeout : 8_000 , interval : 16 } ,
1238+ ) ;
1239+ } finally {
1240+ await mounted . cleanup ( ) ;
1241+ }
1242+ } ) ;
1243+
10541244 it ( "shows a pointer cursor for the running stop button" , async ( ) => {
10551245 const mounted = await mountChatView ( {
10561246 viewport : DEFAULT_VIEWPORT ,
0 commit comments