@@ -24,6 +24,7 @@ import { useComposerDraftStore } from "../composerDraftStore";
2424import {
2525 INLINE_TERMINAL_CONTEXT_PLACEHOLDER ,
2626 type TerminalContextDraft ,
27+ removeInlineTerminalContextPlaceholder ,
2728} from "../lib/terminalContext" ;
2829import { isMacPlatform } from "../lib/utils" ;
2930import { getRouter } from "../router" ;
@@ -324,6 +325,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
324325 } ;
325326}
326327
328+ function withProjectScripts (
329+ snapshot : OrchestrationReadModel ,
330+ scripts : OrchestrationReadModel [ "projects" ] [ number ] [ "scripts" ] ,
331+ ) : OrchestrationReadModel {
332+ return {
333+ ...snapshot ,
334+ projects : snapshot . projects . map ( ( project ) =>
335+ project . id === PROJECT_ID ? { ...project , scripts : Array . from ( scripts ) } : project ,
336+ ) ,
337+ } ;
338+ }
339+
327340function createSnapshotWithLongProposedPlan ( ) : OrchestrationReadModel {
328341 const snapshot = createSnapshotForTargetUser ( {
329342 targetMessageId : "msg-user-plan-target" as MessageId ,
@@ -575,6 +588,58 @@ async function waitForInteractionModeButton(
575588 ) ;
576589}
577590
591+ async function waitForServerConfigToApply ( ) : Promise < void > {
592+ await vi . waitFor (
593+ ( ) => {
594+ expect ( wsRequests . some ( ( request ) => request . _tag === WS_METHODS . serverGetConfig ) ) . toBe ( true ) ;
595+ } ,
596+ { timeout : 8_000 , interval : 16 } ,
597+ ) ;
598+ await waitForLayout ( ) ;
599+ }
600+
601+ function dispatchChatNewShortcut ( ) : void {
602+ const useMetaForMod = isMacPlatform ( navigator . platform ) ;
603+ window . dispatchEvent (
604+ new KeyboardEvent ( "keydown" , {
605+ key : "o" ,
606+ shiftKey : true ,
607+ metaKey : useMetaForMod ,
608+ ctrlKey : ! useMetaForMod ,
609+ bubbles : true ,
610+ cancelable : true ,
611+ } ) ,
612+ ) ;
613+ }
614+
615+ async function triggerChatNewShortcutUntilPath (
616+ router : ReturnType < typeof getRouter > ,
617+ predicate : ( pathname : string ) => boolean ,
618+ errorMessage : string ,
619+ ) : Promise < string > {
620+ let pathname = router . state . location . pathname ;
621+ const deadline = Date . now ( ) + 8_000 ;
622+ while ( Date . now ( ) < deadline ) {
623+ dispatchChatNewShortcut ( ) ;
624+ await waitForLayout ( ) ;
625+ pathname = router . state . location . pathname ;
626+ if ( predicate ( pathname ) ) {
627+ return pathname ;
628+ }
629+ }
630+ throw new Error ( `${ errorMessage } Last path: ${ pathname } ` ) ;
631+ }
632+
633+ async function waitForNewThreadShortcutLabel ( ) : Promise < void > {
634+ const newThreadButton = page . getByTestId ( "new-thread-button" ) ;
635+ await expect . element ( newThreadButton ) . toBeInTheDocument ( ) ;
636+ await newThreadButton . hover ( ) ;
637+ const shortcutLabel = isMacPlatform ( navigator . platform )
638+ ? "New thread (⇧⌘O)"
639+ : "New thread (Ctrl+Shift+O)" ;
640+ await expect . element ( page . getByText ( shortcutLabel ) ) . toBeInTheDocument ( ) ;
641+ }
642+
578643async function waitForImagesToLoad ( scope : ParentNode ) : Promise < void > {
579644 const images = Array . from ( scope . querySelectorAll ( "img" ) ) ;
580645 if ( images . length === 0 ) {
@@ -980,6 +1045,145 @@ describe("ChatView timeline estimator parity (full app)", () => {
9801045 }
9811046 } ) ;
9821047
1048+ it ( "runs project scripts from local draft threads at the project cwd" , async ( ) => {
1049+ useComposerDraftStore . setState ( {
1050+ draftThreadsByThreadId : {
1051+ [ THREAD_ID ] : {
1052+ projectId : PROJECT_ID ,
1053+ createdAt : NOW_ISO ,
1054+ runtimeMode : "full-access" ,
1055+ interactionMode : "default" ,
1056+ branch : null ,
1057+ worktreePath : null ,
1058+ envMode : "local" ,
1059+ } ,
1060+ } ,
1061+ projectDraftThreadIdByProjectId : {
1062+ [ PROJECT_ID ] : THREAD_ID ,
1063+ } ,
1064+ } ) ;
1065+
1066+ const mounted = await mountChatView ( {
1067+ viewport : DEFAULT_VIEWPORT ,
1068+ snapshot : withProjectScripts ( createDraftOnlySnapshot ( ) , [
1069+ {
1070+ id : "lint" ,
1071+ name : "Lint" ,
1072+ command : "bun run lint" ,
1073+ icon : "lint" ,
1074+ runOnWorktreeCreate : false ,
1075+ } ,
1076+ ] ) ,
1077+ } ) ;
1078+
1079+ try {
1080+ const runButton = await waitForElement (
1081+ ( ) =>
1082+ Array . from ( document . querySelectorAll ( "button" ) ) . find (
1083+ ( button ) => button . title === "Run Lint" ,
1084+ ) as HTMLButtonElement | null ,
1085+ "Unable to find Run Lint button." ,
1086+ ) ;
1087+ runButton . click ( ) ;
1088+
1089+ await vi . waitFor (
1090+ ( ) => {
1091+ const openRequest = wsRequests . find (
1092+ ( request ) => request . _tag === WS_METHODS . terminalOpen ,
1093+ ) ;
1094+ expect ( openRequest ) . toMatchObject ( {
1095+ _tag : WS_METHODS . terminalOpen ,
1096+ threadId : THREAD_ID ,
1097+ cwd : "/repo/project" ,
1098+ env : {
1099+ T3CODE_PROJECT_ROOT : "/repo/project" ,
1100+ } ,
1101+ } ) ;
1102+ } ,
1103+ { timeout : 8_000 , interval : 16 } ,
1104+ ) ;
1105+
1106+ await vi . waitFor (
1107+ ( ) => {
1108+ const writeRequest = wsRequests . find (
1109+ ( request ) => request . _tag === WS_METHODS . terminalWrite ,
1110+ ) ;
1111+ expect ( writeRequest ) . toMatchObject ( {
1112+ _tag : WS_METHODS . terminalWrite ,
1113+ threadId : THREAD_ID ,
1114+ data : "bun run lint\r" ,
1115+ } ) ;
1116+ } ,
1117+ { timeout : 8_000 , interval : 16 } ,
1118+ ) ;
1119+ } finally {
1120+ await mounted . cleanup ( ) ;
1121+ }
1122+ } ) ;
1123+
1124+ it ( "runs project scripts from worktree draft threads at the worktree cwd" , async ( ) => {
1125+ useComposerDraftStore . setState ( {
1126+ draftThreadsByThreadId : {
1127+ [ THREAD_ID ] : {
1128+ projectId : PROJECT_ID ,
1129+ createdAt : NOW_ISO ,
1130+ runtimeMode : "full-access" ,
1131+ interactionMode : "default" ,
1132+ branch : "feature/draft" ,
1133+ worktreePath : "/repo/worktrees/feature-draft" ,
1134+ envMode : "worktree" ,
1135+ } ,
1136+ } ,
1137+ projectDraftThreadIdByProjectId : {
1138+ [ PROJECT_ID ] : THREAD_ID ,
1139+ } ,
1140+ } ) ;
1141+
1142+ const mounted = await mountChatView ( {
1143+ viewport : DEFAULT_VIEWPORT ,
1144+ snapshot : withProjectScripts ( createDraftOnlySnapshot ( ) , [
1145+ {
1146+ id : "test" ,
1147+ name : "Test" ,
1148+ command : "bun run test" ,
1149+ icon : "test" ,
1150+ runOnWorktreeCreate : false ,
1151+ } ,
1152+ ] ) ,
1153+ } ) ;
1154+
1155+ try {
1156+ const runButton = await waitForElement (
1157+ ( ) =>
1158+ Array . from ( document . querySelectorAll ( "button" ) ) . find (
1159+ ( button ) => button . title === "Run Test" ,
1160+ ) as HTMLButtonElement | null ,
1161+ "Unable to find Run Test button." ,
1162+ ) ;
1163+ runButton . click ( ) ;
1164+
1165+ await vi . waitFor (
1166+ ( ) => {
1167+ const openRequest = wsRequests . find (
1168+ ( request ) => request . _tag === WS_METHODS . terminalOpen ,
1169+ ) ;
1170+ expect ( openRequest ) . toMatchObject ( {
1171+ _tag : WS_METHODS . terminalOpen ,
1172+ threadId : THREAD_ID ,
1173+ cwd : "/repo/worktrees/feature-draft" ,
1174+ env : {
1175+ T3CODE_PROJECT_ROOT : "/repo/project" ,
1176+ T3CODE_WORKTREE_PATH : "/repo/worktrees/feature-draft" ,
1177+ } ,
1178+ } ) ;
1179+ } ,
1180+ { timeout : 8_000 , interval : 16 } ,
1181+ ) ;
1182+ } finally {
1183+ await mounted . cleanup ( ) ;
1184+ }
1185+ } ) ;
1186+
9831187 it ( "toggles plan mode with Shift+Tab only while the composer is focused" , async ( ) => {
9841188 const mounted = await mountChatView ( {
9851189 viewport : DEFAULT_VIEWPORT ,
@@ -1045,7 +1249,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
10451249 }
10461250 } ) ;
10471251
1048- it ( "keeps backspaced terminal context pills removed when a new one is added" , async ( ) => {
1252+ it ( "keeps removed terminal context pills removed when a new one is added" , async ( ) => {
10491253 const removedLabel = "Terminal 1 lines 1-2" ;
10501254 const addedLabel = "Terminal 2 lines 9-10" ;
10511255 useComposerDraftStore . getState ( ) . addTerminalContext (
@@ -1075,15 +1279,11 @@ describe("ChatView timeline estimator parity (full app)", () => {
10751279 { timeout : 8_000 , interval : 16 } ,
10761280 ) ;
10771281
1078- const composerEditor = await waitForComposerEditor ( ) ;
1079- composerEditor . focus ( ) ;
1080- composerEditor . dispatchEvent (
1081- new KeyboardEvent ( "keydown" , {
1082- key : "Backspace" ,
1083- bubbles : true ,
1084- cancelable : true ,
1085- } ) ,
1086- ) ;
1282+ const store = useComposerDraftStore . getState ( ) ;
1283+ const currentPrompt = store . draftsByThreadId [ THREAD_ID ] ?. prompt ?? "" ;
1284+ const nextPrompt = removeInlineTerminalContextPlaceholder ( currentPrompt , 0 ) ;
1285+ store . setPrompt ( THREAD_ID , nextPrompt . prompt ) ;
1286+ store . removeTerminalContext ( THREAD_ID , "ctx-removed" ) ;
10871287
10881288 await vi . waitFor (
10891289 ( ) => {
@@ -1505,19 +1705,12 @@ describe("ChatView timeline estimator parity (full app)", () => {
15051705 } ) ;
15061706
15071707 try {
1508- const useMetaForMod = isMacPlatform ( navigator . platform ) ;
1509- window . dispatchEvent (
1510- new KeyboardEvent ( "keydown" , {
1511- key : "o" ,
1512- shiftKey : true ,
1513- metaKey : useMetaForMod ,
1514- ctrlKey : ! useMetaForMod ,
1515- bubbles : true ,
1516- cancelable : true ,
1517- } ) ,
1518- ) ;
1519-
1520- await waitForURL (
1708+ await waitForNewThreadShortcutLabel ( ) ;
1709+ await waitForServerConfigToApply ( ) ;
1710+ const composerEditor = await waitForComposerEditor ( ) ;
1711+ composerEditor . focus ( ) ;
1712+ await waitForLayout ( ) ;
1713+ await triggerChatNewShortcutUntilPath (
15211714 mounted . router ,
15221715 ( path ) => UUID_ROUTE_RE . test ( path ) ,
15231716 "Route should have changed to a new draft thread UUID from the shortcut." ,
@@ -1526,7 +1719,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
15261719 await mounted . cleanup ( ) ;
15271720 }
15281721 } ) ;
1529-
15301722 it ( "creates a fresh draft after the previous draft thread is promoted" , async ( ) => {
15311723 const mounted = await mountChatView ( {
15321724 viewport : DEFAULT_VIEWPORT ,
@@ -1561,6 +1753,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
15611753 try {
15621754 const newThreadButton = page . getByTestId ( "new-thread-button" ) ;
15631755 await expect . element ( newThreadButton ) . toBeInTheDocument ( ) ;
1756+ await waitForNewThreadShortcutLabel ( ) ;
1757+ await waitForServerConfigToApply ( ) ;
15641758 await newThreadButton . click ( ) ;
15651759
15661760 const promotedThreadPath = await waitForURL (
@@ -1574,19 +1768,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
15741768 syncServerReadModel ( addThreadToSnapshot ( fixture . snapshot , promotedThreadId ) ) ;
15751769 useComposerDraftStore . getState ( ) . clearDraftThread ( promotedThreadId ) ;
15761770
1577- const useMetaForMod = isMacPlatform ( navigator . platform ) ;
1578- window . dispatchEvent (
1579- new KeyboardEvent ( "keydown" , {
1580- key : "o" ,
1581- shiftKey : true ,
1582- metaKey : useMetaForMod ,
1583- ctrlKey : ! useMetaForMod ,
1584- bubbles : true ,
1585- cancelable : true ,
1586- } ) ,
1587- ) ;
1588-
1589- const freshThreadPath = await waitForURL (
1771+ const freshThreadPath = await triggerChatNewShortcutUntilPath (
15901772 mounted . router ,
15911773 ( path ) => UUID_ROUTE_RE . test ( path ) && path !== promotedThreadPath ,
15921774 "Shortcut should create a fresh draft instead of reusing the promoted thread." ,
0 commit comments