fix(studio/chat): cancel in-flight run when trashing a thread from sidebar (#5067)

Trashing a thread mid-stream used to delete the Dexie rows while the
model kept generating, because the sidebar has no access to the
@assistant-ui aui context. Expose per-thread cancelRun() through the
chat runtime store and call it from deleteChatItem so trash behaves
like Stop → Trash. Covers compare pairs by cancelling each paired
thread.

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
This commit is contained in:
Roland Tannous 2026-04-20 21:06:59 +04:00 committed by GitHub
commit 9954781d30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 68 additions and 9 deletions

View file

@ -58,23 +58,36 @@ export function useChatSidebarItems() {
return { items, canCompare };
}
function cancelIfRunning(threadId: string): void {
const { runningByThreadId, cancelByThreadId } =
useChatRuntimeStore.getState();
if (!runningByThreadId[threadId]) return;
cancelByThreadId[threadId]?.();
}
export async function deleteChatItem(
item: SidebarItem,
activeId: string | undefined,
onSelect: (view: { mode: "single"; newThreadNonce: string }) => void,
) {
const threadIds: string[] =
item.type === "single"
? [item.id]
: (await db.threads.where("pairId").equals(item.id).toArray()).map(
(t) => t.id,
);
// Stop any in-flight streams before deleting, so the model doesn't keep
// generating against a thread that no longer exists.
for (const id of threadIds) cancelIfRunning(id);
await db.transaction("rw", db.threads, db.messages, async () => {
if (item.type === "single") {
await db.messages.where("threadId").equals(item.id).delete();
await db.threads.delete(item.id);
} else {
const paired = await db.threads.where("pairId").equals(item.id).toArray();
for (const t of paired) {
await db.messages.where("threadId").equals(t.id).delete();
await db.threads.delete(t.id);
}
for (const id of threadIds) {
await db.messages.where("threadId").equals(id).delete();
await db.threads.delete(id);
}
});
if (activeId === item.id) {
useChatRuntimeStore.getState().setActiveThreadId(null);
onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() });

View file

@ -730,6 +730,34 @@ function ActiveThreadSync({
return null;
}
// Exposes the current thread's cancelRun() via the shared store so external
// surfaces (e.g. the sidebar trash button) can stop an in-flight stream
// before deleting the thread — mirroring the Stop → Trash sequence.
function CancelRegistrar(): ReactElement | null {
const aui = useAui();
const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId);
const isRunning = useChatRuntimeStore((s) =>
mainThreadId ? Boolean(s.runningByThreadId[mainThreadId]) : false,
);
useEffect(() => {
if (!mainThreadId || !isRunning) return;
const cancel = () => {
try {
aui.thread().cancelRun();
} catch {
// Run may have already ended between the caller's read and this call.
}
};
useChatRuntimeStore.getState().registerThreadCancel(mainThreadId, cancel);
return () => {
useChatRuntimeStore.getState().clearThreadCancel(mainThreadId);
};
}, [aui, mainThreadId, isRunning]);
return null;
}
export function ChatRuntimeProvider({
children,
modelType = "base",
@ -762,6 +790,7 @@ export function ChatRuntimeProvider({
<ActiveThreadSync
enabled={modelType === "base" && !pairId && !newThreadNonce && !initialThreadId}
/>
<CancelRegistrar />
{initialThreadId && (
<ThreadAutoSwitch
threadId={initialThreadId}

View file

@ -146,6 +146,7 @@ type ChatRuntimeStore = {
models: ChatModelSummary[];
loras: ChatLoraSummary[];
runningByThreadId: Record<string, boolean>;
cancelByThreadId: Record<string, () => void>;
autoTitle: boolean;
hfToken: string;
modelsError: string | null;
@ -189,6 +190,8 @@ type ChatRuntimeStore = {
setModels: (models: ChatModelSummary[]) => void;
setLoras: (loras: ChatLoraSummary[]) => void;
setThreadRunning: (threadId: string, running: boolean) => void;
registerThreadCancel: (threadId: string, cancel: () => void) => void;
clearThreadCancel: (threadId: string) => void;
setAutoTitle: (enabled: boolean) => void;
setHfToken: (token: string) => void;
setModelsError: (error: string | null) => void;
@ -218,6 +221,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
models: [],
loras: [],
runningByThreadId: {},
cancelByThreadId: {},
autoTitle: loadBool(AUTO_TITLE_KEY, false),
hfToken: loadString(HF_TOKEN_KEY, ""),
modelsError: null,
@ -277,6 +281,19 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
}
return { runningByThreadId: next };
}),
registerThreadCancel: (threadId, cancel) =>
set((state) => {
const next = { ...state.cancelByThreadId };
next[threadId] = cancel;
return { cancelByThreadId: next };
}),
clearThreadCancel: (threadId) =>
set((state) => {
if (!(threadId in state.cancelByThreadId)) return state;
const next = { ...state.cancelByThreadId };
delete next[threadId];
return { cancelByThreadId: next };
}),
setAutoTitle: (autoTitle) =>
set(() => {
saveBool(AUTO_TITLE_KEY, autoTitle);