@@ -6,7 +6,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
66import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext" ;
77import { createEmptyDocument } from "@/lib/ai-edition/schema" ;
88import { useProjectStore } from "@/lib/ai-edition/store/projectStore" ;
9- import { clearHistory , undo } from "@/lib/ai-edition/store/undo" ;
9+ import { clearHistory , redo , undo } from "@/lib/ai-edition/store/undo" ;
1010import { past } from "@/lib/ai-edition/store/undoStack" ;
1111import type { AiEditionChatEvent , AiEditionChatResult } from "@/native/contracts" ;
1212import { ChatStripPanel } from "./LeftPanel" ;
@@ -19,6 +19,7 @@ const api = vi.hoisted(() => ({
1919 select : vi . fn ( ) ,
2020 create : vi . fn ( ) ,
2121 error : vi . fn ( ) ,
22+ warning : vi . fn ( ) ,
2223} ) ) ;
2324vi . mock ( "@/native/client" , ( ) => ( {
2425 nativeBridgeClient : {
@@ -41,7 +42,7 @@ vi.mock("@/contexts/I18nContext", () => ({
4142 useI18n : ( ) => ( { locale : "en" } ) ,
4243 useScopedT : ( ) => ( key : string ) => key ,
4344} ) ) ;
44- vi . mock ( "sonner" , ( ) => ( { toast : { error : api . error , warning : vi . fn ( ) } } ) ) ;
45+ vi . mock ( "sonner" , ( ) => ( { toast : { error : api . error , warning : api . warning } } ) ) ;
4546let onEvent : ( event : AiEditionChatEvent ) => void ;
4647let replies : ( ( value : AiEditionChatResult ) => void ) [ ] ;
4748const doc = ( title : string , projectId = "p" ) => createEmptyDocument ( { projectId, title } ) ;
@@ -125,27 +126,39 @@ function emit(
125126}
126127
127128describe ( "composer Stop" , ( ) => {
128- it ( "keeps edits and undo steps , retains partial reply, ignores old events and starts the next turn" , async ( ) => {
129+ it ( "commits stopped edits once , retains the live reply, ignores old events and starts the next turn" , async ( ) => {
129130 panel ( ) ;
130131 const id = await start ( ) ;
131132 expect ( screen . getByRole ( "button" , { name : "chat.stop" } ) ) . toBeEnabled ( ) ;
132133 emit ( id , { kind : "document" , document : doc ( "First" ) } ) ;
133134 emit ( id , { kind : "document" , document : doc ( "Second" ) } ) ;
134135 emit ( id , { kind : "text" , delta : "Partial" } ) ;
135- await waitFor ( ( ) => expect ( past ) . toHaveLength ( 2 ) ) ;
136+ await act ( async ( ) => {
137+ // Flush event work before checking that no intermediate edits were saved.
138+ } ) ;
139+ expect ( screen . getByText ( "Partial" ) ) . toBeVisible ( ) ;
140+ expect ( api . save ) . not . toHaveBeenCalled ( ) ;
141+ expect ( past ) . toHaveLength ( 0 ) ;
142+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Before" ) ;
136143 fireEvent . click ( screen . getByRole ( "button" , { name : "chat.stop" } ) ) ;
137144 fireEvent . click ( screen . getByRole ( "button" , { name : "chat.stop" } ) ) ;
138145 expect ( api . cancel ) . toHaveBeenCalledOnce ( ) ;
139146 expect ( api . cancel ) . toHaveBeenCalledWith ( "p" , "s" , id ) ;
140- await act ( async ( ) => replies [ 0 ] ( result ( "Partial" , true ) ) ) ;
147+ await act ( async ( ) => replies [ 0 ] ( { ... result ( "Partial" , true ) , document : doc ( "Second" ) } ) ) ;
141148 expect ( screen . getByText ( "chat.stopped" ) ) . toBeVisible ( ) ;
142149 expect ( screen . getByText ( "Partial" ) ) . toBeVisible ( ) ;
143150 expect ( screen . getByRole ( "button" , { name : "chat.send" } ) ) . toBeInTheDocument ( ) ;
144- expect ( api . save ) . toHaveBeenCalledTimes ( 2 ) ;
151+ expect ( api . save ) . toHaveBeenCalledOnce ( ) ;
152+ expect ( past ) . toHaveLength ( 1 ) ;
153+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Second" ) ;
145154 act ( ( ) => {
146155 undo ( ) ;
147156 } ) ;
148- expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "First" ) ;
157+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Before" ) ;
158+ act ( ( ) => {
159+ redo ( ) ;
160+ } ) ;
161+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Second" ) ;
149162 const nextId = await start ( ) ;
150163 expect ( nextId ) . not . toBe ( id ) ;
151164 emit ( id , { kind : "text" , delta : "STALE" } ) ;
@@ -157,6 +170,70 @@ describe("composer Stop", () => {
157170 expect ( api . error ) . not . toHaveBeenCalled ( ) ;
158171 } ) ;
159172
173+ it . each ( [ false , true ] ) ( "keeps a long turn in one undo step (stopped: %s)" , async ( stopped ) => {
174+ useProjectStore . getState ( ) . setDocument ( doc ( "Pre-turn" ) , { history : true } ) ;
175+ panel ( ) ;
176+ const id = await start ( ) ;
177+ for ( let i = 0 ; i < 60 ; i ++ ) {
178+ emit ( id , { kind : "document" , document : doc ( `Tool ${ i } ` ) } ) ;
179+ }
180+ await act ( async ( ) => {
181+ // Let any queued per-tool writes run so the regression cannot hide in microtasks.
182+ } ) ;
183+ expect ( api . save ) . not . toHaveBeenCalled ( ) ;
184+ expect ( past ) . toHaveLength ( 1 ) ;
185+ if ( stopped ) fireEvent . click ( screen . getByRole ( "button" , { name : "chat.stop" } ) ) ;
186+ await act ( async ( ) =>
187+ replies [ 0 ] ( { ...result ( "Done" , stopped ) , document : doc ( "Final aggregate" ) } ) ,
188+ ) ;
189+ expect ( api . save ) . toHaveBeenCalledOnce ( ) ;
190+ expect ( past ) . toHaveLength ( 2 ) ;
191+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Final aggregate" ) ;
192+ act ( ( ) => {
193+ undo ( ) ;
194+ } ) ;
195+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Pre-turn" ) ;
196+ act ( ( ) => {
197+ undo ( ) ;
198+ } ) ;
199+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Before" ) ;
200+ } ) ;
201+
202+ it ( "applies a stopped aggregate when no document event arrived" , async ( ) => {
203+ panel ( ) ;
204+ await start ( ) ;
205+ fireEvent . click ( screen . getByRole ( "button" , { name : "chat.stop" } ) ) ;
206+ await act ( async ( ) =>
207+ replies [ 0 ] ( { ...result ( "Partial" , true ) , document : doc ( "Completed edit" ) } ) ,
208+ ) ;
209+ expect ( api . save ) . toHaveBeenCalledOnce ( ) ;
210+ expect ( past ) . toHaveLength ( 1 ) ;
211+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Completed edit" ) ;
212+ act ( ( ) => {
213+ undo ( ) ;
214+ } ) ;
215+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Before" ) ;
216+ } ) ;
217+
218+ it ( "keeps manual edits during a turn until Apply anyway commits the aggregate once" , async ( ) => {
219+ panel ( ) ;
220+ const id = await start ( ) ;
221+ emit ( id , { kind : "document" , document : doc ( "First" ) } ) ;
222+ act ( ( ) => useProjectStore . getState ( ) . setDocument ( doc ( "Manual" ) , { history : true } ) ) ;
223+ emit ( id , { kind : "document" , document : doc ( "Second" ) } ) ;
224+ await act ( async ( ) => replies [ 0 ] ( { ...result ( "Done" ) , document : doc ( "Second" ) } ) ) ;
225+ expect ( api . save ) . not . toHaveBeenCalled ( ) ;
226+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Manual" ) ;
227+ expect ( api . warning ) . toHaveBeenCalledWith ( "chat.agentEditConflict" , expect . any ( Object ) ) ;
228+ await act ( async ( ) => api . warning . mock . calls [ 0 ] [ 1 ] . action . onClick ( ) ) ;
229+ await waitFor ( ( ) => expect ( api . save ) . toHaveBeenCalledOnce ( ) ) ;
230+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Second" ) ;
231+ act ( ( ) => {
232+ undo ( ) ;
233+ } ) ;
234+ expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Manual" ) ;
235+ } ) ;
236+
160237 it ( "stops while creating the first session without sending a model request" , async ( ) => {
161238 api . list . mockResolvedValue ( [ ] ) ;
162239 let created ! : ( value : ReturnType < typeof session > ) => void ;
@@ -184,11 +261,12 @@ describe("composer Stop", () => {
184261 act ( ( ) => useProjectStore . setState ( { projectId : "other" , document : doc ( "Other" , "other" ) } ) ) ;
185262 await waitFor ( ( ) => expect ( api . cancel ) . toHaveBeenCalledWith ( "p" , "s" , id ) ) ;
186263 await start ( ) ;
187- await act ( async ( ) => replies [ 0 ] ( result ( "Old reply" , true ) ) ) ;
264+ await act ( async ( ) => replies [ 0 ] ( { ... result ( "Old reply" , true ) , document : doc ( "Old" ) } ) ) ;
188265 emit ( id , { kind : "document" , document : doc ( "Old" ) } ) ;
189266 expect ( screen . queryByText ( "Old reply" ) ) . toBeNull ( ) ;
190267 expect ( screen . getByRole ( "button" , { name : "chat.stop" } ) ) . toBeEnabled ( ) ;
191268 expect ( useProjectStore . getState ( ) . document ?. project . title ) . toBe ( "Other" ) ;
269+ expect ( api . save ) . not . toHaveBeenCalled ( ) ;
192270 await act ( async ( ) => replies [ 1 ] ( result ( "New reply" ) ) ) ;
193271 } ) ;
194272
@@ -197,14 +275,16 @@ describe("composer Stop", () => {
197275 const id = await start ( ) ;
198276 fireEvent . click ( screen . getByRole ( "button" , { name : "chat.newConversation" } ) ) ;
199277 await waitFor ( ( ) => expect ( api . cancel ) . toHaveBeenCalledWith ( "p" , "s" , id ) ) ;
200- await act ( async ( ) => replies [ 0 ] ( result ( "Old" , true ) ) ) ;
278+ await act ( async ( ) => replies [ 0 ] ( { ... result ( "Old" , true ) , document : doc ( "Old" ) } ) ) ;
201279 expect ( screen . queryByText ( "Old" ) ) . toBeNull ( ) ;
280+ expect ( api . save ) . not . toHaveBeenCalled ( ) ;
202281 fireEvent . change ( screen . getByRole ( "textbox" ) , { target : { value : "Next" } } ) ;
203282 fireEvent . keyDown ( screen . getByRole ( "textbox" ) , { key : "Enter" } ) ;
204283 await waitFor ( ( ) => expect ( api . run ) . toHaveBeenCalledTimes ( 2 ) ) ;
205284 const nextId = api . run . mock . calls [ 1 ] [ 4 ] ;
206285 view . unmount ( ) ;
207286 expect ( api . cancel ) . toHaveBeenLastCalledWith ( "p" , "new" , nextId ) ;
208- await act ( async ( ) => replies [ 1 ] ( result ( "Disposed" , true ) ) ) ;
287+ await act ( async ( ) => replies [ 1 ] ( { ...result ( "Disposed" , true ) , document : doc ( "Disposed" ) } ) ) ;
288+ expect ( api . save ) . not . toHaveBeenCalled ( ) ;
209289 } ) ;
210290} ) ;
0 commit comments