From 8df0ef4d2043290815d5a28814541b6428f1f921 Mon Sep 17 00:00:00 2001 From: stantheman0128 Date: Sat, 18 Jul 2026 08:24:39 +0800 Subject: [PATCH 1/5] fix(desktop): hold create guard until route catches new session (#66057) Stop setTimeout(0) from clearing creatingSessionRef before HashRouter lands on the created stored id, and ignore stale-route self-heal when selection already moved to the new runtime. Prevents resumeSession(A) jump-back after the first send on a new chat. Credit: Stan Shih (stantheman0128); AI Cursor/Grok Co-authored-by: Cursor --- apps/desktop/src/app/contrib/wiring.tsx | 1 + .../session/hooks/use-route-resume.test.tsx | 85 +++++++++++++ .../src/app/session/hooks/use-route-resume.ts | 14 ++- .../hooks/use-session-actions.test.tsx | 84 +++++++++++++ .../hooks/use-session-actions/index.ts | 114 ++++++++++++------ 5 files changed, 260 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index ffdc79e65b751..8c8aedef27ac8 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -445,6 +445,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { onFreshDraftRouteIntent: clearRoutedSessionIntent, requestGateway, resetViewSync, + routedSessionId, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, selectedStoredSessionIdRef, diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx index ae7055ff22ad7..2aa18c101cf35 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx @@ -36,6 +36,91 @@ describe('useRouteResume', () => { vi.restoreAllMocks() }) + it('does not resume stale routed session A when refs already moved to new session B (#66057)', () => { + // createBackendSessionForSend updates refs/atoms to B and navigates, but the + // router can still report A for a tick. stuckOnRoutedSession must NOT treat + // that as "stranded on A" and call resumeSession(A) (jump-back bug). + const resumeSession = vi.fn(async () => undefined) + const startFreshSessionDraft = vi.fn() + const activeSessionIdRef: MutableRefObject = { current: 'runtime-B' } + const creatingSessionRef = { current: false } + const runtimeIdByStoredSessionIdRef = { current: new Map([['session-B', 'runtime-B']]) } + const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-B' } + + const { rerender } = render( + + ) + + expect(resumeSession).not.toHaveBeenCalled() + + // Simulate post-create: refs/atoms already on B, route still on A, guard down + // (the old setTimeout(0) clear), freshDraftReady false. + rerender( + + ) + + expect(resumeSession).not.toHaveBeenCalled() + }) + + it('holds the create guard until the route catches up to the created session (#66057)', () => { + // While creatingSessionRef is true, even the stale-route + moved-selection + // shape must not resume. (Belt + guard: selectionMovedAheadOfRoute alone + // also blocks; this asserts the creatingSessionRef gate still works.) + const resumeSession = vi.fn(async () => undefined) + const startFreshSessionDraft = vi.fn() + const creatingSessionRef = { current: true } + const activeSessionIdRef: MutableRefObject = { current: 'runtime-B' } + const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-B' } + + render( + + ) + + expect(resumeSession).not.toHaveBeenCalled() + }) + it('does not re-resume the old session during a /:sid -> /new transition', () => { const resumeSession = vi.fn(async () => undefined) const startFreshSessionDraft = vi.fn() diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts index ed12a91da4210..8873d8a6e48d1 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.ts +++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts @@ -135,7 +135,19 @@ export function useRouteResume({ // pathname flips to / (same null+/:sid signature). freshDraftReady is the // discriminator: it's true while heading into a blank new chat, false when // genuinely stranded on a routed session. - const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady + // + // Also must NOT fire when create/fork already moved selection + runtime to + // a new session B while the router still shows stale A (#66057). That looks + // "stuck on A" but resuming A yanks the UI back off the new chat. + const selectionMovedAheadOfRoute = + Boolean(selectedStoredSessionIdRef.current) && + selectedStoredSessionIdRef.current !== routedSessionId && + Boolean(activeSessionIdRef.current) + + const stuckOnRoutedSession = + routedSessionId !== selectedStoredSessionIdRef.current && + !freshDraftReady && + !selectionMovedAheadOfRoute // Resume when the route meaningfully changed, the gateway just opened, or // we're stranded on a routed session that never loaded. The first two diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 603b45a1a2bda..dd44c98ad4a2b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -113,6 +113,7 @@ function Harness({ navigate: navigate as never, requestGateway, resetViewSync: vi.fn(), + routedSessionId: null, runtimeIdByStoredSessionIdRef: ref(new Map()), selectedStoredSessionId: null, selectedStoredSessionIdRef: ref(null), @@ -591,6 +592,7 @@ function ResumeHarness({ navigate: vi.fn() as never, requestGateway, resetViewSync: vi.fn(), + routedSessionId: null, runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRef ?? ref(new Map()), selectedStoredSessionId, selectedStoredSessionIdRef: ref(selectedStoredSessionId), @@ -998,6 +1000,7 @@ function BranchHarness({ navigate: navigate as never, requestGateway, resetViewSync: vi.fn(), + routedSessionId: null, runtimeIdByStoredSessionIdRef: ref(new Map()), selectedStoredSessionId: null, selectedStoredSessionIdRef: ref(null), @@ -1535,3 +1538,84 @@ describe('createBackendSessionForSend workspace target', () => { expect(params).toMatchObject({ cwd: '/clicked-workspace' }) }) }) + +describe('createBackendSessionForSend creatingSessionRef hold (#66057)', () => { + afterEach(() => { + cleanup() + $newChatProfile.set(null) + $activeGatewayProfile.set('default') + setCurrentCwd('') + setNewChatWorkspaceTarget(undefined) + vi.restoreAllMocks() + }) + + it('keeps creatingSessionRef true until routedSessionId catches up to the created stored id', async () => { + const creatingSessionRef: MutableRefObject = { current: false } + const selectedStoredSessionIdRef: MutableRefObject = { current: null } + const navigate = vi.fn() + let routedSessionId: null | string = null + + function GuardHarness({ + onReady, + routeId + }: { + onReady: (create: () => Promise) => void + routeId: null | string + }) { + const ref = (value: T): MutableRefObject => ({ current: value }) + const actions = useSessionActions({ + activeSessionId: null, + activeSessionIdRef: ref(null), + busyRef: ref(false), + creatingSessionRef, + ensureSessionState: () => ({}) as ClientSessionState, + getRouteToken: () => 'token', + navigate: navigate as never, + requestGateway: async method => { + if (method === 'session.create') { + return { session_id: 'rt-new', stored_session_id: 'stored-new' } as never + } + + return {} as never + }, + resetViewSync: vi.fn(), + routedSessionId: routeId, + runtimeIdByStoredSessionIdRef: ref(new Map()), + selectedStoredSessionId: selectedStoredSessionIdRef.current, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef: ref(new Map()), + syncSessionStateToView: vi.fn(), + updateSessionState: () => ({}) as ClientSessionState + }) + + useEffect(() => { + onReady(() => actions.createBackendSessionForSend()) + }, [actions, onReady]) + + return null + } + + let create: (() => Promise) | null = null + const { rerender } = render( + (create = fn)} routeId={routedSessionId} /> + ) + await waitFor(() => expect(create).not.toBeNull()) + + await act(async () => { + await create!() + }) + + expect(navigate).toHaveBeenCalled() + expect(creatingSessionRef.current).toBe(true) + expect(selectedStoredSessionIdRef.current).toBe('stored-new') + + // Route still stale — guard must stay up. + rerender( undefined} routeId={null} />) + expect(creatingSessionRef.current).toBe(true) + + // Router catches up to the created stored id — release the guard. + routedSessionId = 'stored-new' + rerender( undefined} routeId={routedSessionId} />) + expect(creatingSessionRef.current).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index b0202e722a4db..1f7267d02f6d3 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -103,6 +103,10 @@ interface SessionActionsOptions { onFreshDraftRouteIntent?: () => void requestGateway: (method: string, params?: Record) => Promise resetViewSync: () => void + // Live route session id from the router. Used to drop creatingSessionRef only + // after navigate to a freshly created/forked stored id has actually landed + // (setTimeout(0) cleared the guard before the route caught up ??#66057). + routedSessionId: string | null runtimeIdByStoredSessionIdRef: MutableRefObject> selectedStoredSessionId: string | null selectedStoredSessionIdRef: MutableRefObject @@ -116,10 +120,10 @@ interface SessionActionsOptions { } // Stored ids created in THIS renderer run. A brand-new session lives only in the -// gateway's in-memory map until its first turn persists a state.db row — so if a +// gateway's in-memory map until its first turn persists a state.db row ??so if a // respawning/flapping backend drops it, both resume RPC and the REST transcript // 404 even though the user just made it. We must NOT treat that as "gone" (which -// yanks them to a fresh draft — the "new sessions clear themselves" bug); the +// yanks them to a fresh draft ??the "new sessions clear themselves" bug); the // bounded retry rebinds it when the backend returns. Boot-into-a-stale-last-id // (NOT in this set) still legitimately drops to a draft. const createdThisRun = new Set() @@ -153,10 +157,10 @@ function reconcileAuthoritativeMessages( // // Resolving null profile to the active gateway's is load-bearing: in global-remote // mode one backend serves every profile, so an omitted profile silently lands the -// chat on the launch (default) profile — the "rubberbands back to default" bug. +// chat on the launch (default) profile ??the "rubberbands back to default" bug. // A no-op for single-profile/local-pooled users (a backend resolves its own launch // profile to None). The sticky UI model/effort/fast ride as per-session overrides, -// never the profile default (that lives in Settings → Model). +// never the profile default (that lives in Settings ??Model). async function desktopSessionCreateParams(cwd: string): Promise> { // Treat Send as the linearization point for the visible selector state. The // profile handshake below can yield long enough for background config/model @@ -207,6 +211,7 @@ export function useSessionActions({ onFreshDraftRouteIntent, requestGateway, resetViewSync, + routedSessionId, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, selectedStoredSessionIdRef, @@ -217,11 +222,40 @@ export function useSessionActions({ const { t } = useI18n() const copy = t.desktop const resumeRequestRef = useRef(0) + // Stored id we just created/forked and navigated to. creatingSessionRef stays + // true until routedSessionId + selection both agree on this id ??clearing via + // setTimeout(0) let use-route-resume resume the stale route as "stuck" (#66057). + const pendingCreatedStoredSessionIdRef = useRef(null) + + const releaseCreatingSessionGuard = useCallback(() => { + pendingCreatedStoredSessionIdRef.current = null + creatingSessionRef.current = false + }, [creatingSessionRef]) + + // Drop the create/fork guard only once the router has caught up to the session + // we already selected. Refs/atoms lead; HashRouter can lag a tick. + useEffect(() => { + const pending = pendingCreatedStoredSessionIdRef.current + + if (!creatingSessionRef.current || !pending) { + return + } + + if (routedSessionId === pending && selectedStoredSessionIdRef.current === pending) { + releaseCreatingSessionGuard() + } + }, [ + creatingSessionRef, + releaseCreatingSessionGuard, + routedSessionId, + selectedStoredSessionId, + selectedStoredSessionIdRef + ]) // Follow auto-compression's stored-id rotation only while the exact runtime, // selection, and route intent still belong to the rotating conversation. // The previous implementation carried only the next stored id and navigated - // unconditionally; a fast A → B → C switch could therefore be overwritten + // unconditionally; a fast A ??B ??C switch could therefore be overwritten // by A's delayed session.info event and visibly jump back to A. const storedIdRotation = useStore($activeSessionStoredIdRotation) @@ -250,7 +284,7 @@ export function useSessionActions({ // ChatBar scopes composer state on resolveComposerSessionKey(); migrating // onto the tip while the composer is still bound to the root can lose newer // live editor text on a brief remount. If the new tip row is not in - // $sessions yet, resolveComposerSessionKey falls back to the tip id — prefer + // $sessions yet, resolveComposerSessionKey falls back to the tip id ??prefer // the previous id (usually the lineage root) in that gap. const previousId = storedIdRotation.previousStoredSessionId const nextId = storedIdRotation.nextStoredSessionId @@ -321,7 +355,7 @@ export function useSessionActions({ setSessionStartedAt(null) setTurnStartedAt(null) // The composer's model/effort/fast is sticky UI state (persisted in - // localStorage) — a new chat FOLLOWS your last pick instead of snapping + // localStorage) ??a new chat FOLLOWS your last pick instead of snapping // back to the profile default, so we deliberately don't reset it here. The // profile default still owns first-run seeding and profile switches (see // refreshCurrentModel). Only $currentServiceTier (a live-session mirror) @@ -331,7 +365,7 @@ export function useSessionActions({ setNewChatWorkspaceTarget(hasWorkspaceTarget ? workspaceTarget : undefined) if (!hasWorkspaceTarget) { - // In a project → the repo's default-branch checkout; not in a project → + // In a project ??the repo's default-branch checkout; not in a project ?? // detached. So cmd-n does not inherit an unrelated linked worktree. setCurrentCwd(resolveNewSessionCwd()) } else if (workspaceTarget === null) { @@ -341,7 +375,7 @@ export function useSessionActions({ } setCurrentBranch('') - // Never clear the composer here — ChatBar's per-thread draft swap owns it. + // Never clear the composer here ??ChatBar's per-thread draft swap owns it. setFreshDraftReady(true) }, [activeSessionIdRef, busyRef, navigate, onFreshDraftRouteIntent, resetViewSync, selectedStoredSessionIdRef] @@ -355,9 +389,9 @@ export function useSessionActions({ creatingSessionRef.current = true try { - // An explicit one-shot workspace target (null → detached, string → that + // An explicit one-shot workspace target (null ??detached, string ??that // folder) wins; otherwise the live cwd, then the project-aware default - // (resolveNewSessionCwd — a project's new session keeps its repo cwd). + // (resolveNewSessionCwd ??a project's new session keeps its repo cwd). const workspaceTarget = $newChatWorkspaceTarget.get() const cwd = @@ -375,7 +409,7 @@ export function useSessionActions({ // session we just minted. The active runtime ref is deliberately not a // prong: background gateway events retarget it while other sessions // stream (#47709 class), and the seconds-long session.create round-trip - // (server-side agent + MCP init) makes that churn near-certain — every + // (server-side agent + MCP init) makes that churn near-certain ??every // genuine user switch retargets selection AND route synchronously // anyway. submitTargetStoredId is the just-created stored session, so // our own upcoming re-home onto it never reads as drift. @@ -406,6 +440,10 @@ export function useSessionActions({ // "Untitled session" until the turn persists and auto-title runs. The // server later returns its own preview/title and supersedes this. upsertOptimisticSession(created, stored, null, preview?.trim() || null) + // Hold creatingSessionRef until the route lands on `stored` (release + // effect above). setTimeout(0) raced use-route-resume back onto the + // previous session (#66057). + pendingCreatedStoredSessionIdRef.current = stored navigate(sessionRoute(stored), { replace: true }) // Other windows (e.g. the main window when this is the pop-out) can't // see this session until they re-pull the shared list. @@ -425,16 +463,18 @@ export function useSessionActions({ } // User may have armed YOLO on the new-chat draft before the runtime - // session existed — apply it to the freshly created session. + // session existed ??apply it to the freshly created session. if (yoloArmed) { await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined) } return created.session_id } finally { - window.setTimeout(() => { + // Keep the guard up while a navigate to the new stored id is pending; + // otherwise clear immediately (abort, error, or create without stored id). + if (!pendingCreatedStoredSessionIdRef.current) { creatingSessionRef.current = false - }, 0) + } } }, [ @@ -465,12 +505,12 @@ export function useSessionActions({ [navigate, startFreshSessionDraft] ) - /** Create a fresh session and open it as a tile — leaves the primary chat alone. + /** Create a fresh session and open it as a tile ??leaves the primary chat alone. * Used by the New session row's "Open in split" menu and the tab-strip "+". * * `listed` (default true) controls sidebar visibility. A brand-new backend * session is IN-MEMORY only until its first turn persists a row, so - * `listSessions(min_messages=1)` already hides an unused one — the sidebar + * `listSessions(min_messages=1)` already hides an unused one ??the sidebar * pollution comes solely from the optimistic upsert here. The tab-strip "+" * passes `listed: false` so an unused new tab never clutters the session * list (Cursor-style draft tab); it surfaces on the next refresh once the @@ -497,7 +537,7 @@ export function useSessionActions({ createdThisRun.add(stored) // Seed the per-runtime cache so the tile renders immediately without a - // redundant resume. Only add the row to the SIDEBAR when `listed` — an + // redundant resume. Only add the row to the SIDEBAR when `listed` ??an // unlisted (draft) tab stays out of the session list until its first // turn persists and a refresh surfaces it. if (listed) { @@ -549,7 +589,7 @@ export function useSessionActions({ // so there's zero dead air: highlight the row instantly (the sidebar reads // $selectedStoredSessionId) and, for a cold target, drop the previous // transcript so the thread shows its loader instead of the old session - // lingering until resume lands. A warm-cached target keeps its transcript — + // lingering until resume lands. A warm-cached target keeps its transcript ?? // the cached fast-path repaints it this same tick. Setting the ref here is // also what use-route-resume's self-heal assumes ("set synchronously at // resume entry"). @@ -572,7 +612,7 @@ export function useSessionActions({ // session being resumed. A pooled profile backend that gets idle-reaped // and respawned (pruneSecondaryGateways) re-mints runtime ids, so a // recycled id can resolve to a live-but-DIFFERENT session's cache entry. - // The session.activate 404 guard below only catches a fully-DEAD id — a + // The session.activate 404 guard below only catches a fully-DEAD id ??a // recycled-live id 200s, so an unchecked hit paints the wrong transcript // under the current route (the "open chat A, chat B loads" bug). On a // mismatch the mapping is cross-wired: purge both sides and report a miss @@ -796,8 +836,8 @@ export function useSessionActions({ activeSessionIdRef.current = null // A warm-cache hit at entry skipped the cold-path transcript clear, but the - // warm path can still bail down to here — an empty-transcript drop, or the - // cache getting purged during the profile-swap await — so the PREVIOUS + // warm path can still bail down to here ??an empty-transcript drop, or the + // cache getting purged during the profile-swap await ??so the PREVIOUS // session's transcript would leak into this cold resume ("switching // sessions shows the same messages"). Clear it so the loader/prefetch // paints fresh; guarded so the normal cold path (already cleared) no-ops. @@ -840,10 +880,10 @@ export function useSessionActions({ let prefetchedStoredSessionId: string | null = null // REST transcript prefetch and the gateway resume RPC are independent - // — run them concurrently so a big session's wall time is + // ??run them concurrently so a big session's wall time is // max(prefetch, resume) instead of their sum. The prefetch paints the // transcript as soon as it lands; the RPC binds the runtime id. - // Watch windows skip the prefetch — lazy resume attaches the live mirror. + // Watch windows skip the prefetch ??lazy resume attaches the live mirror. const prefetchPromise = watchWindow ? null : getSessionMessages(storedSessionId, sessionProfile) const resumePromise = requestGateway('session.resume', { @@ -896,7 +936,7 @@ export function useSessionActions({ // Keep the local snapshot when resume would only reshuffle runtime // projection. When the REST prefetch already hydrated the transcript, - // skip converting/reconciling the resume payload entirely — on a + // skip converting/reconciling the resume payload entirely ??on a // 1000+-message session that second conversion plus the deep // equivalence compare costs over a second of main-thread time. const resumedStoredSessionId = resumed.session_key || resumed.resumed @@ -1000,11 +1040,11 @@ export function useSessionActions({ // The gateway resume RPC failed. Try the REST transcript as a fallback // so the window at least shows history. CRITICAL: this fallback must be - // wrapped in its own try — if it ALSO throws (wedged/unreachable backend, + // wrapped in its own try ??if it ALSO throws (wedged/unreachable backend, // the common case when resume failed in the first place), an unguarded // throw here skips setMessages AND leaves activeSessionId null with an // empty transcript. That is the exact state the thread loader latches on - // forever (messagesEmpty && !activeSessionId) with no recovery path — + // forever (messagesEmpty && !activeSessionId) with no recovery path ?? // the "open in new window stays stuck loading, even after a nap" bug. let fallbackError: unknown = null @@ -1041,12 +1081,12 @@ export function useSessionActions({ // The session is genuinely gone (deleted, or a stale id from a wiped / // rotated backend): the resume RPC and the authoritative REST transcript - // both 404. There's nothing to recover — silently drop to a fresh draft + // both 404. There's nothing to recover ??silently drop to a fresh draft // instead of toasting an error and hot-looping the bounded retry on a // permanently-dead id. (Booting straight into a no-longer-existent // last-session id is the common trigger.) if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) { - // A session created THIS run isn't gone — its backend just flapped + // A session created THIS run isn't gone ??its backend just flapped // before the turn-less session persisted. Keep the empty view and arm // the bounded retry to rebind, rather than yanking to a fresh draft. // Only a stale id from a PRIOR run drops to a draft. @@ -1067,7 +1107,7 @@ export function useSessionActions({ // That is the exact stranded state the loader latches on // (messagesEmpty && !activeSessionId), and matches $resumeFailedSessionId's // documented contract. If the REST fallback DID paint history, the - // window is readable — arming here would needlessly auto-retry and, + // window is readable ??arming here would needlessly auto-retry and, // once retries exhaust, blank that visible transcript behind the // exhausted-state error overlay (a regression vs. plain fallback success). setResumeFailedSessionId(storedSessionId) @@ -1099,7 +1139,7 @@ export function useSessionActions({ // Shared fork: create a child session seeded with `branchMessages`, linked to // `parentStoredId` so it nests under its parent, then open it as its own tab - // and switch to it — the parent chat stays put (mirrors openNewSessionTile). + // and switch to it ??the parent chat stays put (mirrors openNewSessionTile). const forkBranch = useCallback( async ( branchMessages: BranchMessage[], @@ -1172,7 +1212,7 @@ export function useSessionActions({ // Open the branch as its own tab and switch to it, leaving the parent // chat exactly where it is. Prime the tile with the create runtime so it // skips a redundant resume. Do NOT select it as the primary session - // first — openSessionTile no-ops when the id is already primary. + // first ??openSessionTile no-ops when the id is already primary. openSessionTile(routedSessionId, 'center') patchSessionTile(routedSessionId, { runtimeId: branched.session_id }) revealTreePane(`session-tile:${routedSessionId}`) @@ -1184,15 +1224,15 @@ export function useSessionActions({ return false } finally { - window.setTimeout(() => { + if (!pendingCreatedStoredSessionIdRef.current) { creatingSessionRef.current = false - }, 0) + } } }, [copy, creatingSessionRef, ensureSessionState, requestGateway, updateSessionState] ) - // Branch the open chat — optionally from a specific message — off its live transcript. + // Branch the open chat ??optionally from a specific message ??off its live transcript. const branchCurrentSession = useCallback( async (messageId?: string): Promise => { if (!activeSessionIdRef.current) { @@ -1243,7 +1283,7 @@ export function useSessionActions({ clearNotifications() // Right-clicking a session outside the paginated sidebar window is a cache - // miss: resolve it (cache → active backend → cross-profile) so the branch + // miss: resolve it (cache ??active backend ??cross-profile) so the branch // is created on the parent's OWNING profile, not whichever is live (#67603). const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? @@ -1358,7 +1398,7 @@ export function useSessionActions({ notifyError(err, copy.deleteFailed) } finally { // Release the tombstone to the normal projects.tree prune now the RPC has - // settled (kept on success — the backend has deleted it; cleared on the + // settled (kept on success ??the backend has deleted it; cleared on the // rollback above on failure). endSessionMutation(removedIds) } From 5bf2f924b40d8bea21a04e609f9e291e7dba05a3 Mon Sep 17 00:00:00 2001 From: stantheman0128 Date: Sat, 18 Jul 2026 08:28:19 +0800 Subject: [PATCH 2/5] fix(desktop): clear create guard if route never catches new session Release creatingSessionRef when navigate throws, when the route leaves the pre-create location for a non-pending id, or after a 3s safety timeout so a stuck router cannot block resumes forever. Same clear on forkBranch catch. Co-authored-by: Cursor --- .../hooks/use-session-actions.test.tsx | 238 ++++++++++++++---- .../hooks/use-session-actions/index.ts | 73 +++++- 2 files changed, 265 insertions(+), 46 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index dd44c98ad4a2b..191102506f6ae 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -1542,6 +1542,7 @@ describe('createBackendSessionForSend workspace target', () => { describe('createBackendSessionForSend creatingSessionRef hold (#66057)', () => { afterEach(() => { cleanup() + vi.useRealTimers() $newChatProfile.set(null) $activeGatewayProfile.set('default') setCurrentCwd('') @@ -1549,55 +1550,72 @@ describe('createBackendSessionForSend creatingSessionRef hold (#66057)', () => { vi.restoreAllMocks() }) + function GuardHarness({ + creatingSessionRef, + navigate, + onReady, + requestGateway, + routeId, + selectedStoredSessionIdRef + }: { + creatingSessionRef: MutableRefObject + navigate: (...args: never[]) => unknown + onReady: (create: () => Promise) => void + requestGateway: (method: string, params?: Record) => Promise + routeId: null | string + selectedStoredSessionIdRef: MutableRefObject + }) { + const ref = (value: T): MutableRefObject => ({ current: value }) + const actions = useSessionActions({ + activeSessionId: null, + activeSessionIdRef: ref(null), + busyRef: ref(false), + creatingSessionRef, + ensureSessionState: () => ({}) as ClientSessionState, + getRouteToken: () => 'token', + navigate: navigate as never, + requestGateway, + resetViewSync: vi.fn(), + routedSessionId: routeId, + runtimeIdByStoredSessionIdRef: ref(new Map()), + selectedStoredSessionId: selectedStoredSessionIdRef.current, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef: ref(new Map()), + syncSessionStateToView: vi.fn(), + updateSessionState: () => ({}) as ClientSessionState + }) + + useEffect(() => { + onReady(() => actions.createBackendSessionForSend()) + }, [actions, onReady]) + + return null + } + it('keeps creatingSessionRef true until routedSessionId catches up to the created stored id', async () => { const creatingSessionRef: MutableRefObject = { current: false } const selectedStoredSessionIdRef: MutableRefObject = { current: null } const navigate = vi.fn() - let routedSessionId: null | string = null - - function GuardHarness({ - onReady, - routeId - }: { - onReady: (create: () => Promise) => void - routeId: null | string - }) { - const ref = (value: T): MutableRefObject => ({ current: value }) - const actions = useSessionActions({ - activeSessionId: null, - activeSessionIdRef: ref(null), - busyRef: ref(false), - creatingSessionRef, - ensureSessionState: () => ({}) as ClientSessionState, - getRouteToken: () => 'token', - navigate: navigate as never, - requestGateway: async method => { - if (method === 'session.create') { - return { session_id: 'rt-new', stored_session_id: 'stored-new' } as never - } + let routedSessionId: null | string = 'session-A' - return {} as never - }, - resetViewSync: vi.fn(), - routedSessionId: routeId, - runtimeIdByStoredSessionIdRef: ref(new Map()), - selectedStoredSessionId: selectedStoredSessionIdRef.current, - selectedStoredSessionIdRef, - sessionStateByRuntimeIdRef: ref(new Map()), - syncSessionStateToView: vi.fn(), - updateSessionState: () => ({}) as ClientSessionState - }) - - useEffect(() => { - onReady(() => actions.createBackendSessionForSend()) - }, [actions, onReady]) + const requestGateway = async (method: string): Promise => { + if (method === 'session.create') { + return { session_id: 'rt-new', stored_session_id: 'stored-new' } as T + } - return null + return {} as T } let create: (() => Promise) | null = null const { rerender } = render( - (create = fn)} routeId={routedSessionId} /> + (create = fn)} + requestGateway={requestGateway} + routeId={routedSessionId} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> ) await waitFor(() => expect(create).not.toBeNull()) @@ -1609,13 +1627,149 @@ describe('createBackendSessionForSend creatingSessionRef hold (#66057)', () => { expect(creatingSessionRef.current).toBe(true) expect(selectedStoredSessionIdRef.current).toBe('stored-new') - // Route still stale — guard must stay up. - rerender( undefined} routeId={null} />) + // Route still stale on A — guard must stay up (not a user navigation away). + rerender( + undefined} + requestGateway={requestGateway} + routeId="session-A" + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) expect(creatingSessionRef.current).toBe(true) // Router catches up to the created stored id — release the guard. routedSessionId = 'stored-new' - rerender( undefined} routeId={routedSessionId} />) + rerender( + undefined} + requestGateway={requestGateway} + routeId={routedSessionId} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + expect(creatingSessionRef.current).toBe(false) + }) + + it('clears creatingSessionRef when navigate throws', async () => { + const creatingSessionRef: MutableRefObject = { current: false } + const selectedStoredSessionIdRef: MutableRefObject = { current: null } + const navigate = vi.fn(() => { + throw new Error('navigate failed') + }) + + let create: (() => Promise) | null = null + render( + (create = fn)} + requestGateway={async method => { + if (method === 'session.create') { + return { session_id: 'rt-new', stored_session_id: 'stored-new' } as never + } + + return {} as never + }} + routeId="session-A" + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + await waitFor(() => expect(create).not.toBeNull()) + + await act(async () => { + await create!() + }) + + expect(navigate).toHaveBeenCalled() + expect(creatingSessionRef.current).toBe(false) + }) + + it('clears creatingSessionRef when the route moves to a different session than pending', async () => { + const creatingSessionRef: MutableRefObject = { current: false } + const selectedStoredSessionIdRef: MutableRefObject = { current: null } + const navigate = vi.fn() + + const requestGateway = async (method: string): Promise => { + if (method === 'session.create') { + return { session_id: 'rt-new', stored_session_id: 'stored-new' } as T + } + + return {} as T + } + + let create: (() => Promise) | null = null + const { rerender } = render( + (create = fn)} + requestGateway={requestGateway} + routeId="session-A" + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + await waitFor(() => expect(create).not.toBeNull()) + + await act(async () => { + await create!() + }) + expect(creatingSessionRef.current).toBe(true) + + // User clicked another session while create navigate was still pending. + rerender( + undefined} + requestGateway={requestGateway} + routeId="session-C" + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + expect(creatingSessionRef.current).toBe(false) + }) + + it('clears creatingSessionRef via safety timeout if the route never catches up', async () => { + const creatingSessionRef: MutableRefObject = { current: false } + const selectedStoredSessionIdRef: MutableRefObject = { current: null } + const navigate = vi.fn() + + let create: (() => Promise) | null = null + render( + (create = fn)} + requestGateway={async method => { + if (method === 'session.create') { + return { session_id: 'rt-new', stored_session_id: 'stored-new' } as never + } + + return {} as never + }} + routeId="session-A" + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + await waitFor(() => expect(create).not.toBeNull()) + + // Arm the pending timeout under fake timers so we can advance deterministically. + vi.useFakeTimers() + await act(async () => { + await create!() + }) + expect(creatingSessionRef.current).toBe(true) + + // Route stays on A forever — safety timeout must drop the guard so resumes + // are not permanently blocked. + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) expect(creatingSessionRef.current).toBe(false) }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 1f7267d02f6d3..da4357b45a85e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -128,6 +128,10 @@ interface SessionActionsOptions { // (NOT in this set) still legitimately drops to a draft. const createdThisRun = new Set() +// How long we keep creatingSessionRef after create/fork navigate before giving up +// if the router never lands on the pending stored id (stuck navigate / lost race). +const CREATE_GUARD_RELEASE_MS = 3_000 + // Reflect a stored row's persisted token counts into the live usage atom // (total is derived, so callers can't drift it out of sync with input/output). function applyStoredUsage(stored: { input_tokens?: number | null; output_tokens?: number | null }) { @@ -226,14 +230,56 @@ export function useSessionActions({ // true until routedSessionId + selection both agree on this id ??clearing via // setTimeout(0) let use-route-resume resume the stale route as "stuck" (#66057). const pendingCreatedStoredSessionIdRef = useRef(null) + // Route id at the moment we armed pending (often the stale previous session). + // Distinguishes "router still lagging on A" from "user navigated to C". + const pendingCreatedFromRouteRef = useRef(null) + const pendingGuardTimeoutRef = useRef | null>(null) const releaseCreatingSessionGuard = useCallback(() => { + if (pendingGuardTimeoutRef.current != null) { + clearTimeout(pendingGuardTimeoutRef.current) + pendingGuardTimeoutRef.current = null + } + pendingCreatedStoredSessionIdRef.current = null + pendingCreatedFromRouteRef.current = null creatingSessionRef.current = false }, [creatingSessionRef]) - // Drop the create/fork guard only once the router has caught up to the session - // we already selected. Refs/atoms lead; HashRouter can lag a tick. + // Arm the create/fork hold: keep creatingSessionRef until the route lands on + // `storedId`, the user leaves for another route, navigate throws, or the + // safety timeout fires (so a stuck router can't block resumes forever). + const armPendingCreatedSession = useCallback( + (storedId: string) => { + pendingCreatedStoredSessionIdRef.current = storedId + pendingCreatedFromRouteRef.current = routedSessionId + + if (pendingGuardTimeoutRef.current != null) { + clearTimeout(pendingGuardTimeoutRef.current) + } + + pendingGuardTimeoutRef.current = setTimeout(() => { + pendingGuardTimeoutRef.current = null + + if (pendingCreatedStoredSessionIdRef.current === storedId) { + releaseCreatingSessionGuard() + } + }, CREATE_GUARD_RELEASE_MS) + }, + [releaseCreatingSessionGuard, routedSessionId] + ) + + useEffect( + () => () => { + if (pendingGuardTimeoutRef.current != null) { + clearTimeout(pendingGuardTimeoutRef.current) + } + }, + [] + ) + + // Drop the create/fork guard once the router catches up ??or if the user + // navigates somewhere other than the pending id (left the pre-create route). useEffect(() => { const pending = pendingCreatedStoredSessionIdRef.current @@ -243,6 +289,14 @@ export function useSessionActions({ if (routedSessionId === pending && selectedStoredSessionIdRef.current === pending) { releaseCreatingSessionGuard() + + return + } + + const fromRoute = pendingCreatedFromRouteRef.current + + if (routedSessionId !== fromRoute && routedSessionId !== pending) { + releaseCreatingSessionGuard() } }, [ creatingSessionRef, @@ -443,8 +497,14 @@ export function useSessionActions({ // Hold creatingSessionRef until the route lands on `stored` (release // effect above). setTimeout(0) raced use-route-resume back onto the // previous session (#66057). - pendingCreatedStoredSessionIdRef.current = stored - navigate(sessionRoute(stored), { replace: true }) + armPendingCreatedSession(stored) + + try { + navigate(sessionRoute(stored), { replace: true }) + } catch { + releaseCreatingSessionGuard() + } + // Other windows (e.g. the main window when this is the pop-out) can't // see this session until they re-pull the shared list. broadcastSessionsChanged() @@ -479,10 +539,12 @@ export function useSessionActions({ }, [ activeSessionIdRef, + armPendingCreatedSession, creatingSessionRef, ensureSessionState, getRouteToken, navigate, + releaseCreatingSessionGuard, requestGateway, resetViewSync, selectedStoredSessionIdRef, @@ -1220,6 +1282,9 @@ export function useSessionActions({ return true } catch (err) { + // Navigate throw or earlier failure after arming pending ??never leave + // creatingSessionRef stuck true. + releaseCreatingSessionGuard() notifyError(err, copy.branchFailed) return false From 63567bbcfaf33fef2b02fdc026c597f81740e2a0 Mon Sep 17 00:00:00 2001 From: stantheman0128 Date: Sun, 19 Jul 2026 19:36:30 +0800 Subject: [PATCH 3/5] fix(desktop): recover from create route timeout mismatch Scope selectionMovedAheadOfRoute to the pending-create hold and retry navigate when the 3s safety timeout fires so ChatView can leave a stuck A-route/B-selection loading state instead of suppressing resume forever. Co-authored-by: Cursor --- .../session/hooks/use-route-resume.test.tsx | 73 +++++++++++++++++-- .../src/app/session/hooks/use-route-resume.ts | 7 ++ .../hooks/use-session-actions.test.tsx | 8 +- .../hooks/use-session-actions/index.ts | 17 ++++- 4 files changed, 93 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx index 2aa18c101cf35..12a9084c7019f 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx @@ -36,14 +36,15 @@ describe('useRouteResume', () => { vi.restoreAllMocks() }) - it('does not resume stale routed session A when refs already moved to new session B (#66057)', () => { + it('does not resume stale routed session A while the create guard holds selection on B (#66057)', () => { // createBackendSessionForSend updates refs/atoms to B and navigates, but the - // router can still report A for a tick. stuckOnRoutedSession must NOT treat - // that as "stranded on A" and call resumeSession(A) (jump-back bug). + // router can still report A for a tick. While creatingSessionRef is true, + // stuckOnRoutedSession must NOT treat that as "stranded on A" and call + // resumeSession(A) (jump-back bug). const resumeSession = vi.fn(async () => undefined) const startFreshSessionDraft = vi.fn() const activeSessionIdRef: MutableRefObject = { current: 'runtime-B' } - const creatingSessionRef = { current: false } + const creatingSessionRef = { current: true } const runtimeIdByStoredSessionIdRef = { current: new Map([['session-B', 'runtime-B']]) } const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-B' } @@ -51,7 +52,7 @@ describe('useRouteResume', () => { { expect(resumeSession).not.toHaveBeenCalled() - // Simulate post-create: refs/atoms already on B, route still on A, guard down - // (the old setTimeout(0) clear), freshDraftReady false. + // Simulate post-create: refs/atoms already on B, route still on A, create + // guard still held until the router catches up. rerender( { expect(resumeSession).not.toHaveBeenCalled() }) + it('recovers by resuming A after create timeout when the route never catches up to B', () => { + // Post-timeout shape: creatingSessionRef false, selection/active on B, route + // still on A. selectionMovedAheadOfRoute must NOT keep blocking once the + // pending-create hold is gone — stuckOnRoutedSession should resume A so + // ChatView leaves its route/selection mismatch loading state. + const resumeSession = vi.fn(async () => undefined) + const startFreshSessionDraft = vi.fn() + const activeSessionIdRef: MutableRefObject = { current: 'runtime-A' } + const creatingSessionRef = { current: false } + const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-A' } + + const { rerender } = render( + + ) + + expect(resumeSession).not.toHaveBeenCalled() + + // Create moved selection/runtime to B; safety timeout already released the + // guard; router never left A. + activeSessionIdRef.current = 'runtime-B' + selectedStoredSessionIdRef.current = 'session-B' + creatingSessionRef.current = false + rerender( + + ) + + expect(resumeSession).toHaveBeenCalledTimes(1) + expect(resumeSession).toHaveBeenCalledWith('session-A', true) + }) + it('does not re-resume the old session during a /:sid -> /new transition', () => { const resumeSession = vi.fn(async () => undefined) const startFreshSessionDraft = vi.fn() diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts index 8873d8a6e48d1..4ac68d9a1d0dd 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.ts +++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts @@ -139,7 +139,14 @@ export function useRouteResume({ // Also must NOT fire when create/fork already moved selection + runtime to // a new session B while the router still shows stale A (#66057). That looks // "stuck on A" but resuming A yanks the UI back off the new chat. + // + // Scope this suppression to an active pending-create hold only. Once + // creatingSessionRef drops (route caught up, user left, or the safety + // timeout), a lingering A-route / B-selection mismatch must be able to + // self-heal via stuckOnRoutedSession — otherwise ChatView stays in its + // route/selection loading state forever after a stuck navigate. const selectionMovedAheadOfRoute = + creatingSessionRef.current && Boolean(selectedStoredSessionIdRef.current) && selectedStoredSessionIdRef.current !== routedSessionId && Boolean(activeSessionIdRef.current) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 191102506f6ae..6fad48240cac9 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -1764,12 +1764,16 @@ describe('createBackendSessionForSend creatingSessionRef hold (#66057)', () => { await create!() }) expect(creatingSessionRef.current).toBe(true) + expect(navigate).toHaveBeenCalledTimes(1) - // Route stays on A forever — safety timeout must drop the guard so resumes - // are not permanently blocked. + // Route stays on A forever — safety timeout must retry navigate (reconcile) + // and drop the guard so use-route-resume can self-heal if the route still + // never moves. await act(async () => { await vi.advanceTimersByTimeAsync(3_000) }) expect(creatingSessionRef.current).toBe(false) + expect(navigate).toHaveBeenCalledTimes(2) + expect(navigate).toHaveBeenLastCalledWith('/stored-new', { replace: true }) }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index da4357b45a85e..7c608703ca4eb 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -261,12 +261,23 @@ export function useSessionActions({ pendingGuardTimeoutRef.current = setTimeout(() => { pendingGuardTimeoutRef.current = null - if (pendingCreatedStoredSessionIdRef.current === storedId) { - releaseCreatingSessionGuard() + if (pendingCreatedStoredSessionIdRef.current !== storedId) { + return } + + // Route never caught up. Retry navigate so ChatView can leave the + // route/selection mismatch loading state; then drop the guard so + // use-route-resume can self-heal to the URL if navigate still fails. + try { + navigate(sessionRoute(storedId), { replace: true }) + } catch { + // Ignore — release below still unblocks recovery. + } + + releaseCreatingSessionGuard() }, CREATE_GUARD_RELEASE_MS) }, - [releaseCreatingSessionGuard, routedSessionId] + [navigate, releaseCreatingSessionGuard, routedSessionId] ) useEffect( From 518c209b2d73060c5e5685951d34f42432aa3249 Mon Sep 17 00:00:00 2001 From: stantheman0128 Date: Mon, 27 Jul 2026 01:26:33 +0800 Subject: [PATCH 4/5] style(desktop): restore comment punctuation mangled by a cp950 round-trip An editing step on Windows decoded these files as cp950 and re-encoded them as ASCII, which turned every em dash, arrow and curly quote into "??" and swallowed the byte that followed it. The result was 34 comment lines showing up as unrelated changes in the diff. Restored each line by matching its mangled form against the merge-base blob, plus four comments added by this branch that had no upstream original. Only lines with a unique upstream match were rewritten, so nullish coalescing is untouched. No code changes. Co-authored-by: Claude Opus 5 --- .../hooks/use-session-actions/index.ts | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 7c608703ca4eb..f56739954eca9 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -105,7 +105,7 @@ interface SessionActionsOptions { resetViewSync: () => void // Live route session id from the router. Used to drop creatingSessionRef only // after navigate to a freshly created/forked stored id has actually landed - // (setTimeout(0) cleared the guard before the route caught up ??#66057). + // (setTimeout(0) cleared the guard before the route caught up — #66057). routedSessionId: string | null runtimeIdByStoredSessionIdRef: MutableRefObject> selectedStoredSessionId: string | null @@ -120,10 +120,10 @@ interface SessionActionsOptions { } // Stored ids created in THIS renderer run. A brand-new session lives only in the -// gateway's in-memory map until its first turn persists a state.db row ??so if a +// gateway's in-memory map until its first turn persists a state.db row — so if a // respawning/flapping backend drops it, both resume RPC and the REST transcript // 404 even though the user just made it. We must NOT treat that as "gone" (which -// yanks them to a fresh draft ??the "new sessions clear themselves" bug); the +// yanks them to a fresh draft — the "new sessions clear themselves" bug); the // bounded retry rebinds it when the backend returns. Boot-into-a-stale-last-id // (NOT in this set) still legitimately drops to a draft. const createdThisRun = new Set() @@ -161,10 +161,10 @@ function reconcileAuthoritativeMessages( // // Resolving null profile to the active gateway's is load-bearing: in global-remote // mode one backend serves every profile, so an omitted profile silently lands the -// chat on the launch (default) profile ??the "rubberbands back to default" bug. +// chat on the launch (default) profile — the "rubberbands back to default" bug. // A no-op for single-profile/local-pooled users (a backend resolves its own launch // profile to None). The sticky UI model/effort/fast ride as per-session overrides, -// never the profile default (that lives in Settings ??Model). +// never the profile default (that lives in Settings → Model). async function desktopSessionCreateParams(cwd: string): Promise> { // Treat Send as the linearization point for the visible selector state. The // profile handshake below can yield long enough for background config/model @@ -227,7 +227,7 @@ export function useSessionActions({ const copy = t.desktop const resumeRequestRef = useRef(0) // Stored id we just created/forked and navigated to. creatingSessionRef stays - // true until routedSessionId + selection both agree on this id ??clearing via + // true until routedSessionId + selection both agree on this id — clearing via // setTimeout(0) let use-route-resume resume the stale route as "stuck" (#66057). const pendingCreatedStoredSessionIdRef = useRef(null) // Route id at the moment we armed pending (often the stale previous session). @@ -289,7 +289,7 @@ export function useSessionActions({ [] ) - // Drop the create/fork guard once the router catches up ??or if the user + // Drop the create/fork guard once the router catches up — or if the user // navigates somewhere other than the pending id (left the pre-create route). useEffect(() => { const pending = pendingCreatedStoredSessionIdRef.current @@ -320,7 +320,7 @@ export function useSessionActions({ // Follow auto-compression's stored-id rotation only while the exact runtime, // selection, and route intent still belong to the rotating conversation. // The previous implementation carried only the next stored id and navigated - // unconditionally; a fast A ??B ??C switch could therefore be overwritten + // unconditionally; a fast A → B → C switch could therefore be overwritten // by A's delayed session.info event and visibly jump back to A. const storedIdRotation = useStore($activeSessionStoredIdRotation) @@ -349,7 +349,7 @@ export function useSessionActions({ // ChatBar scopes composer state on resolveComposerSessionKey(); migrating // onto the tip while the composer is still bound to the root can lose newer // live editor text on a brief remount. If the new tip row is not in - // $sessions yet, resolveComposerSessionKey falls back to the tip id ??prefer + // $sessions yet, resolveComposerSessionKey falls back to the tip id — prefer // the previous id (usually the lineage root) in that gap. const previousId = storedIdRotation.previousStoredSessionId const nextId = storedIdRotation.nextStoredSessionId @@ -420,7 +420,7 @@ export function useSessionActions({ setSessionStartedAt(null) setTurnStartedAt(null) // The composer's model/effort/fast is sticky UI state (persisted in - // localStorage) ??a new chat FOLLOWS your last pick instead of snapping + // localStorage) — a new chat FOLLOWS your last pick instead of snapping // back to the profile default, so we deliberately don't reset it here. The // profile default still owns first-run seeding and profile switches (see // refreshCurrentModel). Only $currentServiceTier (a live-session mirror) @@ -430,7 +430,7 @@ export function useSessionActions({ setNewChatWorkspaceTarget(hasWorkspaceTarget ? workspaceTarget : undefined) if (!hasWorkspaceTarget) { - // In a project ??the repo's default-branch checkout; not in a project ?? + // In a project → the repo's default-branch checkout; not in a project → // detached. So cmd-n does not inherit an unrelated linked worktree. setCurrentCwd(resolveNewSessionCwd()) } else if (workspaceTarget === null) { @@ -440,7 +440,7 @@ export function useSessionActions({ } setCurrentBranch('') - // Never clear the composer here ??ChatBar's per-thread draft swap owns it. + // Never clear the composer here — ChatBar's per-thread draft swap owns it. setFreshDraftReady(true) }, [activeSessionIdRef, busyRef, navigate, onFreshDraftRouteIntent, resetViewSync, selectedStoredSessionIdRef] @@ -454,9 +454,9 @@ export function useSessionActions({ creatingSessionRef.current = true try { - // An explicit one-shot workspace target (null ??detached, string ??that + // An explicit one-shot workspace target (null → detached, string → that // folder) wins; otherwise the live cwd, then the project-aware default - // (resolveNewSessionCwd ??a project's new session keeps its repo cwd). + // (resolveNewSessionCwd — a project's new session keeps its repo cwd). const workspaceTarget = $newChatWorkspaceTarget.get() const cwd = @@ -474,7 +474,7 @@ export function useSessionActions({ // session we just minted. The active runtime ref is deliberately not a // prong: background gateway events retarget it while other sessions // stream (#47709 class), and the seconds-long session.create round-trip - // (server-side agent + MCP init) makes that churn near-certain ??every + // (server-side agent + MCP init) makes that churn near-certain — every // genuine user switch retargets selection AND route synchronously // anyway. submitTargetStoredId is the just-created stored session, so // our own upcoming re-home onto it never reads as drift. @@ -534,7 +534,7 @@ export function useSessionActions({ } // User may have armed YOLO on the new-chat draft before the runtime - // session existed ??apply it to the freshly created session. + // session existed — apply it to the freshly created session. if (yoloArmed) { await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined) } @@ -578,12 +578,12 @@ export function useSessionActions({ [navigate, startFreshSessionDraft] ) - /** Create a fresh session and open it as a tile ??leaves the primary chat alone. + /** Create a fresh session and open it as a tile — leaves the primary chat alone. * Used by the New session row's "Open in split" menu and the tab-strip "+". * * `listed` (default true) controls sidebar visibility. A brand-new backend * session is IN-MEMORY only until its first turn persists a row, so - * `listSessions(min_messages=1)` already hides an unused one ??the sidebar + * `listSessions(min_messages=1)` already hides an unused one — the sidebar * pollution comes solely from the optimistic upsert here. The tab-strip "+" * passes `listed: false` so an unused new tab never clutters the session * list (Cursor-style draft tab); it surfaces on the next refresh once the @@ -610,7 +610,7 @@ export function useSessionActions({ createdThisRun.add(stored) // Seed the per-runtime cache so the tile renders immediately without a - // redundant resume. Only add the row to the SIDEBAR when `listed` ??an + // redundant resume. Only add the row to the SIDEBAR when `listed` — an // unlisted (draft) tab stays out of the session list until its first // turn persists and a refresh surfaces it. if (listed) { @@ -662,7 +662,7 @@ export function useSessionActions({ // so there's zero dead air: highlight the row instantly (the sidebar reads // $selectedStoredSessionId) and, for a cold target, drop the previous // transcript so the thread shows its loader instead of the old session - // lingering until resume lands. A warm-cached target keeps its transcript ?? + // lingering until resume lands. A warm-cached target keeps its transcript — // the cached fast-path repaints it this same tick. Setting the ref here is // also what use-route-resume's self-heal assumes ("set synchronously at // resume entry"). @@ -685,7 +685,7 @@ export function useSessionActions({ // session being resumed. A pooled profile backend that gets idle-reaped // and respawned (pruneSecondaryGateways) re-mints runtime ids, so a // recycled id can resolve to a live-but-DIFFERENT session's cache entry. - // The session.activate 404 guard below only catches a fully-DEAD id ??a + // The session.activate 404 guard below only catches a fully-DEAD id — a // recycled-live id 200s, so an unchecked hit paints the wrong transcript // under the current route (the "open chat A, chat B loads" bug). On a // mismatch the mapping is cross-wired: purge both sides and report a miss @@ -909,8 +909,8 @@ export function useSessionActions({ activeSessionIdRef.current = null // A warm-cache hit at entry skipped the cold-path transcript clear, but the - // warm path can still bail down to here ??an empty-transcript drop, or the - // cache getting purged during the profile-swap await ??so the PREVIOUS + // warm path can still bail down to here — an empty-transcript drop, or the + // cache getting purged during the profile-swap await — so the PREVIOUS // session's transcript would leak into this cold resume ("switching // sessions shows the same messages"). Clear it so the loader/prefetch // paints fresh; guarded so the normal cold path (already cleared) no-ops. @@ -953,10 +953,10 @@ export function useSessionActions({ let prefetchedStoredSessionId: string | null = null // REST transcript prefetch and the gateway resume RPC are independent - // ??run them concurrently so a big session's wall time is + // — run them concurrently so a big session's wall time is // max(prefetch, resume) instead of their sum. The prefetch paints the // transcript as soon as it lands; the RPC binds the runtime id. - // Watch windows skip the prefetch ??lazy resume attaches the live mirror. + // Watch windows skip the prefetch — lazy resume attaches the live mirror. const prefetchPromise = watchWindow ? null : getSessionMessages(storedSessionId, sessionProfile) const resumePromise = requestGateway('session.resume', { @@ -1009,7 +1009,7 @@ export function useSessionActions({ // Keep the local snapshot when resume would only reshuffle runtime // projection. When the REST prefetch already hydrated the transcript, - // skip converting/reconciling the resume payload entirely ??on a + // skip converting/reconciling the resume payload entirely — on a // 1000+-message session that second conversion plus the deep // equivalence compare costs over a second of main-thread time. const resumedStoredSessionId = resumed.session_key || resumed.resumed @@ -1113,11 +1113,11 @@ export function useSessionActions({ // The gateway resume RPC failed. Try the REST transcript as a fallback // so the window at least shows history. CRITICAL: this fallback must be - // wrapped in its own try ??if it ALSO throws (wedged/unreachable backend, + // wrapped in its own try — if it ALSO throws (wedged/unreachable backend, // the common case when resume failed in the first place), an unguarded // throw here skips setMessages AND leaves activeSessionId null with an // empty transcript. That is the exact state the thread loader latches on - // forever (messagesEmpty && !activeSessionId) with no recovery path ?? + // forever (messagesEmpty && !activeSessionId) with no recovery path — // the "open in new window stays stuck loading, even after a nap" bug. let fallbackError: unknown = null @@ -1154,12 +1154,12 @@ export function useSessionActions({ // The session is genuinely gone (deleted, or a stale id from a wiped / // rotated backend): the resume RPC and the authoritative REST transcript - // both 404. There's nothing to recover ??silently drop to a fresh draft + // both 404. There's nothing to recover — silently drop to a fresh draft // instead of toasting an error and hot-looping the bounded retry on a // permanently-dead id. (Booting straight into a no-longer-existent // last-session id is the common trigger.) if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) { - // A session created THIS run isn't gone ??its backend just flapped + // A session created THIS run isn't gone — its backend just flapped // before the turn-less session persisted. Keep the empty view and arm // the bounded retry to rebind, rather than yanking to a fresh draft. // Only a stale id from a PRIOR run drops to a draft. @@ -1180,7 +1180,7 @@ export function useSessionActions({ // That is the exact stranded state the loader latches on // (messagesEmpty && !activeSessionId), and matches $resumeFailedSessionId's // documented contract. If the REST fallback DID paint history, the - // window is readable ??arming here would needlessly auto-retry and, + // window is readable — arming here would needlessly auto-retry and, // once retries exhaust, blank that visible transcript behind the // exhausted-state error overlay (a regression vs. plain fallback success). setResumeFailedSessionId(storedSessionId) @@ -1212,7 +1212,7 @@ export function useSessionActions({ // Shared fork: create a child session seeded with `branchMessages`, linked to // `parentStoredId` so it nests under its parent, then open it as its own tab - // and switch to it ??the parent chat stays put (mirrors openNewSessionTile). + // and switch to it — the parent chat stays put (mirrors openNewSessionTile). const forkBranch = useCallback( async ( branchMessages: BranchMessage[], @@ -1285,7 +1285,7 @@ export function useSessionActions({ // Open the branch as its own tab and switch to it, leaving the parent // chat exactly where it is. Prime the tile with the create runtime so it // skips a redundant resume. Do NOT select it as the primary session - // first ??openSessionTile no-ops when the id is already primary. + // first — openSessionTile no-ops when the id is already primary. openSessionTile(routedSessionId, 'center') patchSessionTile(routedSessionId, { runtimeId: branched.session_id }) revealTreePane(`session-tile:${routedSessionId}`) @@ -1293,7 +1293,7 @@ export function useSessionActions({ return true } catch (err) { - // Navigate throw or earlier failure after arming pending ??never leave + // Navigate throw or earlier failure after arming pending — never leave // creatingSessionRef stuck true. releaseCreatingSessionGuard() notifyError(err, copy.branchFailed) @@ -1308,7 +1308,7 @@ export function useSessionActions({ [copy, creatingSessionRef, ensureSessionState, requestGateway, updateSessionState] ) - // Branch the open chat ??optionally from a specific message ??off its live transcript. + // Branch the open chat — optionally from a specific message — off its live transcript. const branchCurrentSession = useCallback( async (messageId?: string): Promise => { if (!activeSessionIdRef.current) { @@ -1359,7 +1359,7 @@ export function useSessionActions({ clearNotifications() // Right-clicking a session outside the paginated sidebar window is a cache - // miss: resolve it (cache ??active backend ??cross-profile) so the branch + // miss: resolve it (cache → active backend → cross-profile) so the branch // is created on the parent's OWNING profile, not whichever is live (#67603). const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? @@ -1474,7 +1474,7 @@ export function useSessionActions({ notifyError(err, copy.deleteFailed) } finally { // Release the tombstone to the normal projects.tree prune now the RPC has - // settled (kept on success ??the backend has deleted it; cleared on the + // settled (kept on success — the backend has deleted it; cleared on the // rollback above on failure). endSessionMutation(removedIds) } From 70538847549edcd6a419e9395a424fa6ab654614 Mon Sep 17 00:00:00 2001 From: stantheman0128 Date: Mon, 27 Jul 2026 01:26:33 +0800 Subject: [PATCH 5/5] test(desktop): wire the route options into both session-actions harnesses SessionActionsOptions gained routedSessionId and getRoutedStoredSessionId in this branch, but StoredIdRotationHarness only passed the getter and GuardHarness only passed the reactive id, so tsc failed on both call sites. Each harness already holds the route it is simulating; feed that to the missing option rather than a stub. Co-authored-by: Claude Opus 5 --- apps/desktop/src/app/session/hooks/use-session-actions.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 6fad48240cac9..986d68f2a4604 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -153,6 +153,7 @@ function StoredIdRotationHarness({ navigate: navigate as never, requestGateway: async () => ({}) as never, resetViewSync: vi.fn(), + routedSessionId: getRoutedStoredSessionId(), runtimeIdByStoredSessionIdRef: ref(new Map()), selectedStoredSessionId: selectedStoredSessionIdRef.current, selectedStoredSessionIdRef, @@ -1573,6 +1574,7 @@ describe('createBackendSessionForSend creatingSessionRef hold (#66057)', () => { creatingSessionRef, ensureSessionState: () => ({}) as ClientSessionState, getRouteToken: () => 'token', + getRoutedStoredSessionId: () => routeId, navigate: navigate as never, requestGateway, resetViewSync: vi.fn(),