From ff532da0fe2865a5af45d3920f6f0a05e5ca770c Mon Sep 17 00:00:00 2001 From: Jiawei Zhao Date: Tue, 1 Sep 2026 13:26:57 +0800 Subject: [PATCH 1/3] fix(desktop): skip archived session queries Automatic Skills and Plan refreshes could reach the Runtime Host after a session entered archival, producing avoidable session errors. CLOSES #4430 Signed-off-by: Jiawei Zhao Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 4 +- .../bootstrap-selection-lease.test.ts | 18 ++- .../session-navigation-controller.test.ts | 2 +- ...n-navigation-row-actions-revisions.test.ts | 86 +++++++++++- .../session-navigation-session-purge.test.ts | 1 + .../main/__tests__/session-query-gate.test.ts | 131 ++++++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 7 +- .../src/renderer/bootstrap-selection-lease.ts | 13 +- .../src/renderer/composer-mentions.tsx | 20 ++- .../controller/session-row-actions.ts | 52 +++++-- .../use-session-navigation-controller.ts | 7 +- apps/desktop/src/renderer/plan-mode-panel.tsx | 42 ++++-- .../src/renderer/session-catalog-state.ts | 29 ++++ 13 files changed, 369 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-query-gate.test.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 57999f4148..cde9343acb 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -854,7 +854,7 @@ "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, "usePlanModeState": 1, - "useRef": 25, + "useRef": 24, "useSessionCollaborationDialog": 1, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, @@ -985,7 +985,7 @@ "react": 1 }, "importSpecifiers": 186, - "nonTriviaTokens": 15725 + "nonTriviaTokens": 15723 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/bootstrap-selection-lease.test.ts b/apps/desktop/src/main/__tests__/bootstrap-selection-lease.test.ts index 3179171fa3..67a52cad94 100644 --- a/apps/desktop/src/main/__tests__/bootstrap-selection-lease.test.ts +++ b/apps/desktop/src/main/__tests__/bootstrap-selection-lease.test.ts @@ -30,10 +30,10 @@ import { writeNewTaskReloadDraft, } from '../../renderer/new-task-reload-intent.js'; -type Summary = { id: string; lastMessageAt?: number }; +type Summary = { id: string; lastMessageAt?: number; isArchived: boolean }; -function session(id: string, lastMessageAt?: number): Summary { - return { id, lastMessageAt }; +function session(id: string, lastMessageAt?: number, isArchived = false): Summary { + return { id, lastMessageAt, isArchived }; } function harness(activeId?: string) { @@ -88,6 +88,18 @@ describe('bootstrap selection lease', () => { assert.equal(state.activeId(), undefined); }); + for (const { name, initialActiveId, sessions, expected } of [ + { name: 'the freshest session is archived', initialActiveId: undefined, sessions: [session('archived', 2, true), session('active', 1)], expected: 'active' }, + { name: 'the bootstrap-owned selection is archived', initialActiveId: 'archived', sessions: [session('archived', 2, true), session('active', 1)], expected: 'active' }, + { name: 'every session is archived', initialActiveId: 'archived', sessions: [session('archived', 1, true)], expected: undefined }, + ] as const) { + it(`skips archived sessions when ${name}`, () => { + const state = harness(initialActiveId); + assert.equal(state.lease.reconcile(sessions), true); + assert.equal(state.activeId(), expected); + }); + } + it('does not reconcile after release', () => { const state = harness(); state.lease.release(); diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index 77ae054556..ee4e0c0fd6 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -108,7 +108,7 @@ function ports( return { activeIdRef: { current: activeSessionId }, sessionsRef: { current: sessions }, - pendingSessionRowActionsRef: { current: new Set() }, + acquireAutomaticQueryBlock: () => ({ release: () => undefined }), activateSession: (sessionId) => calls.push(`activate:${sessionId ?? 'none'}`), clearActiveMessages: () => calls.push('clear-messages'), clearSessionRendererState: (sessionId) => calls.push(`clear:${sessionId}`), diff --git a/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts index 7e7a59f9ef..11c6e004e4 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts @@ -90,14 +90,22 @@ describe('revision-family session row actions', () => { }); const branch = summary('branch', { parentSessionId: 'root', branchOfTurnId: 'turn-1' }); const activeIdRef = { current: 'root' as string | undefined }; + const service = createService(calls); const actions = createSessionNavigationRowActions({ uiLocale: 'en', activeIdRef, + acquireAutomaticQueryBlock: (ids) => { + calls.push(`acquire:${ids.join(',')}`); + return { release: () => calls.push('release') }; + }, clearActiveMessages: () => undefined, clearSessionRendererState: (id) => { cleared.push(id); }, pendingSessionRowActionsRef: { current: new Set() }, - refreshSessions: async () => [root, version, branch], - service: createService(calls), + refreshSessions: async () => { + calls.push('refresh'); + return [root, version, branch]; + }, + service, sessionsRef: { current: [root, version, branch] }, setActiveId: (id) => { selections.push(id); activeIdRef.current = id; }, toastApi: { @@ -115,17 +123,84 @@ describe('revision-family session row actions', () => { assert.deepEqual(calls, [ 'flag:version:true:true', + 'refresh', 'rename:branch:Independent branch:true', + 'refresh', + 'acquire:root,version', 'archive:version:true', + 'refresh', + 'release', // The delete asks the Host how many subtasks it would archive before the // confirm, then removes. 'preview:root', + 'acquire:root,version', // `root` is not archived, so the delete states no archived premise — // requiring one would refuse every delete from the rail. 'remove:root:true:false', + 'refresh', + 'release', ]); assert.deepEqual(selections, [undefined, undefined]); assert.deepEqual(cleared, ['root', 'version', 'root', 'version']); + + service.archive = async () => { throw new Error('archive failed'); }; + await actions.archiveSession('root'); + assert.deepEqual(calls.slice(-2), ['acquire:root,version', 'release']); + }); + + it('holds one query block through a bulk archive refresh', async () => { + const calls: string[] = []; + const root = summary('root'); + const version = summary('version', { + revisionRootSessionId: 'root', + revisionParentSessionId: 'root', + }); + const other = summary('other'); + let rejectRefresh = false; + const actions = createSessionNavigationRowActions({ + uiLocale: 'en', + activeIdRef: { current: undefined }, + acquireAutomaticQueryBlock: (ids) => { + calls.push(`acquire:${ids.join(',')}`); + return { release: () => calls.push('release') }; + }, + clearActiveMessages: () => undefined, + clearSessionRendererState: () => undefined, + pendingSessionRowActionsRef: { current: new Set() }, + refreshSessions: async () => { + calls.push('refresh'); + if (rejectRefresh) throw new Error('refresh failed'); + return []; + }, + service: createService(calls), + sessionsRef: { current: [root, version, other] }, + setActiveId: () => undefined, + toastApi: { + success: () => undefined, + error: () => undefined, + confirm: async () => true, + }, + }); + + await actions.archiveSelected(['version', 'other']); + + assert.deepEqual(calls, [ + 'acquire:root,version,other', + 'archive:version:true', + 'archive:other:true', + 'refresh', + 'release', + ]); + + calls.length = 0; + rejectRefresh = true; + await assert.rejects(actions.archiveSelected(['other']), /refresh failed/); + assert.deepEqual(calls, [ + 'acquire:other', + 'archive:other:true', + 'refresh', + 'release', + ]); }); }); @@ -138,9 +213,11 @@ function deleteHarness( const calls: string[] = []; const confirms: Array<{ title: string; description: string }> = []; const successes: Array<{ title: string; description?: string }> = []; + let leaseReleased = false; const actions = createSessionNavigationRowActions({ uiLocale: 'en', activeIdRef: { current: undefined }, + acquireAutomaticQueryBlock: () => ({ release: () => { leaseReleased = true; } }), clearActiveMessages: () => undefined, clearSessionRendererState: () => undefined, pendingSessionRowActionsRef: { current: new Set() }, @@ -154,7 +231,7 @@ function deleteHarness( confirm: async (options) => { confirms.push({ title: options.title, description: options.description }); return true; }, }, }); - return { actions, calls, confirms, successes }; + return { actions, calls, confirms, successes, wasLeaseReleased: () => leaseReleased }; } describe('delete confirm warns off the Host preview, toast reports the Host count', () => { @@ -219,7 +296,7 @@ describe('delete confirm warns off the Host preview, toast reports the Host coun }); it('stays silent on the toast when a concurrent restore calls the delete off', async () => { - const { actions, confirms, successes } = deleteHarness( + const { actions, confirms, successes, wasLeaseReleased } = deleteHarness( [summary('parent', { name: 'hi' })], 'restored', 0, @@ -232,5 +309,6 @@ describe('delete confirm warns off the Host preview, toast reports the Host coun assert.match(confirms[0].description, /kept and moved to Archived/); // But nothing was deleted, so nothing moved to the archive. assert.deepEqual(successes, [{ title: 'hi was restored, so it was kept', description: undefined }]); + assert.equal(wasLeaseReleased(), true); }); }); diff --git a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts index 83a5f7099b..0387541cf1 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts @@ -142,6 +142,7 @@ function createActions(input: { return createSessionNavigationRowActions({ uiLocale: 'en', activeIdRef: input.activeIdRef, + acquireAutomaticQueryBlock: () => ({ release: () => undefined }), clearActiveMessages: () => undefined, clearSessionRendererState: (id) => { input.harness.cleared.push(id); diff --git a/apps/desktop/src/main/__tests__/session-query-gate.test.ts b/apps/desktop/src/main/__tests__/session-query-gate.test.ts new file mode 100644 index 0000000000..3025c076b4 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-query-gate.test.ts @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { PlanSessionState } from '@maka/core/plan'; +import type { SessionSummary } from '@maka/core/session'; +import { LocaleProvider, ToastProvider } from '@maka/ui'; +import { act, createElement } from 'react'; +import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; +import { + ComposerMentionsProvider, + useComposerMentionsContext, +} from '../../renderer/composer-mentions.js'; +import { usePlanModeState } from '../../renderer/plan-mode-panel.js'; +import { createSessionCatalogController } from '../../renderer/session-catalog-state.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +test('query blocking pauses, resumes, and fences automatic Skills and Plan reads', async () => { + const { root } = installReactRenderer(); + const session = { id: 'session', isArchived: false } as SessionSummary; + const catalog = createSessionCatalogController(); + catalog.commitSessions([{ ...session, isArchived: true } as DesktopSessionSummary]); + const firstSkillQuery = deferred(); + const firstPlanQuery = deferred(); + const stalePlanState = { + schemaVersion: 1, + sessionId: session.id, + storeVersion: 1, + proposals: [], + executions: [], + } satisfies PlanSessionState; + const freshPlanState = { ...stalePlanState, storeVersion: 2 } satisfies PlanSessionState; + let skillQueries = 0; + let planQueries = 0; + let planState: PlanSessionState | undefined; + let skillsUnavailable = false; + + (globalThis.window as unknown as { maka: unknown }).maka = { + skills: { + listInvocable: async () => { + skillQueries += 1; + return skillQueries === 1 ? firstSkillQuery.promise : []; + }, + }, + sessions: { + getPlanState: async () => { + planQueries += 1; + return planQueries === 1 ? firstPlanQuery.promise : freshPlanState; + }, + subscribeChanges: () => () => undefined, + subscribeEvents: () => () => undefined, + subscribePlanChanges: () => () => undefined, + }, + mcp: { subscribeChanges: () => () => undefined }, + }; + + function QueryProbe() { + const plan = usePlanModeState(session, catalog); + planState = plan.state; + skillsUnavailable = useComposerMentionsContext()?.mentionSkillsUnavailable ?? false; + return null; + } + + await act(async () => { + root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(ToastProvider, { + children: createElement(ComposerMentionsProvider, { + skillCatalogRevision: 0, + sessionId: session.id, + automaticQueryGate: catalog, + children: createElement(QueryProbe), + }), + }), + })); + await Promise.resolve(); + }); + + assert.deepEqual([skillQueries, planQueries], [0, 0]); + + await act(async () => { + catalog.commitSessions([session as DesktopSessionSummary]); + await Promise.resolve(); + }); + + let lease!: ReturnType; + let overlappingLease!: ReturnType; + await act(async () => { + lease = catalog.acquireAutomaticQueryBlock([session.id]); + overlappingLease = catalog.acquireAutomaticQueryBlock([session.id]); + firstSkillQuery.reject(new Error('session archived')); + firstPlanQuery.resolve(stalePlanState); + await Promise.resolve(); + }); + + assert.equal(planState, undefined); + assert.equal(skillsUnavailable, false); + + await act(async () => { + lease.release(); + await Promise.resolve(); + }); + assert.deepEqual([skillQueries, planQueries], [1, 1]); + + await act(async () => { + overlappingLease.release(); + await Promise.resolve(); + }); + assert.deepEqual([skillQueries, planQueries], [2, 2]); + assert.deepEqual(planState, freshPlanState); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 84a2984fa1..0c0caeef24 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -360,6 +360,7 @@ function AppShellContent({ transcriptRangeRef, messageLoadPending, setMessageLoadPending, + sessionCatalogController, sessionUiController, } = useAppShellSessionWorkspace(toastApi); const activeCatalogSession = sessions.find((session) => session.id === activeId); @@ -1252,7 +1253,7 @@ function AppShellContent({ ? sessionSettingIntent.overlays.permissionMode[activeId] ?? activeBoundarySurface.permissionMode : activeBoundarySurface.permissionMode; - const planMode = usePlanModeState(sharedSessionActive ? undefined : activeSessionForView); + const planMode = usePlanModeState(sharedSessionActive ? undefined : activeSessionForView, sessionCatalogController); const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({ id: proposal.proposalId, afterTurnId: proposal.turnId, @@ -1619,6 +1620,7 @@ function AppShellContent({ const composerMentionsSurface: ComposerMentionsSurface = { skillCatalogRevision: moduleHub.selectors.skillCatalogRevision, sessionId: ownerActiveId, + automaticQueryGate: sessionCatalogController, projectPath: activeId ? ownerActiveId ? projectInfo?.projectPath @@ -1685,7 +1687,6 @@ function AppShellContent({ useLayoutEffect(() => { openSessionInChatRef.current = openSession; }, [openSession]); - const pendingSessionRowActionsRef = useRef(new Set()); const sessionNavigationCommandsRef = useRef(null); // Built inline: the rail reads these through a ref published on commit, so // their identity carries no information and this object never has to be @@ -1693,7 +1694,7 @@ function AppShellContent({ const sessionNavigationPorts: SessionNavigationPorts = { activeIdRef, sessionsRef, - pendingSessionRowActionsRef, + acquireAutomaticQueryBlock: sessionCatalogController.acquireAutomaticQueryBlock, activateSession: setActiveId, clearActiveMessages, clearSessionRendererState, diff --git a/apps/desktop/src/renderer/bootstrap-selection-lease.ts b/apps/desktop/src/renderer/bootstrap-selection-lease.ts index 3a1cacb766..b393b196fa 100644 --- a/apps/desktop/src/renderer/bootstrap-selection-lease.ts +++ b/apps/desktop/src/renderer/bootstrap-selection-lease.ts @@ -17,12 +17,14 @@ * under the License. */ -export interface BootstrapSelectionLease { +type BootstrapSelectionSummary = { id: string; lastMessageAt?: number; isArchived: boolean }; + +export interface BootstrapSelectionLease { reconcile(sessions: readonly Summary[]): boolean; release(): void; } -export function createBootstrapSelectionLease(options: { +export function createBootstrapSelectionLease(options: { readActiveId: () => string | undefined; readSelectionRevision: () => number; select: (sessionId: string | undefined) => void; @@ -37,11 +39,12 @@ export function createBootstrapSelectionLease !session.isArchived); const current = options.readActiveId(); - const next = current && sessions.some((session) => session.id === current) + const next = current && activeSessions.some((session) => session.id === current) ? current - : sessions[0]?.lastMessageAt - ? sessions[0].id + : activeSessions[0]?.lastMessageAt + ? activeSessions[0].id : undefined; if (next !== current) options.select(next); ownedRevision = options.readSelectionRevision(); diff --git a/apps/desktop/src/renderer/composer-mentions.tsx b/apps/desktop/src/renderer/composer-mentions.tsx index 4a4eeb62fc..97d8375eea 100644 --- a/apps/desktop/src/renderer/composer-mentions.tsx +++ b/apps/desktop/src/renderer/composer-mentions.tsx @@ -59,6 +59,10 @@ export interface ComposerMentionsSurface { /** Invalidates Runtime's invocable projection after installed Skills settle. */ skillCatalogRevision: number; sessionId?: string; + automaticQueryGate: { + subscribe(listener: () => void): () => void; + isAutomaticQueryBlocked(sessionId: string): boolean; + }; projectPath?: string; newSessionModel?: { llmConnectionSlug: string; model: string }; newSessionCollaborationMode?: 'agent' | 'plan'; @@ -78,6 +82,7 @@ function useComposerMentions(options: ComposerMentionsSurface): ComposerMentions const { projectPath, sessionId, + automaticQueryGate, skillCatalogRevision, newSessionModel, newSessionCollaborationMode, @@ -130,8 +135,11 @@ function useComposerMentions(options: ComposerMentionsSurface): ComposerMentions useEffect(() => { let cancelled = false; let requestVersion = 0; + const blocked = () => !!sessionId && automaticQueryGate.isAutomaticQueryBlocked(sessionId); + let queryBlocked = blocked(); const refresh = () => { const version = ++requestVersion; + if (queryBlocked) return; setCatalog((previous) => previous.contextKey === contextKey ? // A same-context refresh keeps both its settled verdict and the @@ -161,7 +169,7 @@ function useComposerMentions(options: ComposerMentionsSurface): ComposerMentions : Promise.resolve([]); void request.then( (next) => { - if (cancelled || version !== requestVersion) return; + if (cancelled || version !== requestVersion || queryBlocked) return; setCatalog((previous) => ({ contextKey, loading: false, @@ -179,12 +187,18 @@ function useComposerMentions(options: ComposerMentionsSurface): ComposerMentions () => { // Fail soft: an unavailable projection leaves `/` with no suggestions. // Direct `/skill:` input still reaches the same Runtime resolver. - if (cancelled || version !== requestVersion) return; + if (cancelled || version !== requestVersion || queryBlocked) return; setCatalog({ contextKey, loading: false, settled: 'empty', skills: EMPTY_SKILLS }); }, ); }; refresh(); + const unsubscribeQueryGate = automaticQueryGate.subscribe(() => { + const next = blocked(); + if (next === queryBlocked) return; + queryBlocked = next; + refresh(); + }); const unsubscribeSessions = window.maka.sessions.subscribeChanges((event) => { if ( sessionId && @@ -203,12 +217,14 @@ function useComposerMentions(options: ComposerMentionsSurface): ComposerMentions return () => { cancelled = true; requestVersion += 1; + unsubscribeQueryGate(); unsubscribeSessions(); unsubscribeContext(); }; }, [ projectPath, sessionId, + automaticQueryGate, skillCatalogRevision, newSessionModel?.llmConnectionSlug, newSessionModel?.model, diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts index 8303b9f2c1..4a8168db8d 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts @@ -117,6 +117,7 @@ export interface SessionNavigationRowActions { export function createSessionNavigationRowActions(deps: { uiLocale: UiLocale; activeIdRef: RefBox; + acquireAutomaticQueryBlock(sessionIds: readonly string[]): { release(): void }; clearActiveMessages: () => void; clearSessionRendererState: (sessionId: string) => void; pendingSessionRowActionsRef: RefBox>; @@ -129,6 +130,7 @@ export function createSessionNavigationRowActions(deps: { const { uiLocale, activeIdRef, + acquireAutomaticQueryBlock, clearActiveMessages, clearSessionRendererState, pendingSessionRowActionsRef, @@ -140,6 +142,19 @@ export function createSessionNavigationRowActions(deps: { } = deps; const copy = getShellCopy(uiLocale).sessionRowActions; + async function withAutomaticQueryBlock( + sessionId: string, + action: (familyIds: readonly string[]) => Promise, + ): Promise { + const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); + const lease = acquireAutomaticQueryBlock(familyIds); + try { + return await action(familyIds); + } finally { + lease.release(); + } + } + async function runSessionRowAction( sessionId: string, actionId: 'flag' | 'archive' | 'rename' | 'delete', @@ -173,14 +188,15 @@ export function createSessionNavigationRowActions(deps: { async function archiveSession(sessionId: string) { return runSessionRowAction(sessionId, 'archive', copy.archiveFailedTitle, async () => { - const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); - await service.archive(sessionId, { revisionFamily: true }); - if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { - setActiveId(undefined); - clearActiveMessages(); - } - for (const id of familyIds) clearSessionRendererState(id); - await refreshSessions(); + await withAutomaticQueryBlock(sessionId, async (familyIds) => { + await service.archive(sessionId, { revisionFamily: true }); + if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { + setActiveId(undefined); + clearActiveMessages(); + } + for (const id of familyIds) clearSessionRendererState(id); + await refreshSessions(); + }); }); } @@ -233,10 +249,16 @@ export function createSessionNavigationRowActions(deps: { if (!ok) return; // The confirm named an archived task, so a restore revokes it. An active // task has no such premise to lose. - const { disposition, archivedSubtaskCount } = await removeSessionFamily(sessionId, { - requireArchived: session?.isArchived === true, - }); - await refreshSessions(); + const { disposition, archivedSubtaskCount } = await withAutomaticQueryBlock( + sessionId, + async () => { + const outcome = await removeSessionFamily(sessionId, { + requireArchived: session?.isArchived === true, + }); + await refreshSessions(); + return outcome; + }, + ); // `restored` means nothing was deleted, so no subtask moved either. On a // real delete the count is the Host's executed number, not an estimate. if (disposition === 'restored') toastApi.success(copy.deleteRestoredTitle(name)); @@ -387,6 +409,10 @@ export function createSessionNavigationRowActions(deps: { * a run of them is what a sweep exists to avoid. */ async function archiveSessions(sessionIds: readonly string[]): Promise { + const familyIds = [ + ...new Set(sessionIds.flatMap((id) => revisionFamilySessionIds(sessionsRef.current, id))), + ]; + const lease = acquireAutomaticQueryBlock(familyIds); const failed: string[] = []; let firstFailure: SessionArchiveOutcome['firstFailure']; let archived = 0; @@ -419,7 +445,7 @@ export function createSessionNavigationRowActions(deps: { } // Once, after the whole sweep. Refreshing per task would re-render the rail // under the user's cursor for every id in the selection. - await refreshSessions(); + await refreshSessions().finally(() => lease.release()); return { archived, failed, firstFailure }; } diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts index d676fc0222..c34c21dcf1 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts @@ -75,7 +75,7 @@ type RefBox = { current: T }; export interface SessionNavigationPorts { activeIdRef: RefBox; sessionsRef: RefBox>; - pendingSessionRowActionsRef: RefBox>; + acquireAutomaticQueryBlock(sessionIds: readonly string[]): { release(): void }; activateSession(sessionId: string | undefined): void; clearActiveMessages(): void; clearSessionRendererState(sessionId: string): void; @@ -136,6 +136,7 @@ export function useSessionNavigationController( // be upstream of the rail, where a single ordinary `function` declaration // anywhere in the chain silently undoes the whole thing (#4109). const portsRef = useRef(ports); + const pendingSessionRowActionsRef = useRef(new Set()); useLayoutEffect(() => { portsRef.current = ports; }); @@ -145,10 +146,12 @@ export function useSessionNavigationController( createSessionNavigationRowActions({ uiLocale: locale, activeIdRef: portsRef.current.activeIdRef, + acquireAutomaticQueryBlock: (sessionIds) => + portsRef.current.acquireAutomaticQueryBlock(sessionIds), clearActiveMessages: () => portsRef.current.clearActiveMessages(), clearSessionRendererState: (sessionId) => portsRef.current.clearSessionRendererState(sessionId), - pendingSessionRowActionsRef: portsRef.current.pendingSessionRowActionsRef, + pendingSessionRowActionsRef, refreshSessions: () => portsRef.current.refreshSessions(), service, sessionsRef: portsRef.current.sessionsRef, diff --git a/apps/desktop/src/renderer/plan-mode-panel.tsx b/apps/desktop/src/renderer/plan-mode-panel.tsx index 37c5b3d850..84c8f2f795 100644 --- a/apps/desktop/src/renderer/plan-mode-panel.tsx +++ b/apps/desktop/src/renderer/plan-mode-panel.tsx @@ -36,7 +36,13 @@ export interface PlanModeState { abandon(executionId: string, title: string): Promise; } -export function usePlanModeState(session: SessionSummary | undefined): PlanModeState { +export function usePlanModeState( + session: SessionSummary | undefined, + automaticQueryGate: { + subscribe(listener: () => void): () => void; + isAutomaticQueryBlocked(sessionId: string): boolean; + }, +): PlanModeState { const toastApi = useToast(); const copy = getPlanModeCopy(useUiLocale()); const [state, setState] = useState(); @@ -55,17 +61,35 @@ export function usePlanModeState(session: SessionSummary | undefined): PlanModeS turnId: string; } | undefined>(undefined); - const refresh = useCallback(async () => { - if (!session) return; - setState(await window.maka.sessions.getPlanState(session.id)); - }, [session?.id]); + const refresh = useCallback(async (isCurrent: () => boolean = () => true) => { + if (!session || automaticQueryGate.isAutomaticQueryBlocked(session.id)) return; + const next = await window.maka.sessions.getPlanState(session.id); + if (isCurrent() && !automaticQueryGate.isAutomaticQueryBlocked(session.id)) setState(next); + }, [automaticQueryGate, session?.id]); useEffect(() => { setState(undefined); setError(undefined); - if (!session) return; - const refreshOrReport = () => void refresh().catch((cause) => setError(message(cause))); + if (!session) { + return; + } + let requestVersion = 0; + const blocked = () => automaticQueryGate.isAutomaticQueryBlocked(session.id); + let queryBlocked = blocked(); + const refreshOrReport = () => { + const version = ++requestVersion; + if (queryBlocked) return; + void refresh(() => version === requestVersion).catch((cause) => { + if (version === requestVersion && !queryBlocked) setError(message(cause)); + }); + }; refreshOrReport(); + const unsubscribeQueryGate = automaticQueryGate.subscribe(() => { + const next = blocked(); + if (next === queryBlocked) return; + queryBlocked = next; + refreshOrReport(); + }); const unsubscribeEvents = window.maka.sessions.subscribeEvents(session.id, (event: SessionEvent) => { if ( event.type === 'plan_submitted' @@ -80,10 +104,12 @@ export function usePlanModeState(session: SessionSummary | undefined): PlanModeS refreshOrReport, ); return () => { + requestVersion += 1; + unsubscribeQueryGate(); unsubscribeEvents(); unsubscribePlanChanges(); }; - }, [session?.id, session?.collaborationMode, refresh]); + }, [automaticQueryGate, session?.id, session?.collaborationMode]); const run = useCallback(async (action: () => Promise): Promise => { setPending(true); diff --git a/apps/desktop/src/renderer/session-catalog-state.ts b/apps/desktop/src/renderer/session-catalog-state.ts index 294a73beef..c49aa1c1f5 100644 --- a/apps/desktop/src/renderer/session-catalog-state.ts +++ b/apps/desktop/src/renderer/session-catalog-state.ts @@ -48,10 +48,39 @@ export function createSessionCatalogController() { revision: 0, activeSessionId: undefined, }); + const automaticQueryBlockCounts = new Map(); + const notifyAutomaticQueryGate = () => state.replaceState({ ...state.getState() }); return { getState: state.getState, subscribe: state.subscribe, + isAutomaticQueryBlocked(sessionId: string): boolean { + return ( + automaticQueryBlockCounts.has(sessionId) || + state.getState().sessions.some((session) => session.id === sessionId && session.isArchived) + ); + }, + acquireAutomaticQueryBlock(sessionIds: readonly string[]): { release(): void } { + const ids = [...new Set(sessionIds)]; + for (const id of ids) { + automaticQueryBlockCounts.set(id, (automaticQueryBlockCounts.get(id) ?? 0) + 1); + } + if (ids.length > 0) notifyAutomaticQueryGate(); + + let released = false; + return { + release(): void { + if (released) return; + released = true; + for (const id of ids) { + const count = automaticQueryBlockCounts.get(id) ?? 0; + if (count <= 1) automaticQueryBlockCounts.delete(id); + else automaticQueryBlockCounts.set(id, count - 1); + } + if (ids.length > 0) notifyAutomaticQueryGate(); + }, + }; + }, commitSessions(next: readonly DesktopSessionSummary[]): void { const current = state.getState(); state.replaceState({ ...current, sessions: next, revision: current.revision + 1 }); From b288b5299f11d3eba7b19a550a9a74b7f4ee633e Mon Sep 17 00:00:00 2001 From: Jiawei Zhao Date: Thu, 3 Sep 2026 10:08:17 +0800 Subject: [PATCH 2/3] test(desktop): stabilize code scroll drag The E2E could leave the viewport before anchoring a text selection, so scrolling succeeded while the selection stayed empty. Wait for the composer and anchor within the rendered line first. Signed-off-by: Jiawei Zhao Generated-by: OpenAI Codex --- apps/desktop/e2e/code-scroll.spec.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/desktop/e2e/code-scroll.spec.ts b/apps/desktop/e2e/code-scroll.spec.ts index cb73873c60..b20c71e208 100644 --- a/apps/desktop/e2e/code-scroll.spec.ts +++ b/apps/desktop/e2e/code-scroll.spec.ts @@ -39,6 +39,7 @@ test('a one-line Markdown code block exposes native and selection horizontal scr longLine, '```', ].join('\n')); + await expect(page.getByRole('button', { name: '发送', exact: true })).toBeEnabled(); await composer.press('Enter'); const codeBlocks = page.locator('.maka-markdown-code[data-maka-code-layout="single-line"]'); @@ -101,18 +102,22 @@ test('a one-line Markdown code block exposes native and selection horizontal scr window.getSelection()?.removeAllRanges(); }); const code = viewport.locator('code'); - const codeBox = await code.boundingBox(); - if (!codeBox) throw new Error('code line has no visible bounds'); - const textY = codeBox.y + Math.min(codeBox.height / 2, 18); - await page.mouse.move(codeBox.x + 24, textY); + const lineBox = await code.locator('[data-line="1"]').boundingBox(); + if (!lineBox) throw new Error('code line has no visible bounds'); + const textY = lineBox.y + lineBox.height / 2; + const textStartX = lineBox.x + 4; + await page.mouse.move(textStartX, textY); await page.mouse.down(); + // Establish the selection on text before leaving the viewport. Starting on + // padding can still auto-scroll the container without anchoring a range. + await page.mouse.move(textStartX + 120, textY, { steps: 5 }); + await expect.poll( + () => viewport.evaluate(() => window.getSelection()?.toString().length ?? 0), + ).toBeGreaterThan(10); await page.mouse.move(metrics.rect.x + metrics.rect.width + 50, textY, { steps: 20 }); await expect.poll( () => viewport.evaluate((element) => (element as HTMLElement).scrollLeft), ).toBeGreaterThan(0); - await expect.poll( - () => viewport.evaluate(() => window.getSelection()?.toString().length ?? 0), - ).toBeGreaterThan(10); const afterSelectionDrag = await viewport.evaluate((element) => ({ scrollLeft: (element as HTMLElement).scrollLeft, selection: window.getSelection()?.toString() ?? '', From b5de8d8c6b9a99bd0c3f1e80ec1aa2308a6376c3 Mon Sep 17 00:00:00 2001 From: Jiawei Zhao Date: Thu, 3 Sep 2026 10:40:25 +0800 Subject: [PATCH 3/3] test(desktop): request top history explicitly The scroll E2E recurred because moving to zero was treated as the request even though a settled scroller cannot move farther. Dispatching the upward wheel matches the production trigger and removes timing dependence on a scroll event. Signed-off-by: Jiawei Zhao Generated-by: OpenAI Codex --- apps/desktop/e2e/transcript-scroll.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index 8e599c683d..3ceda7552b 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -630,6 +630,7 @@ test('history asked for at the very top of the scroller still lands above the re const root = document.querySelector(selector); if (!root) throw new Error('the chat scroll container is missing'); root.scrollTop = 0; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); }, SCROLLER); await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore);