Repository navigation
fix(web): a branch restore in one thread no longer blocks another thread #24060
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
maria-rcks
merged 9 commits into
pingdotgg:main
from
Batuhan1999:fix/thread-switch-branch-state
Oct 30, 2026
+205
−10
Merged
Changes from 1 commit
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
fb4fdf4
fix(web): a branch restore in one thread no longer blocks another thread
Batuhan1999 153689f
fix(web): track branch restores per thread so overlapping restores ke…
Batuhan1999 fde86e3
Merge remote-tracking branch 'github/main' into takeover-24060
maria-rcks 5d8451f
test(web): cover both branch restore completion orders
maria-rcks 387e3fc
fix(web): guard restore focus when its animation frame runs
maria-rcks 62885c4
chore: merge main into restore branch fix
maria-rcks 0f2fbe0
test(web): supply turn items to restore branch harness
maria-rcks 19b9b18
chore: merge main
maria-rcks 5582512
test(web): thread lifecycle fixture supplies the detail ref main now …
maria-rcks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
149 changes: 149 additions & 0 deletions
149
apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| import { expect, it, vi } from "vite-plus/test"; | ||
| import { page } from "vite-plus/test/browser"; | ||
|
|
||
| const state = vi.hoisted(() => ({ | ||
| switchRef: (async () => undefined) as (input: unknown) => Promise<unknown>, | ||
| })); | ||
|
|
||
| vi.mock(import("../../state/session"), async (importOriginal) => ({ | ||
| ...(await importOriginal()), | ||
| useEnvironmentScope: (() => true) as never, | ||
| readEnvironmentScope: (() => true) as never, | ||
| })); | ||
|
|
||
| vi.mock(import("../../state/use-atom-command"), async (importOriginal) => ({ | ||
| ...(await importOriginal()), | ||
| useAtomCommand: (() => (input: unknown) => state.switchRef(input)) as never, | ||
| })); | ||
|
|
||
| vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) => ({ | ||
| ...(await importOriginal()), | ||
| useOrchestrationCommand: (() => async () => ({ _tag: "Success", value: undefined })) as never, | ||
| })); | ||
|
|
||
| vi.mock(import("../../hooks/useThreadActions"), async (importOriginal) => ({ | ||
| ...(await importOriginal()), | ||
| useThreadActions: (() => ({})) as never, | ||
| useAcknowledgeThreadWoke: (() => () => undefined) as never, | ||
| })); | ||
|
|
||
| vi.mock(import("../../state/entities"), async (importOriginal) => ({ | ||
| ...(await importOriginal()), | ||
| useChildThreadInputs: (() => []) as never, | ||
| })); | ||
|
|
||
| import { render } from "../../../test/render"; | ||
| import { useThreadLifecycle } from "./useThreadLifecycle"; | ||
|
|
||
| const environmentId = EnvironmentId.make("environment:test"); | ||
|
|
||
| function setup() { | ||
| state.switchRef = async () => undefined; | ||
| } | ||
|
|
||
| function threadInput(threadId: string, branch: string) { | ||
| const ref = scopeThreadRef(environmentId, ThreadId.make(threadId)); | ||
| const thread = { id: ref.threadId, environmentId, branch, worktreePath: null }; | ||
| return { | ||
| routeThreadKey: scopedThreadKey(ref), | ||
| activeThread: thread as never, | ||
| activeThreadId: ref.threadId, | ||
| activeThreadRef: ref, | ||
| activeThreadKey: scopedThreadKey(ref), | ||
| localCheckoutBranchMismatch: { threadBranch: branch, currentBranch: "main" }, | ||
| }; | ||
| } | ||
|
|
||
| type ThreadInput = ReturnType<typeof threadInput>; | ||
|
|
||
| function RestoreBranchHarness(props: { | ||
| readonly thread: ThreadInput; | ||
| readonly currentRouteThreadKeyRef: { readonly current: string | null }; | ||
| readonly refreshGitStatus: () => void; | ||
| readonly scheduleComposerFocus: () => void; | ||
| }) { | ||
| const lifecycle = useThreadLifecycle({ | ||
| ...props.thread, | ||
| environmentId, | ||
| currentRouteThreadKeyRef: props.currentRouteThreadKeyRef, | ||
| canOperateThread: true, | ||
| clientSettingsHydrated: true, | ||
| activeThreadShell: null, | ||
| activeLatestRun: null as never, | ||
| activeProjectCwd: "/repo", | ||
| serverConfig: null, | ||
| runtimeMode: "full-access" as never, | ||
| isWorking: false, | ||
| pendingBackgroundTasks: [], | ||
| activePendingApproval: null as never, | ||
| activePendingUserInput: null as never, | ||
| gitStatusQuery: { refresh: props.refreshGitStatus }, | ||
| nowMinute: "", | ||
| composerRef: { current: null } as never, | ||
| composerDraftTarget: { kind: "thread", ref: props.thread.activeThreadRef } as never, | ||
| sendInFlightRef: { current: false }, | ||
| setOptimisticUserMessages: () => undefined, | ||
| setThreadError: () => undefined, | ||
| clearUsageLimitsFor: () => undefined, | ||
| beginLocalDispatch: () => undefined, | ||
| resetLocalDispatch: () => undefined, | ||
| scheduleComposerFocus: props.scheduleComposerFocus, | ||
| scrollToEnd: () => undefined, | ||
| persistThreadSettingsForNextTurn: async () => ({ _tag: "Success", value: undefined }) as never, | ||
| }); | ||
| return ( | ||
| <button | ||
| type="button" | ||
| disabled={lifecycle.isRestoringThreadBranch} | ||
| onClick={lifecycle.handleSwitchCheckoutToThreadStable} | ||
| > | ||
| {lifecycle.isRestoringThreadBranch ? "Restoring..." : "Restore branch"} | ||
| </button> | ||
| ); | ||
| } | ||
|
|
||
| it("keeps a branch restore in one thread from blocking or refocusing the next thread", async () => { | ||
| setup(); | ||
| const switchRef = vi.fn((_input: unknown) => new Promise<unknown>(() => undefined)); | ||
| let finishThreadA: (result: unknown) => void = () => undefined; | ||
| switchRef.mockImplementationOnce( | ||
| () => | ||
| new Promise((resolve) => { | ||
| finishThreadA = resolve; | ||
| }), | ||
| ); | ||
| state.switchRef = switchRef; | ||
| const threadA = threadInput("thread:a", "feature-a"); | ||
| const threadB = threadInput("thread:b", "feature-b"); | ||
| const currentRouteThreadKeyRef = { current: threadA.routeThreadKey }; | ||
| const refreshGitStatus = vi.fn(); | ||
| const scheduleComposerFocus = vi.fn(); | ||
| const harness = (thread: ThreadInput) => ( | ||
| <RestoreBranchHarness | ||
| thread={thread} | ||
| currentRouteThreadKeyRef={currentRouteThreadKeyRef} | ||
| refreshGitStatus={refreshGitStatus} | ||
| scheduleComposerFocus={scheduleComposerFocus} | ||
| /> | ||
| ); | ||
| await using screen = await render(harness(threadA)); | ||
|
|
||
| await page.getByRole("button", { name: "Restore branch" }).click(); | ||
| await expect.element(page.getByRole("button", { name: "Restoring..." })).toBeDisabled(); | ||
|
|
||
| // ChatView stays mounted across server threads, so the hook sees thread B in place. | ||
| currentRouteThreadKeyRef.current = threadB.routeThreadKey; | ||
| await screen.rerender(harness(threadB)); | ||
| await expect.element(page.getByRole("button", { name: "Restore branch" })).toBeEnabled(); | ||
| await page.getByRole("button", { name: "Restore branch" }).click(); | ||
| expect(switchRef).toHaveBeenLastCalledWith( | ||
| expect.objectContaining({ input: { cwd: "/repo", refName: "feature-b" } }), | ||
| ); | ||
|
|
||
| finishThreadA({ _tag: "Success", value: { refName: "feature-a" } }); | ||
| await vi.waitFor(() => expect(refreshGitStatus).toHaveBeenCalledOnce()); | ||
| expect(scheduleComposerFocus).not.toHaveBeenCalled(); | ||
| await expect.element(page.getByRole("button", { name: "Restoring..." })).toBeDisabled(); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.