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"...@@ -14,7 +14,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
14import type { Thread, ThreadShell, TurnDiffSummary } from "../types";14import type { Thread, ThreadShell, TurnDiffSummary } from "../types";
15import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";15import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
16import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";16import 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";
18import {28import {
19 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,29 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
20 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,30 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
...@@ -39,6 +49,7 @@ import {...@@ -39,6 +49,7 @@ import {
39 resolveComposerInteractionMode,49 resolveComposerInteractionMode,
40 resolveComposerProviderSelection,50 resolveComposerProviderSelection,
41 resolveDraftPromotionNavigationTarget,51 resolveDraftPromotionNavigationTarget,
52 observeProactivePanelUserChoice,
42 resolveProactiveTurnDiffAction,53 resolveProactiveTurnDiffAction,
43 resolveThreadMetadataUpdateForNextTurn,54 resolveThreadMetadataUpdateForNextTurn,
44 resolveSendEnvMode,55 resolveSendEnvMode,
...@@ -95,6 +106,37 @@ describe("agent browser close confirmation", () => {...@@ -95,6 +106,37 @@ describe("agent browser close confirmation", () => {
95});106});
96107
97describe("floating browser preview", () => {108describe("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
98 it("only hides the duplicate while the same browser is rendered in the panel", () => {140 it("only hides the duplicate while the same browser is rendered in the panel", () => {
99 expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false);141 expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false);
100 expect(142 expect(
...@@ -116,6 +158,90 @@ describe("floating browser preview", () => {...@@ -116,6 +158,90 @@ describe("floating browser preview", () => {
116});158});
117159
118describe("proactive panels", () => {160describe("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
119 it("opens a pull request only after a newly observed link appears", () => {245 it("opens a pull request only after a newly observed link appears", () => {
120 expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false);246 expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false);
121 expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true);247 expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true);
...@@ -173,14 +299,12 @@ describe("proactive panels", () => {...@@ -173,14 +299,12 @@ describe("proactive panels", () => {
173 resolveProactiveTurnDiffAction({299 resolveProactiveTurnDiffAction({
174 checkpoint: changedCheckpoint,300 checkpoint: changedCheckpoint,
175 isGitRepo: true,301 isGitRepo: true,
176 activeSurfaceKind: null,
177 }),302 }),
178 ).toBe("open");303 ).toBe("open");
179 expect(304 expect(
180 resolveProactiveTurnDiffAction({305 resolveProactiveTurnDiffAction({
181 checkpoint: unchangedCheckpoint,306 checkpoint: unchangedCheckpoint,
182 isGitRepo: true,307 isGitRepo: true,
183 activeSurfaceKind: null,
184 }),308 }),
185 ).toBe("ignore");309 ).toBe("ignore");
186 });310 });
...@@ -199,39 +323,21 @@ describe("proactive panels", () => {...@@ -199,39 +323,21 @@ describe("proactive panels", () => {
199 resolveProactiveTurnDiffAction({323 resolveProactiveTurnDiffAction({
200 checkpoint: undefined,324 checkpoint: undefined,
201 isGitRepo: true,325 isGitRepo: true,
202 activeSurfaceKind: null,
203 }),326 }),
204 ).toBe("defer");327 ).toBe("defer");
205 expect(328 expect(
206 resolveProactiveTurnDiffAction({329 resolveProactiveTurnDiffAction({
207 checkpoint: missingCheckpoint,330 checkpoint: missingCheckpoint,
208 isGitRepo: true,331 isGitRepo: true,
209 activeSurfaceKind: null,
210 }),332 }),
211 ).toBe("defer");333 ).toBe("defer");
212 expect(334 expect(
213 resolveProactiveTurnDiffAction({335 resolveProactiveTurnDiffAction({
214 checkpoint: changedCheckpoint,336 checkpoint: changedCheckpoint,
215 isGitRepo: undefined,337 isGitRepo: undefined,
216 activeSurfaceKind: null,
217 }),338 }),
218 ).toBe("defer");339 ).toBe("defer");
219 });340 });
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 });
235});341});
236342
237describe("toolGroupConsumesUpwardNavigation", () => {343describe("toolGroupConsumesUpwardNavigation", () => {
apps/web/src/components/ChatView.logic.ts+26-2
...@@ -106,6 +106,32 @@ export function shouldOpenProactivePullRequest(...@@ -106,6 +106,32 @@ export function shouldOpenProactivePullRequest(
106 return previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey;106 return previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey;
107}107}
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
109export function shouldOpenProactiveTurnDiff(input: {135export function shouldOpenProactiveTurnDiff(input: {
110 previousRunningTurnId: TurnId | null | undefined;136 previousRunningTurnId: TurnId | null | undefined;
111 runningTurnId: TurnId | null;137 runningTurnId: TurnId | null;
...@@ -124,9 +150,7 @@ export function shouldOpenProactiveTurnDiff(input: {...@@ -124,9 +150,7 @@ export function shouldOpenProactiveTurnDiff(input: {
124export function resolveProactiveTurnDiffAction(input: {150export function resolveProactiveTurnDiffAction(input: {
125 checkpoint: Pick<TurnDiffSummary, "status" | "files"> | undefined;151 checkpoint: Pick<TurnDiffSummary, "status" | "files"> | undefined;
126 isGitRepo: boolean | undefined;152 isGitRepo: boolean | undefined;
127 activeSurfaceKind: RightPanelSurface["kind"] | null;
128}): "defer" | "ignore" | "open" {153}): "defer" | "ignore" | "open" {
129 if (input.activeSurfaceKind === "pull-request") return "ignore";
130 if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer";154 if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer";
131 if (input.isGitRepo === undefined) return "defer";155 if (input.isGitRepo === undefined) return "defer";
132 if (156 if (
apps/web/src/components/ChatView.tsx+65-84
...@@ -161,6 +161,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";...@@ -161,6 +161,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
161import { useMediaQuery } from "../hooks/useMediaQuery";161import { useMediaQuery } from "../hooks/useMediaQuery";
162import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";162import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
163import {163import {
164 pullRequestSurface,
164 selectActiveRightPanel,165 selectActiveRightPanel,
165 selectActiveRightPanelSurface,166 selectActiveRightPanelSurface,
166 selectThreadRightPanelState,167 selectThreadRightPanelState,
...@@ -396,6 +397,7 @@ import {...@@ -396,6 +397,7 @@ import {
396 resolveComposerInteractionMode,397 resolveComposerInteractionMode,
397 resolveComposerProviderSelection,398 resolveComposerProviderSelection,
398 resolveDraftHeroState,399 resolveDraftHeroState,
400 observeProactivePanelUserChoice,
399 resolveProactiveTurnDiffAction,401 resolveProactiveTurnDiffAction,
400 resolveThreadMetadataUpdateForNextTurn,402 resolveThreadMetadataUpdateForNextTurn,
401 resolveSendEnvMode,403 resolveSendEnvMode,
...@@ -4060,23 +4062,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -4060,23 +4062,6 @@ export default function ChatView(props: ChatViewProps) {
4060 const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`;4062 const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`;
4061 if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return;4063 if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return;
4062 threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey);4064 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
4080 threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => {4065 threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => {
4081 if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;4066 if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;
4082 const result = await updateThreadMetadata({4067 const result = await updateThreadMetadata({
...@@ -4102,7 +4087,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -4102,7 +4087,6 @@ export default function ChatView(props: ChatViewProps) {
4102 activeThreadKey,4087 activeThreadKey,
4103 activeThreadRef,4088 activeThreadRef,
4104 isServerThread,4089 isServerThread,
4105 persistedLinkedThreadPullRequest,
4106 replacementLinkedThreadPullRequest,4090 replacementLinkedThreadPullRequest,
4107 updateThreadMetadata,4091 updateThreadMetadata,
4108 ]);4092 ]);
...@@ -4124,29 +4108,47 @@ export default function ChatView(props: ChatViewProps) {...@@ -4124,29 +4108,47 @@ export default function ChatView(props: ChatViewProps) {
4124 },4108 },
4125 [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests],4109 [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests],
4126 );4110 );
4127 const proactiveTurnObservationRef = useRef<{4111 const proactivePanelObservationRef = useRef<ReturnType<
4128 threadKey: string;4112 typeof observeProactivePanelUserChoice
4129 runningTurnId: TurnId | null;4113 > | null>(null);
4130 } | null>(null);
4131 const proactivePullRequestObservationRef = useRef<{
4132 threadKey: string;
4133 targetKey: string | null;
4134 } | null>(null);
41354114
4136 useEffect(() => {4115 useEffect(() => {
4137 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {4116 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
4138 proactiveTurnObservationRef.current = null;4117 proactivePanelObservationRef.current = null;
4139 return;4118 return;
4140 }4119 }
4141 if (!clientSettingsHydrated || threadDetailLoading) {4120 const panels = useRightPanelStore.getState();
4142 return;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 );
4143 }4149 }
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;
4150 const settledTurnId = latestTurnSettled ? (activeLatestTurn?.turnId ?? null) : null;4152 const settledTurnId = latestTurnSettled ? (activeLatestTurn?.turnId ?? null) : null;
4151 const newlyCompletedTurnId = shouldOpenProactiveTurnDiff({4153 const newlyCompletedTurnId = shouldOpenProactiveTurnDiff({
4152 previousRunningTurnId,4154 previousRunningTurnId,
...@@ -4156,8 +4158,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -4156,8 +4158,8 @@ export default function ChatView(props: ChatViewProps) {
4156 })4158 })
4157 ? settledTurnId4159 ? settledTurnId
4158 : null;4160 : null;
4159 const eligibleCompletion =4161 const proactivePanelsEnabled = settings.proactivePanelsEnabled && !shouldUseRightPanelSheet;
4160 settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyCompletedTurnId !== null;4162 const eligibleCompletion = proactivePanelsEnabled && newlyCompletedTurnId !== null;
4161 const completedCheckpoint = eligibleCompletion4163 const completedCheckpoint = eligibleCompletion
4162 ? activeThread?.checkpoints.find((checkpoint) => checkpoint.turnId === newlyCompletedTurnId)4164 ? activeThread?.checkpoints.find((checkpoint) => checkpoint.turnId === newlyCompletedTurnId)
4163 : undefined;4165 : undefined;
...@@ -4165,17 +4167,36 @@ export default function ChatView(props: ChatViewProps) {...@@ -4165,17 +4167,36 @@ export default function ChatView(props: ChatViewProps) {
4165 ? resolveProactiveTurnDiffAction({4167 ? resolveProactiveTurnDiffAction({
4166 checkpoint: completedCheckpoint,4168 checkpoint: completedCheckpoint,
4167 isGitRepo: gitStatusQuery.data?.isRepo,4169 isGitRepo: gitStatusQuery.data?.isRepo,
4168 activeSurfaceKind: activeRightPanelSurface?.kind ?? null,
4169 })4170 })
4170 : "ignore";4171 : "ignore";
4171 proactiveTurnObservationRef.current = {4172 const eligibleLink =
4172 threadKey: activeThreadKey,4173 proactivePanelsEnabled &&
4174 shouldOpenProactivePullRequest(previousTargetKey, linkedThreadPullRequestKey);
4175 const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown;
4176 proactivePanelObservationRef.current = {
4177 ...observation,
4173 runningTurnId: diffAction === "defer" ? (previousRunningTurnId ?? null) : activeRunningTurnId,4178 runningTurnId: diffAction === "defer" ? (previousRunningTurnId ?? null) : activeRunningTurnId,
4179 targetKey: shouldDeferLink ? (previousTargetKey ?? null) : linkedThreadPullRequestKey,
4174 };4180 };
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 }
4177 useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId);4199 useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId);
4178 useRightPanelStore.getState().open(activeThreadRef, "diff");
4179 onDiffPanelOpen?.();4200 onDiffPanelOpen?.();
4180 }, [4201 }, [
4181 activeThread?.checkpoints,4202 activeThread?.checkpoints,
...@@ -4184,56 +4205,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -4184,56 +4205,16 @@ export default function ChatView(props: ChatViewProps) {
4184 activeRunningTurnId,4205 activeRunningTurnId,
4185 activeThreadKey,4206 activeThreadKey,
4186 activeThreadRef,4207 activeThreadRef,
4187 activeRightPanelSurface?.kind,
4188 clientSettingsHydrated,4208 clientSettingsHydrated,
4189 gitStatusQuery.data?.isRepo,4209 gitStatusQuery.data?.isRepo,
4190 isServerThread,4210 isServerThread,
4191 latestTurnSettled,4211 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,
4234 linkedThreadPullRequest,4212 linkedThreadPullRequest,
4235 linkedThreadPullRequestKey,4213 linkedThreadPullRequestKey,
4214 onDiffPanelOpen,
4215 persistedLinkedThreadPullRequest,
4236 pullRequestsCapabilityKnown,4216 pullRequestsCapabilityKnown,
4217 replacementLinkedThreadPullRequest,
4237 settings.proactivePanelsEnabled,4218 settings.proactivePanelsEnabled,
4238 shouldUseRightPanelSheet,4219 shouldUseRightPanelSheet,
4239 supportsPullRequests,4220 supportsPullRequests,
apps/web/src/rightPanelStore.test.ts+106-1
...@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";...@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";
44
5import {5import {
6 migratePersistedRightPanelState,6 migratePersistedRightPanelState,
7 pullRequestSurface,
7 pullRequestSurfaceId,8 pullRequestSurfaceId,
8 selectActiveRightPanel,9 selectActiveRightPanel,
9 selectActiveRightPanelSurface,10 selectActiveRightPanelSurface,
...@@ -16,10 +17,114 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"))...@@ -16,10 +17,114 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"))
16const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));17const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));
1718
18beforeEach(() => {19beforeEach(() => {
19 useRightPanelStore.setState({ byThreadKey: {} });20 useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} });
20});21});
2122
22describe("rightPanelStore", () => {23describe("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
23 it("drops the legacy singleton terminal surface during migration", () => {128 it("drops the legacy singleton terminal surface during migration", () => {
24 expect(129 expect(
25 migratePersistedRightPanelState({130 migratePersistedRightPanelState({
apps/web/src/rightPanelStore.ts+131-64
...@@ -88,6 +88,18 @@ export interface ThreadRightPanelState {...@@ -88,6 +88,18 @@ export interface ThreadRightPanelState {
8888
89interface RightPanelStoreState {89interface RightPanelStoreState {
90 byThreadKey: Record<string, ThreadRightPanelState>;90 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;
91 open: (103 open: (
92 ref: ScopedThreadRef,104 ref: ScopedThreadRef,
93 kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">,105 kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">,
...@@ -193,7 +205,7 @@ export function pullRequestSurfaceId(target: {...@@ -193,7 +205,7 @@ export function pullRequestSurfaceId(target: {
193 return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`;205 return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`;
194}206}
195207
196function pullRequestSurface(target: {208export function pullRequestSurface(target: {
197 environmentId?: string;209 environmentId?: string;
198 projectId: string;210 projectId: string;
199 repository: string;211 repository: string;
...@@ -237,6 +249,29 @@ const updateThread = (...@@ -237,6 +249,29 @@ const updateThread = (
237 return { ...byThreadKey, [threadKey]: next };249 return { ...byThreadKey, [threadKey]: next };
238};250};
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
240function normalizeRevealLine(line: number | undefined): number | null {275function normalizeRevealLine(line: number | undefined): number | null {
241 if (line === undefined || !Number.isFinite(line)) return null;276 if (line === undefined || !Number.isFinite(line)) return null;
242 return Math.max(1, Math.trunc(line));277 return Math.max(1, Math.trunc(line));
...@@ -359,37 +394,62 @@ export function migratePersistedRightPanelState(persistedState: unknown): {...@@ -359,37 +394,62 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
359394
360export const useRightPanelStore = create<RightPanelStoreState>()(395export const useRightPanelStore = create<RightPanelStoreState>()(
361 persist(396 persist(
362 (set) => ({397 (set, get) => ({
363 byThreadKey: {},398 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 },
364 open: (ref, kind) =>424 open: (ref, kind) =>
365 set((state) => ({425 set((state) =>
366 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {426 userAction(state, scopedThreadKey(ref), (current) => {
367 if (kind === "preview") {427 if (kind === "preview") {
368 const existing = current.surfaces.find((surface) => surface.kind === "preview");428 const existing = current.surfaces.find((surface) => surface.kind === "preview");
369 return upsertSurface(current, existing ?? browserSurface(null));429 return upsertSurface(current, existing ?? browserSurface(null));
370 }430 }
371 return upsertSurface(current, singletonSurface(kind));431 return upsertSurface(current, singletonSurface(kind));
372 }),432 }),
373 })),433 ),
374 openBrowser: (ref, tabId) =>434 openBrowser: (ref, tabId) =>
375 set((state) => ({435 set((state) =>
376 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {436 userAction(state, scopedThreadKey(ref), (current) => {
377 const surface = browserSurface(tabId);437 const surface = browserSurface(tabId);
378 const withoutPlaceholder = tabId438 const withoutPlaceholder = tabId
379 ? current.surfaces.filter((entry) => entry.id !== "browser:new")439 ? current.surfaces.filter((entry) => entry.id !== "browser:new")
380 : current.surfaces;440 : current.surfaces;
381 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);441 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);
382 }),442 }),
383 })),443 ),
384 openPullRequest: (ref, target) =>444 openPullRequest: (ref, target) =>
385 set((state) => ({445 set((state) =>
386 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {446 userAction(state, scopedThreadKey(ref), (current) => {
387 return upsertSurface(current, pullRequestSurface(target));447 return upsertSurface(current, pullRequestSurface(target));
388 }),448 }),
389 })),449 ),
390 openFile: (ref, relativePath, line) =>450 openFile: (ref, relativePath, line) =>
391 set((state) => ({451 set((state) =>
392 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {452 userAction(state, scopedThreadKey(ref), (current) => {
393 const withoutStandaloneExplorer = current.surfaces.filter(453 const withoutStandaloneExplorer = current.surfaces.filter(
394 (surface) => surface.kind !== "files",454 (surface) => surface.kind !== "files",
395 );455 );
...@@ -413,10 +473,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -413,10 +473,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
413 : [...withoutStandaloneExplorer, surface],473 : [...withoutStandaloneExplorer, surface],
414 };474 };
415 }),475 }),
416 })),476 ),
417 openAttachment: (ref, attachment) =>477 openAttachment: (ref, attachment) =>
418 set((state) => ({478 set((state) =>
419 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {479 userAction(state, scopedThreadKey(ref), (current) => {
420 const withoutStandaloneExplorer = current.surfaces.filter(480 const withoutStandaloneExplorer = current.surfaces.filter(
421 (surface) => surface.kind !== "files",481 (surface) => surface.kind !== "files",
422 );482 );
...@@ -425,16 +485,16 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -425,16 +485,16 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
425 attachmentSurface(attachment),485 attachmentSurface(attachment),
426 );486 );
427 }),487 }),
428 })),488 ),
429 openTerminal: (ref, terminalId) =>489 openTerminal: (ref, terminalId) =>
430 set((state) => ({490 set((state) =>
431 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>491 userAction(state, scopedThreadKey(ref), (current) =>
432 upsertSurface(current, terminalSurface(terminalId)),492 upsertSurface(current, terminalSurface(terminalId)),
433 ),493 ),
434 })),494 ),
435 splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") =>495 splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") =>
436 set((state) => ({496 set((state) =>
437 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({497 userAction(state, scopedThreadKey(ref), (current) => ({
438 ...current,498 ...current,
439 isOpen: true,499 isOpen: true,
440 activeSurfaceId: surfaceId,500 activeSurfaceId: surfaceId,
...@@ -451,10 +511,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -451,10 +511,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
451 };511 };
452 }),512 }),
453 })),513 })),
454 })),514 ),
455 activateTerminal: (ref, surfaceId, terminalId) =>515 activateTerminal: (ref, surfaceId, terminalId) =>
456 set((state) => ({516 set((state) =>
457 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({517 userAction(state, scopedThreadKey(ref), (current) => ({
458 ...current,518 ...current,
459 activeSurfaceId: surfaceId,519 activeSurfaceId: surfaceId,
460 surfaces: current.surfaces.map((surface) =>520 surfaces: current.surfaces.map((surface) =>
...@@ -465,10 +525,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -465,10 +525,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
465 : surface,525 : surface,
466 ),526 ),
467 })),527 })),
468 })),528 ),
469 closeTerminal: (ref, surfaceId, terminalId) =>529 closeTerminal: (ref, surfaceId, terminalId) =>
470 set((state) => ({530 set((state) =>
471 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {531 userAction(state, scopedThreadKey(ref), (current) => {
472 const surface = current.surfaces.find(532 const surface = current.surfaces.find(
473 (entry) => entry.id === surfaceId && entry.kind === "terminal",533 (entry) => entry.id === surfaceId && entry.kind === "terminal",
474 );534 );
...@@ -504,18 +564,18 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -504,18 +564,18 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
504 ),564 ),
505 };565 };
506 }),566 }),
507 })),567 ),
508 activateSurface: (ref, surfaceId) =>568 activateSurface: (ref, surfaceId) =>
509 set((state) => ({569 set((state) =>
510 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>570 userAction(state, scopedThreadKey(ref), (current) =>
511 current.surfaces.some((surface) => surface.id === surfaceId)571 current.surfaces.some((surface) => surface.id === surfaceId)
512 ? { ...current, isOpen: true, activeSurfaceId: surfaceId }572 ? { ...current, isOpen: true, activeSurfaceId: surfaceId }
513 : current,573 : current,
514 ),574 ),
515 })),575 ),
516 closeSurface: (ref, surfaceId) =>576 closeSurface: (ref, surfaceId) =>
517 set((state) => ({577 set((state) =>
518 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {578 userAction(state, scopedThreadKey(ref), (current) => {
519 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);579 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
520 if (index < 0) return current;580 if (index < 0) return current;
521 const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);581 const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);
...@@ -530,10 +590,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -530,10 +590,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
530 activeSurfaceId: fallback?.id ?? null,590 activeSurfaceId: fallback?.id ?? null,
531 };591 };
532 }),592 }),
533 })),593 ),
534 closeOtherSurfaces: (ref, surfaceId) =>594 closeOtherSurfaces: (ref, surfaceId) =>
535 set((state) => ({595 set((state) =>
536 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {596 userAction(state, scopedThreadKey(ref), (current) => {
537 const surface = current.surfaces.find((entry) => entry.id === surfaceId);597 const surface = current.surfaces.find((entry) => entry.id === surfaceId);
538 if (!surface || current.surfaces.length === 1) return current;598 if (!surface || current.surfaces.length === 1) return current;
539 return {599 return {
...@@ -543,10 +603,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -543,10 +603,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
543 activeSurfaceId: surface.id,603 activeSurfaceId: surface.id,
544 };604 };
545 }),605 }),
546 })),606 ),
547 closeSurfacesToRight: (ref, surfaceId) =>607 closeSurfacesToRight: (ref, surfaceId) =>
548 set((state) => ({608 set((state) =>
549 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {609 userAction(state, scopedThreadKey(ref), (current) => {
550 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);610 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
551 if (index < 0 || index === current.surfaces.length - 1) return current;611 if (index < 0 || index === current.surfaces.length - 1) return current;
552 const surfaces = current.surfaces.slice(0, index + 1);612 const surfaces = current.surfaces.slice(0, index + 1);
...@@ -559,18 +619,18 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -559,18 +619,18 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
559 activeSurfaceId: activeStillExists ? current.activeSurfaceId : surfaceId,619 activeSurfaceId: activeStillExists ? current.activeSurfaceId : surfaceId,
560 };620 };
561 }),621 }),
562 })),622 ),
563 closeAllSurfaces: (ref) =>623 closeAllSurfaces: (ref) =>
564 set((state) => ({624 set((state) =>
565 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>625 userAction(state, scopedThreadKey(ref), (current) =>
566 current.surfaces.length === 0626 current.surfaces.length === 0
567 ? current627 ? current
568 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },628 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },
569 ),629 ),
570 })),630 ),
571 reconcileBrowserSurfaces: (ref, tabIds) =>631 reconcileBrowserSurfaces: (ref, tabIds) =>
572 set((state) => ({632 set((state) =>
573 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {633 automaticUpdate(state, scopedThreadKey(ref), (current) => {
574 const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`));634 const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`));
575 const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview");635 const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview");
576 const existingBrowser = current.surfaces.filter(636 const existingBrowser = current.surfaces.filter(
...@@ -596,10 +656,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -596,10 +656,10 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
596 : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null),656 : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null),
597 };657 };
598 }),658 }),
599 })),659 ),
600 reconcileFileSurfaces: (ref, workspaceAvailable) =>660 reconcileFileSurfaces: (ref, workspaceAvailable) =>
601 set((state) => ({661 set((state) =>
602 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {662 automaticUpdate(state, scopedThreadKey(ref), (current) => {
603 if (workspaceAvailable) return current;663 if (workspaceAvailable) return current;
604 const surfaces = current.surfaces.filter(664 const surfaces = current.surfaces.filter(
605 (surface) =>665 (surface) =>
...@@ -619,29 +679,29 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -619,29 +679,29 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
619 : (surfaces.at(-1)?.id ?? null),679 : (surfaces.at(-1)?.id ?? null),
620 };680 };
621 }),681 }),
622 })),682 ),
623 show: (ref) =>683 show: (ref) =>
624 set((state) => ({684 set((state) =>
625 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>685 userAction(state, scopedThreadKey(ref), (current) =>
626 current.isOpen ? current : { ...current, isOpen: true },686 current.isOpen ? current : { ...current, isOpen: true },
627 ),687 ),
628 })),688 ),
629 close: (ref) =>689 close: (ref) =>
630 set((state) => ({690 set((state) =>
631 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>691 userAction(state, scopedThreadKey(ref), (current) =>
632 current.isOpen ? { ...current, isOpen: false } : current,692 current.isOpen ? { ...current, isOpen: false } : current,
633 ),693 ),
634 })),694 ),
635 toggleVisibility: (ref) =>695 toggleVisibility: (ref) =>
636 set((state) => ({696 set((state) =>
637 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({697 userAction(state, scopedThreadKey(ref), (current) => ({
638 ...current,698 ...current,
639 isOpen: !current.isOpen,699 isOpen: !current.isOpen,
640 })),700 })),
641 })),701 ),
642 toggle: (ref, kind) =>702 toggle: (ref, kind) =>
643 set((state) => ({703 set((state) =>
644 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {704 userAction(state, scopedThreadKey(ref), (current) => {
645 const active = current.surfaces.find(705 const active = current.surfaces.find(
646 (surface) => surface.id === current.activeSurfaceId,706 (surface) => surface.id === current.activeSurfaceId,
647 );707 );
...@@ -654,13 +714,20 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -654,13 +714,20 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
654 }714 }
655 return upsertSurface(current, singletonSurface(kind));715 return upsertSurface(current, singletonSurface(kind));
656 }),716 }),
657 })),717 ),
658 removeThread: (ref) =>718 removeThread: (ref) =>
659 set((state) => {719 set((state) => {
660 const threadKey = scopedThreadKey(ref);720 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 }
662 const { [threadKey]: _removed, ...rest } = state.byThreadKey;727 const { [threadKey]: _removed, ...rest } = state.byThreadKey;
663 return { byThreadKey: rest };728 const { [threadKey]: _revision, ...userActionRevisionByThreadKey } =
729 state.userActionRevisionByThreadKey;
730 return { byThreadKey: rest, userActionRevisionByThreadKey };
664 }),731 }),
665 }),732 }),
666 {733 {