Skip to content

Commit 75d5df3

Browse files
fix(frontend): abort generation on navigate-away with confirmation dialog (#44)
When users navigate back to the plugin list during active generation, the stream now properly cancels via AbortController instead of silently running in the background with orphaned state. A confirmation dialog warns users before leaving. - Add AbortSignal to streamLLM fetch call - Store AbortController ref in useChat, abort on clearMessages/pluginId change - Guard backToPlugins with isLoading check and confirmation modal - Fix isLoading race: only the active controller clears loading state - Route dropdown "My Plugins" through same guard Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1267064 commit 75d5df3

3 files changed

Lines changed: 64 additions & 5 deletions

File tree

frontend/src/App.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ function App() {
3131
const [view, setView] = useState<View>("plugins");
3232
const [activePluginId, setActivePluginId] = useState<string | null>(null);
3333
const [activePluginName, setActivePluginName] = useState<string>("");
34+
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false);
3435

3536
const { messages, isLoading, sendMessage, buildTemplate, clearMessages } = useChat(runCode, activePluginId);
3637

@@ -70,13 +71,22 @@ function App() {
7071
setView("workspace");
7172
};
7273

73-
const backToPlugins = () => {
74+
const doBackToPlugins = () => {
75+
setShowLeaveConfirm(false);
7476
setActivePluginId(null);
7577
setActivePluginName("");
7678
clearMessages();
7779
setView("plugins");
7880
};
7981

82+
const backToPlugins = () => {
83+
if (isLoading) {
84+
setShowLeaveConfirm(true);
85+
return;
86+
}
87+
doBackToPlugins();
88+
};
89+
8090
// Auth loading
8191
if (authLoading) {
8292
return (
@@ -227,7 +237,7 @@ function App() {
227237
</button>
228238
{showMenu && (
229239
<div className="header-dropdown">
230-
<button onClick={() => { setView("plugins"); setShowMenu(false); }}>My Plugins</button>
240+
<button onClick={() => { backToPlugins(); setShowMenu(false); }}>My Plugins</button>
231241
<button onClick={() => { setView("settings"); setShowMenu(false); }}>Settings</button>
232242
{user?.uid && ADMIN_UIDS.has(user.uid) && <button onClick={() => { setView("admin"); setShowMenu(false); }}>Admin</button>}
233243
<button onClick={() => { logout(); setShowMenu(false); }}>Sign Out</button>
@@ -263,6 +273,28 @@ function App() {
263273
pluginName={activePluginName}
264274
/>
265275
</main>
276+
{showLeaveConfirm && (
277+
<div className="modal-overlay" onClick={() => setShowLeaveConfirm(false)}>
278+
<div className="modal" onClick={(e) => e.stopPropagation()}>
279+
<h3 className="modal-title">Generation in progress</h3>
280+
<p className="modal-subtitle">
281+
Your plugin is still being generated. Leaving now will cancel it.
282+
</p>
283+
<div className="modal-actions">
284+
<button className="modal-cancel" onClick={() => setShowLeaveConfirm(false)}>
285+
Stay
286+
</button>
287+
<button
288+
className="modal-confirm"
289+
style={{ background: "var(--error)" }}
290+
onClick={doBackToPlugins}
291+
>
292+
Leave
293+
</button>
294+
</div>
295+
</div>
296+
</div>
297+
)}
266298
</div>
267299
);
268300
}

frontend/src/api/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export async function* streamLLM(
3232
messages: { role: string; content: string }[] = [],
3333
template?: string,
3434
templateCode?: string,
35+
signal?: AbortSignal,
3536
): AsyncGenerator<GenerateEvent> {
3637
const body: Record<string, unknown> = { prompt, model, messages };
3738
if (template) body.template = template;
@@ -46,6 +47,7 @@ export async function* streamLLM(
4647
method: "POST",
4748
headers,
4849
body: JSON.stringify(body),
50+
signal,
4951
});
5052

5153
if (response.status === 429) {

frontend/src/hooks/useChat.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,15 @@ export function useChat(runCode: RunCodeFn, pluginId: string | null) {
8484
const messagesRef = useRef<ChatMessage[]>([]);
8585
messagesRef.current = messages;
8686
const templateUsedRef = useRef<string | null>(null);
87+
const abortControllerRef = useRef<AbortController | null>(null);
8788

8889
// Load existing messages when pluginId changes
8990
useEffect(() => {
9091
if (!pluginId) {
92+
abortControllerRef.current?.abort();
93+
abortControllerRef.current = null;
9194
setMessages([]);
95+
setIsLoading(false);
9296
setHistoryLoaded(false);
9397
templateUsedRef.current = null;
9498
return;
@@ -182,6 +186,10 @@ export function useChat(runCode: RunCodeFn, pluginId: string | null) {
182186
setMessages((prev) => [...prev, userMsg, assistantMsg]);
183187
setIsLoading(true);
184188

189+
abortControllerRef.current?.abort();
190+
const abortController = new AbortController();
191+
abortControllerRef.current = abortController;
192+
185193
// Save user message to Firestore
186194
if (pluginId) {
187195
saveMessage(pluginId, { role: "user", content: prompt }).catch((e) => console.warn("Failed to save user message:", e));
@@ -245,7 +253,7 @@ export function useChat(runCode: RunCodeFn, pluginId: string | null) {
245253
// Phase 1: Stream LLM response (accumulate but don't show in UI)
246254
let fullResponse = "";
247255
let streamError = false;
248-
for await (const event of streamLLM(currentPrompt, model, currentHistory, attempt === 0 ? template : undefined, attempt === 0 ? templateCode : undefined)) {
256+
for await (const event of streamLLM(currentPrompt, model, currentHistory, attempt === 0 ? template : undefined, attempt === 0 ? templateCode : undefined, abortController.signal)) {
249257
if (event.type === "chunk") {
250258
fullResponse += event.content || "";
251259
} else if (event.type === "error") {
@@ -424,6 +432,7 @@ export function useChat(runCode: RunCodeFn, pluginId: string | null) {
424432
}
425433
} // end retry loop
426434
} catch (err) {
435+
if (err instanceof DOMException && err.name === "AbortError") return;
427436
const isRateLimited = err instanceof RateLimitError;
428437
const errorMsg = err instanceof Error ? err.message : "Unknown error";
429438
setMessages((prev) =>
@@ -434,7 +443,10 @@ export function useChat(runCode: RunCodeFn, pluginId: string | null) {
434443
)
435444
);
436445
} finally {
437-
setIsLoading(false);
446+
if (abortControllerRef.current === abortController) {
447+
setIsLoading(false);
448+
abortControllerRef.current = null;
449+
}
438450
}
439451
},
440452
[runCode, pluginId]
@@ -447,6 +459,10 @@ export function useChat(runCode: RunCodeFn, pluginId: string | null) {
447459
setMessages((prev) => [...prev, userMsg, assistantMsg]);
448460
setIsLoading(true);
449461

462+
abortControllerRef.current?.abort();
463+
const abortController = new AbortController();
464+
abortControllerRef.current = abortController;
465+
450466
if (pluginId) {
451467
saveMessage(pluginId, { role: "user", content: userMsg.content }).catch((e) => console.warn("Failed to save user message:", e));
452468
}
@@ -477,6 +493,8 @@ export function useChat(runCode: RunCodeFn, pluginId: string | null) {
477493
const code = await fetchTemplateCode(templateName);
478494
const rewritten = rewriteSavePaths(code);
479495

496+
if (abortController.signal.aborted) return;
497+
480498
setMessages((prev) =>
481499
prev.map((m) => (m.id === assistantId ? { ...m, status: "running" as const } : m))
482500
);
@@ -566,19 +584,26 @@ export function useChat(runCode: RunCodeFn, pluginId: string | null) {
566584
}
567585
}
568586
} catch (err) {
587+
if (err instanceof DOMException && err.name === "AbortError") return;
569588
const errorMsg = err instanceof Error ? err.message : "Template build failed";
570589
setMessages((prev) =>
571590
prev.map((m) => (m.id === assistantId ? { ...m, error: errorMsg, status: "error" } : m))
572591
);
573592
} finally {
574-
setIsLoading(false);
593+
if (abortControllerRef.current === abortController) {
594+
setIsLoading(false);
595+
abortControllerRef.current = null;
596+
}
575597
}
576598
},
577599
[runCode, pluginId]
578600
);
579601

580602
const clearMessages = useCallback(() => {
603+
abortControllerRef.current?.abort();
604+
abortControllerRef.current = null;
581605
setMessages([]);
606+
setIsLoading(false);
582607
templateUsedRef.current = null;
583608
}, []);
584609

0 commit comments

Comments
 (0)