authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 02:56:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:56:35-07:00
logbccad270466a039e254167b1b4da06e344d750a4
tree78bed61426709a8ca565e20fe159cdadfa5f0c4e
parente0adcc8a24db604c522282edea2823e3ef7a2bc6
signature Signed by PGP key B5690EEEBB952194

fix(web): keep manual panel choices during a turn (#10113)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

5 files changed, 455 insertions(+), 172 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+127-21
......@@ -14,7 +14,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
1414import type { Thread, ThreadShell, TurnDiffSummary } from "../types";
1515import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
1616import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
17import type { RightPanelSurface } from "../rightPanelStore";
17import { scopeThreadRef } from "@t3tools/client-runtime/environment";
18import {
19 type RightPanelSurface,
20 pullRequestSurface,
21 selectActiveRightPanelSurface,
22 useRightPanelStore,
23} from "../rightPanelStore";
24import {
25 selectThreadPreviewMiniPlayer,
26 usePreviewMiniPlayerStore,
27} from "../previewMiniPlayerStore";
1828import {
1929 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
2030 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
......@@ -39,6 +49,7 @@ import {
3949 resolveComposerInteractionMode,
4050 resolveComposerProviderSelection,
4151 resolveDraftPromotionNavigationTarget,
52 observeProactivePanelUserChoice,
4253 resolveProactiveTurnDiffAction,
4354 resolveThreadMetadataUpdateForNextTurn,
4455 resolveSendEnvMode,
......@@ -95,6 +106,37 @@ describe("agent browser close confirmation", () => {
95106});
96107
97108describe("floating browser preview", () => {
109 it("keeps agent preview intent when a user selects its browser tab and then switches away", () => {
110 useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} });
111 usePreviewMiniPlayerStore.setState({ byThreadKey: {} });
112 const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1"));
113 const panels = useRightPanelStore.getState();
114 const revision = panels.getUserActionRevision(ref);
115 usePreviewMiniPlayerStore.getState().open(ref, "agent-tab");
116 panels.reconcileBrowserSurfaces(ref, ["agent-tab"]);
117 const intent = selectThreadPreviewMiniPlayer(
118 usePreviewMiniPlayerStore.getState().byThreadKey,
119 ref,
120 );
121 const isFloating = () =>
122 shouldRenderPreviewMiniPlayer(
123 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref)
124 ?.tabId ?? null,
125 selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref),
126 );
127
128 panels.openProactive(ref, { id: "diff", kind: "diff" }, revision);
129 expect(isFloating()).toBe(true);
130 panels.activateSurface(ref, "browser:agent-tab");
131 expect(isFloating()).toBe(false);
132 expect(panels.openProactive(ref, { id: "diff", kind: "diff" }, revision)).toBe(false);
133 panels.open(ref, "diff");
134 expect(isFloating()).toBe(true);
135 expect(
136 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref),
137 ).toBe(intent);
138 });
139
98140 it("only hides the duplicate while the same browser is rendered in the panel", () => {
99141 expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false);
100142 expect(
......@@ -116,6 +158,90 @@ describe("floating browser preview", () => {
116158});
117159
118160describe("proactive panels", () => {
161 it("keeps a manual PR selection made after following a replacement while loading", () => {
162 useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} });
163 const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1"));
164 const panels = useRightPanelStore.getState();
165 const oldPr = pullRequestSurface({
166 projectId: "project-1",
167 repository: "owner/repo",
168 number: 1,
169 });
170 const replacement = pullRequestSurface({ ...oldPr, number: 2 });
171 const turnId = TurnId.make("turn-1");
172 panels.openPullRequest(ref, oldPr);
173 const loading = observeProactivePanelUserChoice(null, {
174 threadKey: "env-1:thread-1",
175 runningTurnId: turnId,
176 userActionRevision: panels.getUserActionRevision(ref),
177 });
178 expect(panels.openProactive(ref, replacement, loading.userActionRevision)).toBe(true);
179
180 panels.activateSurface(ref, oldPr.id);
181 const loaded = observeProactivePanelUserChoice(loading, {
182 threadKey: loading.threadKey,
183 runningTurnId: turnId,
184 userActionRevision: panels.getUserActionRevision(ref),
185 });
186 expect(panels.openProactive(ref, replacement, loaded.userActionRevision)).toBe(false);
187 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref)).toEqual(
188 oldPr,
189 );
190 expect(shouldOpenProactivePullRequest(loaded.targetKey, "owner/repo:2")).toBe(false);
191 expect(
192 shouldOpenProactiveTurnDiff({
193 previousRunningTurnId: loaded.runningTurnId,
194 runningTurnId: null,
195 settledTurnId: turnId,
196 turnCompleted: true,
197 }),
198 ).toBe(false);
199 });
200
201 it.each(["idle", "loading", "observed"] as const)(
202 "captures a new turn's choice once with initial state %s",
203 (initialState) => {
204 useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} });
205 const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1"));
206 const panels = useRightPanelStore.getState();
207 const firstTurn = TurnId.make("turn-1");
208 const nextTurn = TurnId.make("turn-2");
209 const initial = observeProactivePanelUserChoice(null, {
210 threadKey: "env-1:thread-1",
211 runningTurnId: initialState === "idle" ? null : firstTurn,
212 userActionRevision: panels.getUserActionRevision(ref),
213 });
214 panels.openFile(ref, "src/first.ts");
215 const loadingNextTurn = observeProactivePanelUserChoice(
216 {
217 ...initial,
218 ...(initialState === "observed" ? { runningTurnId: firstTurn, targetKey: null } : {}),
219 },
220 {
221 threadKey: initial.threadKey,
222 runningTurnId: nextTurn,
223 userActionRevision: panels.getUserActionRevision(ref),
224 },
225 );
226 expect(
227 panels.openProactive(ref, { id: "diff", kind: "diff" }, loadingNextTurn.userActionRevision),
228 ).toBe(true);
229
230 panels.openFile(ref, "src/second.ts");
231 const loaded = observeProactivePanelUserChoice(loadingNextTurn, {
232 threadKey: initial.threadKey,
233 runningTurnId: nextTurn,
234 userActionRevision: panels.getUserActionRevision(ref),
235 });
236 expect(
237 panels.openProactive(ref, { id: "diff", kind: "diff" }, loaded.userActionRevision),
238 ).toBe(false);
239 expect(
240 selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref)?.id,
241 ).toBe("file:src/second.ts");
242 },
243 );
244
119245 it("opens a pull request only after a newly observed link appears", () => {
120246 expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false);
121247 expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true);
......@@ -173,14 +299,12 @@ describe("proactive panels", () => {
173299 resolveProactiveTurnDiffAction({
174300 checkpoint: changedCheckpoint,
175301 isGitRepo: true,
176 activeSurfaceKind: null,
177302 }),
178303 ).toBe("open");
179304 expect(
180305 resolveProactiveTurnDiffAction({
181306 checkpoint: unchangedCheckpoint,
182307 isGitRepo: true,
183 activeSurfaceKind: null,
184308 }),
185309 ).toBe("ignore");
186310 });
......@@ -199,39 +323,21 @@ describe("proactive panels", () => {
199323 resolveProactiveTurnDiffAction({
200324 checkpoint: undefined,
201325 isGitRepo: true,
202 activeSurfaceKind: null,
203326 }),
204327 ).toBe("defer");
205328 expect(
206329 resolveProactiveTurnDiffAction({
207330 checkpoint: missingCheckpoint,
208331 isGitRepo: true,
209 activeSurfaceKind: null,
210332 }),
211333 ).toBe("defer");
212334 expect(
213335 resolveProactiveTurnDiffAction({
214336 checkpoint: changedCheckpoint,
215337 isGitRepo: undefined,
216 activeSurfaceKind: null,
217338 }),
218339 ).toBe("defer");
219340 });
220
221 it("keeps an active pull request above a completed turn diff", () => {
222 const changedCheckpoint = {
223 status: "ready",
224 files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }],
225 } satisfies Pick<TurnDiffSummary, "status" | "files">;
226
227 expect(
228 resolveProactiveTurnDiffAction({
229 checkpoint: changedCheckpoint,
230 isGitRepo: true,
231 activeSurfaceKind: "pull-request",
232 }),
233 ).toBe("ignore");
234 });
235341});
236342
237343describe("toolGroupConsumesUpwardNavigation", () => {
apps/web/src/components/ChatView.logic.ts+26-2
......@@ -106,6 +106,32 @@ export function shouldOpenProactivePullRequest(
106106 return previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey;
107107}
108108
109interface ProactivePanelObservation {
110 threadKey: string;
111 runningTurnId: TurnId | null | undefined;
112 targetKey: string | null | undefined;
113 userActionTurnId: TurnId | null;
114 userActionRevision: number;
115}
116
117/** Capture user intent before loading or metadata writes can defer panel activation. */
118export function observeProactivePanelUserChoice(
119 previous: ProactivePanelObservation | null,
120 input: { threadKey: string; runningTurnId: TurnId | null; userActionRevision: number },
121): ProactivePanelObservation {
122 const sameThread = previous?.threadKey === input.threadKey;
123 const newTurn =
124 sameThread && input.runningTurnId !== null && input.runningTurnId !== previous.userActionTurnId;
125 return {
126 threadKey: input.threadKey,
127 runningTurnId: sameThread ? previous.runningTurnId : undefined,
128 targetKey: sameThread ? previous.targetKey : undefined,
129 userActionTurnId: input.runningTurnId ?? (sameThread ? previous.userActionTurnId : null),
130 userActionRevision:
131 !sameThread || newTurn ? input.userActionRevision : previous.userActionRevision,
132 };
133}
134
109135export function shouldOpenProactiveTurnDiff(input: {
110136 previousRunningTurnId: TurnId | null | undefined;
111137 runningTurnId: TurnId | null;
......@@ -124,9 +150,7 @@ export function shouldOpenProactiveTurnDiff(input: {
124150export function resolveProactiveTurnDiffAction(input: {
125151 checkpoint: Pick<TurnDiffSummary, "status" | "files"> | undefined;
126152 isGitRepo: boolean | undefined;
127 activeSurfaceKind: RightPanelSurface["kind"] | null;
128153}): "defer" | "ignore" | "open" {
129 if (input.activeSurfaceKind === "pull-request") return "ignore";
130154 if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer";
131155 if (input.isGitRepo === undefined) return "defer";
132156 if (
apps/web/src/components/ChatView.tsx+65-84
......@@ -161,6 +161,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
161161import { useMediaQuery } from "../hooks/useMediaQuery";
162162import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
163163import {
164 pullRequestSurface,
164165 selectActiveRightPanel,
165166 selectActiveRightPanelSurface,
166167 selectThreadRightPanelState,
......@@ -396,6 +397,7 @@ import {
396397 resolveComposerInteractionMode,
397398 resolveComposerProviderSelection,
398399 resolveDraftHeroState,
400 observeProactivePanelUserChoice,
399401 resolveProactiveTurnDiffAction,
400402 resolveThreadMetadataUpdateForNextTurn,
401403 resolveSendEnvMode,
......@@ -4060,23 +4062,6 @@ export default function ChatView(props: ChatViewProps) {
40604062 const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`;
40614063 if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return;
40624064 threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey);
4063 const openSurface = selectActiveRightPanelSurface(
4064 useRightPanelStore.getState().byThreadKey,
4065 activeThreadRef,
4066 );
4067 if (
4068 openSurface?.kind === "pull-request" &&
4069 persistedLinkedThreadPullRequest !== null &&
4070 openSurface.projectId === persistedLinkedThreadPullRequest.projectId &&
4071 openSurface.repository.toLowerCase() ===
4072 persistedLinkedThreadPullRequest.repository.toLowerCase() &&
4073 openSurface.number === persistedLinkedThreadPullRequest.number
4074 ) {
4075 useRightPanelStore
4076 .getState()
4077 .openPullRequest(activeThreadRef, replacementLinkedThreadPullRequest);
4078 }
4079
40804065 threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => {
40814066 if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;
40824067 const result = await updateThreadMetadata({
......@@ -4102,7 +4087,6 @@ export default function ChatView(props: ChatViewProps) {
41024087 activeThreadKey,
41034088 activeThreadRef,
41044089 isServerThread,
4105 persistedLinkedThreadPullRequest,
41064090 replacementLinkedThreadPullRequest,
41074091 updateThreadMetadata,
41084092 ]);
......@@ -4124,29 +4108,47 @@ export default function ChatView(props: ChatViewProps) {
41244108 },
41254109 [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests],
41264110 );
4127 const proactiveTurnObservationRef = useRef<{
4128 threadKey: string;
4129 runningTurnId: TurnId | null;
4130 } | null>(null);
4131 const proactivePullRequestObservationRef = useRef<{
4132 threadKey: string;
4133 targetKey: string | null;
4134 } | null>(null);
4111 const proactivePanelObservationRef = useRef<ReturnType<
4112 typeof observeProactivePanelUserChoice
4113 > | null>(null);
41354114
41364115 useEffect(() => {
41374116 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
4138 proactiveTurnObservationRef.current = null;
4117 proactivePanelObservationRef.current = null;
41394118 return;
41404119 }
4141 if (!clientSettingsHydrated || threadDetailLoading) {
4142 return;
4120 const panels = useRightPanelStore.getState();
4121 const observation = observeProactivePanelUserChoice(proactivePanelObservationRef.current, {
4122 threadKey: activeThreadKey,
4123 runningTurnId: activeRunningTurnId,
4124 userActionRevision: panels.getUserActionRevision(activeThreadRef),
4125 });
4126 proactivePanelObservationRef.current = observation;
4127 const {
4128 runningTurnId: previousRunningTurnId,
4129 targetKey: previousTargetKey,
4130 userActionRevision,
4131 } = observation;
4132 const openSurface = selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef);
4133 const followSelectedPullRequest =
4134 replacementLinkedThreadPullRequest !== null &&
4135 openSurface?.kind === "pull-request" &&
4136 persistedLinkedThreadPullRequest !== null &&
4137 openSurface.projectId === persistedLinkedThreadPullRequest.projectId &&
4138 openSurface.repository.toLowerCase() ===
4139 persistedLinkedThreadPullRequest.repository.toLowerCase() &&
4140 openSurface.number === persistedLinkedThreadPullRequest.number;
4141 // Following the selected linked PR does not open an unrelated panel, so it
4142 // remains available with proactive panels off. It still respects a later choice.
4143 if (followSelectedPullRequest && replacementLinkedThreadPullRequest !== null) {
4144 panels.openProactive(
4145 activeThreadRef,
4146 pullRequestSurface(replacementLinkedThreadPullRequest),
4147 userActionRevision,
4148 );
41434149 }
4150 if (!clientSettingsHydrated || threadDetailLoading) return;
41444151
4145 const previousObservation = proactiveTurnObservationRef.current;
4146 const observingSameThread = previousObservation?.threadKey === activeThreadKey;
4147 const previousRunningTurnId = observingSameThread
4148 ? previousObservation.runningTurnId
4149 : undefined;
41504152 const settledTurnId = latestTurnSettled ? (activeLatestTurn?.turnId ?? null) : null;
41514153 const newlyCompletedTurnId = shouldOpenProactiveTurnDiff({
41524154 previousRunningTurnId,
......@@ -4156,8 +4158,8 @@ export default function ChatView(props: ChatViewProps) {
41564158 })
41574159 ? settledTurnId
41584160 : null;
4159 const eligibleCompletion =
4160 settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyCompletedTurnId !== null;
4161 const proactivePanelsEnabled = settings.proactivePanelsEnabled && !shouldUseRightPanelSheet;
4162 const eligibleCompletion = proactivePanelsEnabled && newlyCompletedTurnId !== null;
41614163 const completedCheckpoint = eligibleCompletion
41624164 ? activeThread?.checkpoints.find((checkpoint) => checkpoint.turnId === newlyCompletedTurnId)
41634165 : undefined;
......@@ -4165,17 +4167,36 @@ export default function ChatView(props: ChatViewProps) {
41654167 ? resolveProactiveTurnDiffAction({
41664168 checkpoint: completedCheckpoint,
41674169 isGitRepo: gitStatusQuery.data?.isRepo,
4168 activeSurfaceKind: activeRightPanelSurface?.kind ?? null,
41694170 })
41704171 : "ignore";
4171 proactiveTurnObservationRef.current = {
4172 threadKey: activeThreadKey,
4172 const eligibleLink =
4173 proactivePanelsEnabled &&
4174 shouldOpenProactivePullRequest(previousTargetKey, linkedThreadPullRequestKey);
4175 const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown;
4176 proactivePanelObservationRef.current = {
4177 ...observation,
41734178 runningTurnId: diffAction === "defer" ? (previousRunningTurnId ?? null) : activeRunningTurnId,
4179 targetKey: shouldDeferLink ? (previousTargetKey ?? null) : linkedThreadPullRequestKey,
41744180 };
4175 if (diffAction !== "open" || newlyCompletedTurnId === null) return;
41764181
4182 if (
4183 !followSelectedPullRequest &&
4184 eligibleLink &&
4185 pullRequestsCapabilityKnown &&
4186 supportsPullRequests &&
4187 linkedThreadPullRequest !== null
4188 ) {
4189 panels.openProactive(
4190 activeThreadRef,
4191 pullRequestSurface(linkedThreadPullRequest),
4192 userActionRevision,
4193 );
4194 }
4195 if (diffAction !== "open" || newlyCompletedTurnId === null) return;
4196 if (!panels.openProactive(activeThreadRef, { id: "diff", kind: "diff" }, userActionRevision)) {
4197 return;
4198 }
41774199 useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId);
4178 useRightPanelStore.getState().open(activeThreadRef, "diff");
41794200 onDiffPanelOpen?.();
41804201 }, [
41814202 activeThread?.checkpoints,
......@@ -4184,56 +4205,16 @@ export default function ChatView(props: ChatViewProps) {
41844205 activeRunningTurnId,
41854206 activeThreadKey,
41864207 activeThreadRef,
4187 activeRightPanelSurface?.kind,
41884208 clientSettingsHydrated,
41894209 gitStatusQuery.data?.isRepo,
41904210 isServerThread,
41914211 latestTurnSettled,
4192 onDiffPanelOpen,
4193 settings.proactivePanelsEnabled,
4194 shouldUseRightPanelSheet,
4195 threadDetailLoading,
4196 ]);
4197
4198 useEffect(() => {
4199 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
4200 proactivePullRequestObservationRef.current = null;
4201 return;
4202 }
4203 if (!clientSettingsHydrated || threadDetailLoading) {
4204 return;
4205 }
4206
4207 const previousObservation = proactivePullRequestObservationRef.current;
4208 const observingSameThread = previousObservation?.threadKey === activeThreadKey;
4209 const previousTargetKey = observingSameThread ? previousObservation.targetKey : undefined;
4210 const newlyLinkedPullRequest = shouldOpenProactivePullRequest(
4211 previousTargetKey,
4212 linkedThreadPullRequestKey,
4213 );
4214 const eligibleLink =
4215 settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyLinkedPullRequest;
4216 const shouldOpenLink =
4217 eligibleLink &&
4218 pullRequestsCapabilityKnown &&
4219 supportsPullRequests &&
4220 linkedThreadPullRequest !== null;
4221 const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown;
4222 proactivePullRequestObservationRef.current = {
4223 threadKey: activeThreadKey,
4224 targetKey: shouldDeferLink ? (previousTargetKey ?? null) : linkedThreadPullRequestKey,
4225 };
4226 if (!shouldOpenLink || linkedThreadPullRequest === null) return;
4227
4228 useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest);
4229 }, [
4230 activeThreadKey,
4231 activeThreadRef,
4232 clientSettingsHydrated,
4233 isServerThread,
42344212 linkedThreadPullRequest,
42354213 linkedThreadPullRequestKey,
4214 onDiffPanelOpen,
4215 persistedLinkedThreadPullRequest,
42364216 pullRequestsCapabilityKnown,
4217 replacementLinkedThreadPullRequest,
42374218 settings.proactivePanelsEnabled,
42384219 shouldUseRightPanelSheet,
42394220 supportsPullRequests,
apps/web/src/rightPanelStore.test.ts+106-1
......@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";
44
55import {
66 migratePersistedRightPanelState,
7 pullRequestSurface,
78 pullRequestSurfaceId,
89 selectActiveRightPanel,
910 selectActiveRightPanelSurface,
......@@ -16,10 +17,114 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"))
1617const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));
1718
1819beforeEach(() => {
19 useRightPanelStore.setState({ byThreadKey: {} });
20 useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} });
2021});
2122
2223describe("rightPanelStore", () => {
24 const completedDiff = { id: "diff", kind: "diff" } as const;
25 const linkedPullRequest = pullRequestSurface({
26 projectId: "project-a",
27 repository: "pingdotgg/t3code",
28 number: 42,
29 });
30
31 it.each(["diff-first", "pull-request-first"])(
32 "keeps the linked pull request above the completed diff with %s delivery",
33 (order) => {
34 const store = useRightPanelStore.getState();
35 const revision = store.getUserActionRevision(refA);
36 const requests =
37 order === "diff-first"
38 ? [completedDiff, linkedPullRequest]
39 : [linkedPullRequest, completedDiff];
40 for (const surface of requests) store.openProactive(refA, surface, revision);
41
42 expect(
43 selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA),
44 ).toEqual(linkedPullRequest);
45
46 store.open(refA, "diff");
47 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
48 },
49 );
50
51 it.each([
52 { choice: "file", choose: () => useRightPanelStore.getState().openFile(refA, "src/app.ts") },
53 {
54 choice: "pull request",
55 choose: () =>
56 useRightPanelStore.getState().openPullRequest(refA, { ...linkedPullRequest, number: 41 }),
57 },
58 { choice: "browser", choose: () => useRightPanelStore.getState().openBrowser(refA, "tab-a") },
59 {
60 choice: "terminal",
61 choose: () => useRightPanelStore.getState().openTerminal(refA, "term-1"),
62 },
63 {
64 choice: "same tab",
65 choose: () => useRightPanelStore.getState().activateSurface(refA, "diff"),
66 },
67 { choice: "hide", choose: () => useRightPanelStore.getState().close(refA) },
68 { choice: "toggle", choose: () => useRightPanelStore.getState().toggle(refA, "diff") },
69 { choice: "close all", choose: () => useRightPanelStore.getState().closeAllSurfaces(refA) },
70 {
71 choice: "terminal close",
72 choose: () => {
73 const store = useRightPanelStore.getState();
74 store.openTerminal(refA, "term-1");
75 store.closeTerminal(refA, "terminal:term-1", "term-1");
76 },
77 },
78 ])("keeps a later $choice choice when automatic requests arrive", ({ choose }) => {
79 const store = useRightPanelStore.getState();
80 store.open(refA, "diff");
81 const revision = store.getUserActionRevision(refA);
82 choose();
83 const chosen = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
84
85 expect(store.openProactive(refA, completedDiff, revision)).toBe(false);
86 expect(store.openProactive(refA, linkedPullRequest, revision)).toBe(false);
87 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toBe(
88 chosen,
89 );
90 });
91
92 it("allows automatic panels for a later turn after a manual choice", () => {
93 const store = useRightPanelStore.getState();
94 const firstTurnRevision = store.getUserActionRevision(refA);
95 store.openFile(refA, "src/app.ts");
96 expect(store.openProactive(refA, completedDiff, firstTurnRevision)).toBe(false);
97
98 const nextTurnRevision = store.getUserActionRevision(refA);
99 expect(store.openProactive(refA, completedDiff, nextTurnRevision)).toBe(true);
100 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
101 });
102
103 it("keeps manual choices scoped to their thread and environment", () => {
104 const otherEnvironment = scopeThreadRef("env-2" as EnvironmentId, refA.threadId);
105 const store = useRightPanelStore.getState();
106 const revision = store.getUserActionRevision(refA);
107 store.openFile(refB, "src/app.ts");
108 store.openFile(otherEnvironment, "src/app.ts");
109
110 expect(store.openProactive(refA, completedDiff, revision)).toBe(true);
111 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBe("file");
112 expect(
113 selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, otherEnvironment),
114 ).toBe("file");
115 });
116
117 it("does not treat resource reconciliation as a manual choice", () => {
118 const store = useRightPanelStore.getState();
119 store.openFile(refA, "src/app.ts");
120 const revision = store.getUserActionRevision(refA);
121 store.reconcileBrowserSurfaces(refA, ["agent-browser"]);
122 store.reconcileFileSurfaces(refA, false);
123
124 expect(store.openProactive(refA, completedDiff, revision)).toBe(true);
125 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
126 });
127
23128 it("drops the legacy singleton terminal surface during migration", () => {
24129 expect(
25130 migratePersistedRightPanelState({
apps/web/src/rightPanelStore.ts+131-64
......@@ -88,6 +88,18 @@ export interface ThreadRightPanelState {
8888
8989interface RightPanelStoreState {
9090 byThreadKey: Record<string, ThreadRightPanelState>;
91 /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */
92 userActionRevisionByThreadKey: Record<string, number>;
93 getUserActionRevision: (ref: ScopedThreadRef) => number;
94 /**
95 * Open a surface on behalf of the app, not the user. Refused when the user
96 * made a panel choice after `expectedUserActionRevision` was read.
97 */
98 openProactive: (
99 ref: ScopedThreadRef,
100 surface: Extract<RightPanelSurface, { kind: "diff" | "pull-request" }>,
101 expectedUserActionRevision: number,
102 ) => boolean;
91103 open: (
92104 ref: ScopedThreadRef,
93105 kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">,
......@@ -193,7 +205,7 @@ export function pullRequestSurfaceId(target: {
193205 return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`;
194206}
195207
196function pullRequestSurface(target: {
208export function pullRequestSurface(target: {
197209 environmentId?: string;
198210 projectId: string;
199211 repository: string;
......@@ -237,6 +249,29 @@ const updateThread = (
237249 return { ...byThreadKey, [threadKey]: next };
238250};
239251
252// Every store action is a user choice unless it goes through `automaticUpdate`.
253// Only `openProactive` and resource reconciliation are automatic, so a new
254// action counts as a user choice by default.
255const automaticUpdate = (
256 state: RightPanelStoreState,
257 threadKey: string,
258 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
259): Partial<RightPanelStoreState> => ({
260 byThreadKey: updateThread(state.byThreadKey, threadKey, updater),
261});
262
263const userAction = (
264 state: RightPanelStoreState,
265 threadKey: string,
266 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
267): Partial<RightPanelStoreState> => ({
268 byThreadKey: updateThread(state.byThreadKey, threadKey, updater),
269 userActionRevisionByThreadKey: {
270 ...state.userActionRevisionByThreadKey,
271 [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1,
272 },
273});
274
240275function normalizeRevealLine(line: number | undefined): number | null {
241276 if (line === undefined || !Number.isFinite(line)) return null;
242277 return Math.max(1, Math.trunc(line));
......@@ -359,37 +394,62 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
359394
360395export const useRightPanelStore = create<RightPanelStoreState>()(
361396 persist(
362 (set) => ({
397 (set, get) => ({
363398 byThreadKey: {},
399 userActionRevisionByThreadKey: {},
400 getUserActionRevision: (ref) =>
401 get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0,
402 openProactive: (ref, surface, expectedUserActionRevision) => {
403 let opened = false;
404 set((state) => {
405 const threadKey = scopedThreadKey(ref);
406 if (
407 (state.userActionRevisionByThreadKey[threadKey] ?? 0) !== expectedUserActionRevision
408 ) {
409 return state;
410 }
411 // A linked PR takes priority over a completed-turn diff. Manual actions
412 // always apply, and later user choices reject both proactive requests.
413 if (
414 surface.kind === "diff" &&
415 selectActiveRightPanel(state.byThreadKey, ref) === "pull-request"
416 ) {
417 return state;
418 }
419 opened = true;
420 return automaticUpdate(state, threadKey, (current) => upsertSurface(current, surface));
421 });
422 return opened;
423 },
364424 open: (ref, kind) =>
365 set((state) => ({
366 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
425 set((state) =>
426 userAction(state, scopedThreadKey(ref), (current) => {
367427 if (kind === "preview") {
368428 const existing = current.surfaces.find((surface) => surface.kind === "preview");
369429 return upsertSurface(current, existing ?? browserSurface(null));
370430 }
371431 return upsertSurface(current, singletonSurface(kind));
372432 }),
373 })),
433 ),
374434 openBrowser: (ref, tabId) =>
375 set((state) => ({
376 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
435 set((state) =>
436 userAction(state, scopedThreadKey(ref), (current) => {
377437 const surface = browserSurface(tabId);
378438 const withoutPlaceholder = tabId
379439 ? current.surfaces.filter((entry) => entry.id !== "browser:new")
380440 : current.surfaces;
381441 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);
382442 }),
383 })),
443 ),
384444 openPullRequest: (ref, target) =>
385 set((state) => ({
386 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
445 set((state) =>
446 userAction(state, scopedThreadKey(ref), (current) => {
387447 return upsertSurface(current, pullRequestSurface(target));
388448 }),
389 })),
449 ),
390450 openFile: (ref, relativePath, line) =>
391 set((state) => ({
392 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
451 set((state) =>
452 userAction(state, scopedThreadKey(ref), (current) => {
393453 const withoutStandaloneExplorer = current.surfaces.filter(
394454 (surface) => surface.kind !== "files",
395455 );
......@@ -413,10 +473,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
413473 : [...withoutStandaloneExplorer, surface],
414474 };
415475 }),
416 })),
476 ),
417477 openAttachment: (ref, attachment) =>
418 set((state) => ({
419 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
478 set((state) =>
479 userAction(state, scopedThreadKey(ref), (current) => {
420480 const withoutStandaloneExplorer = current.surfaces.filter(
421481 (surface) => surface.kind !== "files",
422482 );
......@@ -425,16 +485,16 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
425485 attachmentSurface(attachment),
426486 );
427487 }),
428 })),
488 ),
429489 openTerminal: (ref, terminalId) =>
430 set((state) => ({
431 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
490 set((state) =>
491 userAction(state, scopedThreadKey(ref), (current) =>
432492 upsertSurface(current, terminalSurface(terminalId)),
433493 ),
434 })),
494 ),
435495 splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") =>
436 set((state) => ({
437 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({
496 set((state) =>
497 userAction(state, scopedThreadKey(ref), (current) => ({
438498 ...current,
439499 isOpen: true,
440500 activeSurfaceId: surfaceId,
......@@ -451,10 +511,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
451511 };
452512 }),
453513 })),
454 })),
514 ),
455515 activateTerminal: (ref, surfaceId, terminalId) =>
456 set((state) => ({
457 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({
516 set((state) =>
517 userAction(state, scopedThreadKey(ref), (current) => ({
458518 ...current,
459519 activeSurfaceId: surfaceId,
460520 surfaces: current.surfaces.map((surface) =>
......@@ -465,10 +525,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
465525 : surface,
466526 ),
467527 })),
468 })),
528 ),
469529 closeTerminal: (ref, surfaceId, terminalId) =>
470 set((state) => ({
471 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
530 set((state) =>
531 userAction(state, scopedThreadKey(ref), (current) => {
472532 const surface = current.surfaces.find(
473533 (entry) => entry.id === surfaceId && entry.kind === "terminal",
474534 );
......@@ -504,18 +564,18 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
504564 ),
505565 };
506566 }),
507 })),
567 ),
508568 activateSurface: (ref, surfaceId) =>
509 set((state) => ({
510 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
569 set((state) =>
570 userAction(state, scopedThreadKey(ref), (current) =>
511571 current.surfaces.some((surface) => surface.id === surfaceId)
512572 ? { ...current, isOpen: true, activeSurfaceId: surfaceId }
513573 : current,
514574 ),
515 })),
575 ),
516576 closeSurface: (ref, surfaceId) =>
517 set((state) => ({
518 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
577 set((state) =>
578 userAction(state, scopedThreadKey(ref), (current) => {
519579 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
520580 if (index < 0) return current;
521581 const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);
......@@ -530,10 +590,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
530590 activeSurfaceId: fallback?.id ?? null,
531591 };
532592 }),
533 })),
593 ),
534594 closeOtherSurfaces: (ref, surfaceId) =>
535 set((state) => ({
536 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
595 set((state) =>
596 userAction(state, scopedThreadKey(ref), (current) => {
537597 const surface = current.surfaces.find((entry) => entry.id === surfaceId);
538598 if (!surface || current.surfaces.length === 1) return current;
539599 return {
......@@ -543,10 +603,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
543603 activeSurfaceId: surface.id,
544604 };
545605 }),
546 })),
606 ),
547607 closeSurfacesToRight: (ref, surfaceId) =>
548 set((state) => ({
549 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
608 set((state) =>
609 userAction(state, scopedThreadKey(ref), (current) => {
550610 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
551611 if (index < 0 || index === current.surfaces.length - 1) return current;
552612 const surfaces = current.surfaces.slice(0, index + 1);
......@@ -559,18 +619,18 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
559619 activeSurfaceId: activeStillExists ? current.activeSurfaceId : surfaceId,
560620 };
561621 }),
562 })),
622 ),
563623 closeAllSurfaces: (ref) =>
564 set((state) => ({
565 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
624 set((state) =>
625 userAction(state, scopedThreadKey(ref), (current) =>
566626 current.surfaces.length === 0
567627 ? current
568628 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },
569629 ),
570 })),
630 ),
571631 reconcileBrowserSurfaces: (ref, tabIds) =>
572 set((state) => ({
573 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
632 set((state) =>
633 automaticUpdate(state, scopedThreadKey(ref), (current) => {
574634 const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`));
575635 const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview");
576636 const existingBrowser = current.surfaces.filter(
......@@ -596,10 +656,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
596656 : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null),
597657 };
598658 }),
599 })),
659 ),
600660 reconcileFileSurfaces: (ref, workspaceAvailable) =>
601 set((state) => ({
602 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
661 set((state) =>
662 automaticUpdate(state, scopedThreadKey(ref), (current) => {
603663 if (workspaceAvailable) return current;
604664 const surfaces = current.surfaces.filter(
605665 (surface) =>
......@@ -619,29 +679,29 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
619679 : (surfaces.at(-1)?.id ?? null),
620680 };
621681 }),
622 })),
682 ),
623683 show: (ref) =>
624 set((state) => ({
625 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
684 set((state) =>
685 userAction(state, scopedThreadKey(ref), (current) =>
626686 current.isOpen ? current : { ...current, isOpen: true },
627687 ),
628 })),
688 ),
629689 close: (ref) =>
630 set((state) => ({
631 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
690 set((state) =>
691 userAction(state, scopedThreadKey(ref), (current) =>
632692 current.isOpen ? { ...current, isOpen: false } : current,
633693 ),
634 })),
694 ),
635695 toggleVisibility: (ref) =>
636 set((state) => ({
637 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({
696 set((state) =>
697 userAction(state, scopedThreadKey(ref), (current) => ({
638698 ...current,
639699 isOpen: !current.isOpen,
640700 })),
641 })),
701 ),
642702 toggle: (ref, kind) =>
643 set((state) => ({
644 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
703 set((state) =>
704 userAction(state, scopedThreadKey(ref), (current) => {
645705 const active = current.surfaces.find(
646706 (surface) => surface.id === current.activeSurfaceId,
647707 );
......@@ -654,13 +714,20 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
654714 }
655715 return upsertSurface(current, singletonSurface(kind));
656716 }),
657 })),
717 ),
658718 removeThread: (ref) =>
659719 set((state) => {
660720 const threadKey = scopedThreadKey(ref);
661 if (!(threadKey in state.byThreadKey)) return state;
721 if (
722 !(threadKey in state.byThreadKey) &&
723 !(threadKey in state.userActionRevisionByThreadKey)
724 ) {
725 return state;
726 }
662727 const { [threadKey]: _removed, ...rest } = state.byThreadKey;
663 return { byThreadKey: rest };
728 const { [threadKey]: _revision, ...userActionRevisionByThreadKey } =
729 state.userActionRevisionByThreadKey;
730 return { byThreadKey: rest, userActionRevisionByThreadKey };
664731 }),
665732 }),
666733 {