1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { type EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";
4
5import { useClosedViewStore } from "./closedViewStore";
6import { reopenClosedView } from "./reopenClosedView";
7import {
8 hostRightPanel,
9 migratePersistedRightPanelState,
10 pullRequestSurface,
11 pullRequestSurfaceId,
12 selectActiveRightPanel,
13 selectActiveRightPanelSurface,
14 selectThreadPanelVisibility,
15 selectThreadRightPanelState,
16 useRightPanelStore,
17} from "./rightPanelStore";
18
19const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"));
20const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));
21
22beforeEach(() => {
23 useClosedViewStore.setState({ entries: [] });
24 useRightPanelStore.setState({
25 byThreadKey: {},
26 threadPanelVisibilityByThreadKey: {},
27 userActionRevisionByThreadKey: {},
28 closeRevisionByThreadKey: {},
29 });
30});
31
32async function reloadRightPanelStore() {
33 const { storage, name } = useRightPanelStore.persist.getOptions();
34 const persisted = await storage!.getItem(name!);
35 expect(persisted).not.toBeNull();
36 useRightPanelStore.setState(useRightPanelStore.getInitialState());
37 await storage!.setItem(name!, persisted!);
38 await useRightPanelStore.persist.rehydrate();
39 expect(useRightPanelStore.persist.hasHydrated()).toBe(true);
40}
41
42describe("rightPanelStore", () => {
43 it("opens a hosted side chat's panel actions in its parent's panel until unhosted", () => {
44 const store = useRightPanelStore.getState();
45 const unhost = hostRightPanel(refB, refA);
46 store.openFile(refB, "src/app.ts");
47 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe(
48 "file:src/app.ts",
49 );
50 expect(
51 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB).surfaces,
52 ).toEqual([]);
53 unhost();
54 store.openFile(refB, "src/app.ts");
55 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refB)?.id).toBe(
56 "file:src/app.ts",
57 );
58 });
59
60 it("records single and bulk tab closes, newest first", () => {
61 const store = useRightPanelStore.getState();
62 const pr = pullRequestSurface({
63 projectId: "project-a",
64 repository: "pingdotgg/t3code",
65 number: 42,
66 });
67 store.openFile(refA, "src/app.ts");
68 store.open(refA, "device");
69 store.openPullRequest(refA, pr);
70 store.open(refA, "diff");
71 store.closeSurface(refA, pr.id);
72 store.closeSurfacesToRight(refA, "device");
73 store.closeOtherSurfaces(refA, "file:src/app.ts");
74 expect(
75 useClosedViewStore
76 .getState()
77 .entries.map((entry) => (entry.kind === "panel-tab" ? entry.surface.id : null)),
78 ).toEqual(["device", "diff", pr.id]);
79 });
80
81 it("reopens the active tab first after a bulk close", () => {
82 const store = useRightPanelStore.getState();
83 store.open(refA, "files");
84 store.open(refA, "diff");
85 store.open(refA, "device");
86 store.activateSurface(refA, "diff");
87 store.closeAllSurfaces(refA);
88
89 expect(
90 useClosedViewStore
91 .getState()
92 .entries.map((entry) => entry.kind === "panel-tab" && entry.surface.id),
93 ).toEqual(["diff", "device", "files"]);
94 });
95
96 it("does not save an incidental Files replacement when opening an existing file", () => {
97 const store = useRightPanelStore.getState();
98 store.openFile(refA, "src/app.ts");
99 store.open(refA, "files");
100 store.openFile(refA, "src/app.ts");
101
102 expect(useClosedViewStore.getState().entries).toEqual([]);
103 });
104
105 it("ignores session tabs without browser snapshots and records the empty browser tab", () => {
106 const store = useRightPanelStore.getState();
107 store.openBrowser(refA, "tab-1");
108 store.closeSurface(refA, "browser:tab-1");
109 expect(useClosedViewStore.getState().entries).toEqual([]);
110 store.openBrowser(refA, null);
111 store.closeSurface(refA, "browser:new");
112 expect(useClosedViewStore.getState().entries).toMatchObject([
113 { kind: "panel-tab", surface: { id: "browser:new", resourceId: null } },
114 ]);
115 });
116
117 it("keeps a dismissed device hidden after another file opens", () => {
118 const store = useRightPanelStore.getState();
119 const device = {
120 hostId: "nucbox",
121 deviceId: "emulator-5580",
122 name: "Pixel",
123 platform: "android",
124 } as const;
125 store.openFile(refA, "src/app.ts");
126 store.closeSurface(refA, "file:src/app.ts");
127 store.openDevice(refA, device);
128 store.closeSurface(refA, "device:nucbox:emulator-5580");
129
130 store.openFile(refA, "src/app.ts");
131 store.openDevice(refA, device, true);
132
133 expect(
134 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
135 ).toEqual([expect.objectContaining({ id: "file:src/app.ts" })]);
136 });
137
138 it("records only closed tabs when a panel is hidden or a terminal tab closes", () => {
139 const store = useRightPanelStore.getState();
140 store.open(refA, "diff");
141 store.openFile(refA, "src/app.ts");
142 store.closeSurface(refA, "file:src/app.ts");
143 store.close(refA);
144 expect(useClosedViewStore.getState().entries).toMatchObject([
145 { kind: "panel-tab", threadRef: refA, surface: { id: "file:src/app.ts" } },
146 ]);
147 store.toggleVisibility(refA);
148 store.toggle(refA, "diff");
149 store.openTerminal(refA, "term-1");
150 store.closeSurface(refA, "terminal:term-1");
151 expect(useClosedViewStore.getState().entries).toMatchObject([
152 { kind: "panel-tab", threadRef: refA, surface: { id: "file:src/app.ts" } },
153 ]);
154 expect(useClosedViewStore.getState().entries).toHaveLength(1);
155 });
156
157 it("gives each host/device its own tab and preserves renamed tabs", () => {
158 const store = useRightPanelStore.getState();
159 const android = {
160 hostId: "nucbox",
161 deviceId: "emulator-5580",
162 name: "Pixel",
163 platform: "android",
164 } as const;
165 const ios = { hostId: "macmini", deviceId: "ios-1", name: "iPhone", platform: "ios" } as const;
166 store.open(refA, "device");
167 store.openDevice(refA, android);
168 store.open(refA, "device");
169 expect(
170 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
171 ).toHaveLength(2);
172 store.openDevice(refA, ios);
173 let state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
174 expect(state.surfaces.map((surface) => surface.id)).toEqual([
175 "device:nucbox:emulator-5580",
176 "device:macmini:ios-1",
177 ]);
178 store.renameDevice(refA, "device:nucbox:emulator-5580", "Android test");
179 store.openDevice(refA, android);
180 state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
181 expect(state.surfaces).toHaveLength(2);
182 expect(state.surfaces[0]).toMatchObject({ title: "Android test", target: android });
183 expect(state.activeSurfaceId).toBe("device:nucbox:emulator-5580");
184 store.closeSurface(refA, state.activeSurfaceId!);
185 expect(
186 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
187 ).toEqual([expect.objectContaining({ target: ios })]);
188 });
189
190 it("does not collide when two hosts expose the same device id", () => {
191 const store = useRightPanelStore.getState();
192 const device = { deviceId: "emulator-5554", name: "Pixel", platform: "android" } as const;
193 store.openDevice(refA, { ...device, hostId: "a:b" });
194 store.openDevice(refA, { ...device, hostId: "a" });
195 expect(
196 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
197 ).toHaveLength(2);
198 expect(
199 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB).surfaces,
200 ).toHaveLength(0);
201 });
202
203 it.each(["one", "all", "others", "right"])(
204 "keeps device tabs dismissed across reload after closing %s",
205 (mode) => {
206 const store = useRightPanelStore.getState();
207 const target = {
208 hostId: "nucbox",
209 deviceId: "emulator-5580",
210 name: "Pixel",
211 platform: "android",
212 } as const;
213 store.open(refA, "files");
214 store.openDevice(refA, target);
215 if (mode === "one") store.closeSurface(refA, "device:nucbox:emulator-5580");
216 if (mode === "all") store.closeAllSurfaces(refA);
217 if (mode === "others") store.closeOtherSurfaces(refA, "files");
218 if (mode === "right") store.closeSurfacesToRight(refA, "files");
219 const persisted = JSON.parse(
220 JSON.stringify({ byThreadKey: useRightPanelStore.getState().byThreadKey }),
221 );
222 useRightPanelStore.setState(migratePersistedRightPanelState(persisted));
223 store.openDevice(refA, target, true);
224 expect(
225 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some(
226 (surface) => surface.kind === "device",
227 ),
228 ).toBe(false);
229 store.openDevice(refA, target);
230 expect(
231 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some(
232 (surface) => surface.kind === "device",
233 ),
234 ).toBe(true);
235 },
236 );
237
238 const completedDiff = { kind: "diff", turnId: RunId.make("turn-1") } as const;
239 const linkedPullRequest = pullRequestSurface({
240 projectId: "project-a",
241 repository: "pingdotgg/t3code",
242 number: 42,
243 });
244
245 it.each([
246 { order: "diff-first", surface: linkedPullRequest },
247 { order: "pull-request-first", surface: linkedPullRequest },
248 { order: "diff-first", surface: { id: "pull-requests", kind: "pull-requests" } as const },
249 {
250 order: "pull-request-first",
251 surface: { id: "pull-requests", kind: "pull-requests" } as const,
252 },
253 ])(
254 "prioritizes $surface.kind over browser and diff with $order delivery",
255 ({ order, surface }) => {
256 const store = useRightPanelStore.getState();
257 store.openBrowser(refA, "existing-browser");
258 const revision = store.getUserActionRevision(refA);
259 const requests = order === "diff-first" ? [completedDiff, surface] : [surface, completedDiff];
260 for (const surface of requests) store.openProactive(refA, surface, revision);
261 store.reconcileBrowserSurfaces(refA, ["existing-browser", "agent-browser"]);
262
263 expect(
264 selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA),
265 ).toEqual(surface);
266
267 store.open(refA, "diff");
268 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
269 },
270 );
271
272 it.each([
273 { choice: "file", choose: () => useRightPanelStore.getState().openFile(refA, "src/app.ts") },
274 {
275 choice: "pull request",
276 choose: () =>
277 useRightPanelStore.getState().openPullRequest(refA, { ...linkedPullRequest, number: 41 }),
278 },
279 { choice: "browser", choose: () => useRightPanelStore.getState().openBrowser(refA, "tab-a") },
280 {
281 choice: "terminal",
282 choose: () => useRightPanelStore.getState().openTerminal(refA, "term-1"),
283 },
284 {
285 choice: "same tab",
286 choose: () => useRightPanelStore.getState().activateSurface(refA, "diff"),
287 },
288 { choice: "hide", choose: () => useRightPanelStore.getState().close(refA) },
289 { choice: "toggle", choose: () => useRightPanelStore.getState().toggle(refA, "diff") },
290 { choice: "close all", choose: () => useRightPanelStore.getState().closeAllSurfaces(refA) },
291 {
292 choice: "terminal close",
293 choose: () => {
294 const store = useRightPanelStore.getState();
295 store.openTerminal(refA, "term-1");
296 store.closeTerminal(refA, "terminal:term-1", "term-1");
297 },
298 },
299 ])("keeps a later $choice choice when automatic requests arrive", ({ choose }) => {
300 const store = useRightPanelStore.getState();
301 store.open(refA, "diff");
302 const revision = store.getUserActionRevision(refA);
303 choose();
304 const chosen = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
305
306 expect(store.openProactive(refA, completedDiff, revision)).toBe(false);
307 expect(store.openProactive(refA, linkedPullRequest, revision)).toBe(false);
308 expect(
309 store.openProactive(refA, { id: "pull-requests", kind: "pull-requests" }, revision),
310 ).toBe(false);
311 const { isOpen, activeSurfaceId, surfaces } = selectThreadRightPanelState(
312 useRightPanelStore.getState().byThreadKey,
313 refA,
314 );
315 expect({ isOpen, activeSurfaceId, surfaces }).toEqual({
316 isOpen: chosen.isOpen,
317 activeSurfaceId: chosen.activeSurfaceId,
318 surfaces: chosen.surfaces,
319 });
320 });
321
322 it.each([
323 {
324 surface: linkedPullRequest,
325 choose: () => useRightPanelStore.getState().openFile(refA, "notes.md"),
326 },
327 {
328 surface: { id: "pull-requests", kind: "pull-requests" } as const,
329 choose: () => useRightPanelStore.getState().openBrowser(refA, "tab-a"),
330 },
331 ])(
332 "preserves the selected tab and hidden panel when $surface.kind already exists",
333 ({ surface, choose }) => {
334 const store = useRightPanelStore.getState();
335 store.openProactive(refA, surface, store.getUserActionRevision(refA));
336 choose();
337 const chosen = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
338
339 // Re-entry captures a fresh revision; the existing tab must still preserve the choice.
340 expect(store.openProactive(refA, surface, store.getUserActionRevision(refA))).toBe(false);
341 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual(
342 chosen,
343 );
344
345 store.close(refA);
346 expect(store.openProactive(refA, surface, store.getUserActionRevision(refA))).toBe(false);
347 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
348 ...chosen,
349 isOpen: false,
350 });
351 },
352 );
353
354 it("retargets an existing PR tab but respects a later manual choice", () => {
355 const store = useRightPanelStore.getState();
356 const relinkedPullRequest = pullRequestSurface({ ...linkedPullRequest, number: 43 });
357 store.openPullRequest(refA, relinkedPullRequest);
358 store.openPullRequest(refA, linkedPullRequest);
359 const revision = store.getUserActionRevision(refA);
360
361 expect(store.openProactive(refA, relinkedPullRequest, revision, true)).toBe(true);
362 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual(
363 relinkedPullRequest,
364 );
365
366 store.close(refA);
367 expect(store.openProactive(refA, linkedPullRequest, revision, true)).toBe(false);
368 expect(
369 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA),
370 ).toMatchObject({
371 activeSurfaceId: relinkedPullRequest.id,
372 isOpen: false,
373 });
374 });
375
376 it("refocuses an existing diff for a later turn after a manual choice", () => {
377 const store = useRightPanelStore.getState();
378 store.openProactive(refA, completedDiff, store.getUserActionRevision(refA));
379 const firstTurnRevision = store.getUserActionRevision(refA);
380 store.openFile(refA, "src/app.ts");
381 expect(store.openProactive(refA, completedDiff, firstTurnRevision)).toBe(false);
382
383 const nextTurnRevision = store.getUserActionRevision(refA);
384 expect(
385 store.openProactive(
386 refA,
387 { ...completedDiff, turnId: RunId.make("turn-2") },
388 nextTurnRevision,
389 ),
390 ).toBe(true);
391 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
392 });
393
394 it("offers each completed diff once across dismissal, panel changes, and reload", async () => {
395 const store = useRightPanelStore.getState();
396 expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(true);
397 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
398
399 store.close(refA);
400 await reloadRightPanelStore();
401 expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false);
402 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
403
404 store.open(refA, "files");
405 store.openFile(refA, "src/app.ts");
406 await reloadRightPanelStore();
407 expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false);
408 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("file");
409
410 store.closeAllSurfaces(refA);
411 await reloadRightPanelStore();
412 expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false);
413 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
414
415 const otherEnvironment = scopeThreadRef("env-2" as EnvironmentId, refA.threadId);
416 for (const ref of [refB, otherEnvironment]) {
417 expect(store.openProactive(ref, completedDiff, store.getUserActionRevision(ref))).toBe(true);
418 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, ref)).toBe("diff");
419 }
420
421 const nextTurn = { ...completedDiff, turnId: RunId.make("turn-2") };
422 expect(store.openProactive(refA, nextTurn, store.getUserActionRevision(refA))).toBe(true);
423 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
424
425 const completedWhileOpen = { ...completedDiff, turnId: RunId.make("turn-3") };
426 const existingDiff = selectActiveRightPanelSurface(
427 useRightPanelStore.getState().byThreadKey,
428 refA,
429 );
430 expect(store.openProactive(refA, completedWhileOpen, store.getUserActionRevision(refA))).toBe(
431 false,
432 );
433 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual(
434 existingDiff,
435 );
436 store.closeSurface(refA, "diff");
437 await reloadRightPanelStore();
438 expect(store.openProactive(refA, completedWhileOpen, store.getUserActionRevision(refA))).toBe(
439 false,
440 );
441 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
442
443 // A later storage migration must preserve the dismissal, just like an ordinary reload.
444 const { storage, name, version } = useRightPanelStore.persist.getOptions();
445 const persisted = await storage!.getItem(name!);
446 await storage!.setItem(name!, { ...persisted!, version: version! - 1 });
447 await reloadRightPanelStore();
448 expect(store.openProactive(refA, completedWhileOpen, store.getUserActionRevision(refA))).toBe(
449 false,
450 );
451 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
452
453 expect(
454 store.openProactive(
455 refA,
456 { ...completedDiff, turnId: RunId.make("turn-4") },
457 store.getUserActionRevision(refA),
458 ),
459 ).toBe(true);
460 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
461 store.closeSurface(refA, "diff");
462 store.open(refA, "diff");
463 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
464 });
465
466 it("keeps manual choices scoped to their thread and environment", () => {
467 const otherEnvironment = scopeThreadRef("env-2" as EnvironmentId, refA.threadId);
468 const store = useRightPanelStore.getState();
469 const revision = store.getUserActionRevision(refA);
470 store.openFile(refB, "src/app.ts");
471 store.openFile(otherEnvironment, "src/app.ts");
472
473 expect(store.openProactive(refA, completedDiff, revision)).toBe(true);
474 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBe("file");
475 expect(
476 selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, otherEnvironment),
477 ).toBe("file");
478 });
479
480 it("does not treat resource reconciliation as a manual choice", () => {
481 const store = useRightPanelStore.getState();
482 store.openFile(refA, "src/app.ts");
483 const revision = store.getUserActionRevision(refA);
484 store.reconcileBrowserSurfaces(refA, ["agent-browser"]);
485 store.reconcileFileSurfaces(refA, false);
486
487 expect(store.openProactive(refA, completedDiff, revision)).toBe(true);
488 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff");
489 });
490
491 it("keeps a maximized panel per thread without counting it as a manual choice", () => {
492 const store = useRightPanelStore.getState();
493 store.open(refA, "preview");
494 store.open(refB, "diff");
495 const revision = store.getUserActionRevision(refA);
496 store.setMaximized(refA, true);
497 store.setMaximized(refB, true);
498 store.setMaximized(refB, false);
499
500 const { byThreadKey } = useRightPanelStore.getState();
501 expect(selectThreadRightPanelState(byThreadKey, refA).maximized).toBe(true);
502 expect(selectThreadRightPanelState(byThreadKey, refB).maximized).toBeUndefined();
503 expect(store.getUserActionRevision(refA)).toBe(revision);
504 });
505
506 it("restores a saved maximized panel during migration", () => {
507 const migrated = migratePersistedRightPanelState({
508 byThreadKey: {
509 "env-1:thread-A": {
510 isOpen: true,
511 activeSurfaceId: "browser:new",
512 surfaces: [{ id: "browser:new", kind: "preview", resourceId: null }],
513 maximized: true,
514 },
515 },
516 });
517 expect(selectThreadRightPanelState(migrated.byThreadKey, refA).maximized).toBe(true);
518 });
519
520 it("drops the legacy singleton terminal surface during migration", () => {
521 expect(
522 migratePersistedRightPanelState({
523 byThreadKey: {
524 "env-1:thread-A": {
525 activeSurfaceId: "terminal",
526 surfaces: [
527 { id: "browser:tab-a", kind: "preview", resourceId: "tab-a" },
528 { id: "terminal", kind: "terminal" },
529 ],
530 },
531 },
532 }),
533 ).toEqual({
534 byThreadKey: {
535 "env-1:thread-A": {
536 isOpen: false,
537 activeSurfaceId: null,
538 surfaces: [{ id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }],
539 },
540 },
541 threadPanelVisibilityByThreadKey: {},
542 });
543 });
544
545 it("upgrades saved single-session terminal surfaces to split-capable surfaces", () => {
546 expect(
547 migratePersistedRightPanelState({
548 byThreadKey: {
549 "env-1:thread-A": {
550 isOpen: true,
551 activeSurfaceId: "terminal:term-1",
552 surfaces: [{ id: "terminal:term-1", kind: "terminal", resourceId: "term-1" }],
553 },
554 },
555 }),
556 ).toEqual({
557 byThreadKey: {
558 "env-1:thread-A": {
559 isOpen: true,
560 activeSurfaceId: "terminal:term-1",
561 surfaces: [
562 {
563 id: "terminal:term-1",
564 kind: "terminal",
565 resourceId: "term-1",
566 terminalIds: ["term-1"],
567 activeTerminalId: "term-1",
568 },
569 ],
570 },
571 },
572 threadPanelVisibilityByThreadKey: {},
573 });
574 });
575
576 it("upgrades the legacy singleton pull request surface to a reference-keyed tab", () => {
577 const id = pullRequestSurfaceId({
578 projectId: "project-a",
579 repository: "pingdotgg/t3code",
580 number: 4909,
581 });
582 expect(
583 migratePersistedRightPanelState({
584 byThreadKey: {
585 "env-1:thread-A": {
586 isOpen: true,
587 activeSurfaceId: "pull-request",
588 surfaces: [
589 {
590 id: "pull-request",
591 kind: "pull-request",
592 projectId: "project-a",
593 repository: "pingdotgg/t3code",
594 number: 4909,
595 },
596 ],
597 },
598 },
599 }),
600 ).toEqual({
601 byThreadKey: {
602 "env-1:thread-A": {
603 isOpen: true,
604 activeSurfaceId: id,
605 surfaces: [
606 {
607 id,
608 kind: "pull-request",
609 projectId: "project-a",
610 repository: "pingdotgg/t3code",
611 number: 4909,
612 },
613 ],
614 },
615 },
616 threadPanelVisibilityByThreadKey: {},
617 });
618 });
619
620 it("drops the pull-request list's shared panel so a restart opens the page fresh", () => {
621 const id = pullRequestSurfaceId({
622 projectId: "project-a",
623 repository: "pingdotgg/t3code",
624 number: 4909,
625 });
626 const panelState = {
627 isOpen: true,
628 activeSurfaceId: id,
629 surfaces: [
630 {
631 id,
632 kind: "pull-request" as const,
633 projectId: "project-a",
634 repository: "pingdotgg/t3code",
635 number: 4909,
636 },
637 ],
638 };
639 expect(
640 migratePersistedRightPanelState({
641 byThreadKey: {
642 "env-1:pull-requests-panel": panelState,
643 "env-1:thread-A": panelState,
644 },
645 }),
646 ).toEqual({
647 byThreadKey: { "env-1:thread-A": panelState },
648 threadPanelVisibilityByThreadKey: {},
649 });
650 });
651
652 it.each([
653 { kind: "plan", isOpen: true },
654 { kind: "agents", isOpen: true },
655 { kind: "agents", isOpen: false },
656 ])("drops $kind with isOpen=$isOpen and falls back", ({ kind, isOpen }) => {
657 expect(
658 migratePersistedRightPanelState({
659 byThreadKey: {
660 "env-1:thread-A": {
661 isOpen,
662 activeSurfaceId: kind,
663 surfaces: [{ id: kind, kind }],
664 },
665 "env-1:thread-B": {
666 isOpen,
667 activeSurfaceId: kind,
668 surfaces: [
669 { id: kind, kind },
670 { id: "diff", kind: "diff" },
671 ],
672 },
673 },
674 }),
675 ).toEqual({
676 byThreadKey: {
677 "env-1:thread-A": {
678 isOpen: false,
679 activeSurfaceId: null,
680 surfaces: [],
681 },
682 "env-1:thread-B": {
683 isOpen,
684 activeSurfaceId: "diff",
685 surfaces: [{ id: "diff", kind: "diff" }],
686 },
687 },
688 threadPanelVisibilityByThreadKey: {},
689 });
690 });
691
692 it("persists inline preference without restoring an open popover", () => {
693 expect(
694 migratePersistedRightPanelState({
695 threadPanelVisibilityByThreadKey: {
696 "env-1:thread-A": { inlineOpen: false, popoverOpen: true },
697 "env-1:thread-B": { inlineOpen: true, popoverOpen: true },
698 },
699 }),
700 ).toEqual({
701 byThreadKey: {},
702 threadPanelVisibilityByThreadKey: {
703 "env-1:thread-A": { inlineOpen: false, popoverOpen: false },
704 },
705 });
706 });
707
708 it("closes the popover atomically when the real right panel opens", () => {
709 useRightPanelStore.getState().setThreadPanelOpen(refA, "popover", true);
710 useRightPanelStore.getState().open(refA, "diff");
711
712 expect(
713 selectThreadPanelVisibility(
714 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
715 refA,
716 ),
717 ).toEqual({ inlineOpen: true, popoverOpen: false });
718 });
719
720 it("keeps an open popover visible by promoting it to inline when the real panel closes", () => {
721 const store = useRightPanelStore.getState();
722 store.open(refA, "diff");
723 store.setThreadPanelOpen(refA, "inline", false);
724 store.setThreadPanelOpen(refA, "popover", true);
725 store.close(refA);
726
727 expect(
728 selectThreadPanelVisibility(
729 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
730 refA,
731 ),
732 ).toEqual({ inlineOpen: true, popoverOpen: true });
733 });
734
735 it("opens workspace-root links as the singleton files explorer", () => {
736 const store = useRightPanelStore.getState();
737 store.openFile(refA, "README.md");
738 store.openFile(refA, ".");
739 store.openFile(refA, ".");
740 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
741 expect(state.activeSurfaceId).toBe("files");
742 expect(state.surfaces.map((surface) => surface.id)).toEqual(["file:README.md", "files"]);
743 store.closeSurface(refA, "files");
744 expect(
745 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).activeSurfaceId,
746 ).toBe("file:README.md");
747 store.openFile(refA, ".");
748 expect(
749 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).activeSurfaceId,
750 ).toBe("files");
751 });
752
753 it("walks a folder browser up and back down in one tab", () => {
754 const store = useRightPanelStore.getState();
755 const state = () =>
756 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
757 store.openFile(refA, "README.md");
758 store.openFile(refA, "apps/web/src");
759
760 store.navigateFolder(refA, "file:apps/web/src", "apps", "apps/web/src");
761 expect(state().activeSurfaceId).toBe("file:apps");
762 expect(state().surfaces).toEqual([
763 expect.objectContaining({ id: "file:README.md" }),
764 expect.objectContaining({
765 id: "file:apps",
766 relativePath: "apps",
767 folderTrail: "apps/web/src",
768 }),
769 ]);
770
771 store.navigateFolder(refA, "file:apps", "", "apps/web/src");
772 expect(state().surfaces.map((surface) => surface.id)).toEqual(["file:README.md", "files"]);
773 expect(state().surfaces[1]).toEqual({
774 id: "files",
775 kind: "files",
776 folderTrail: "apps/web/src",
777 });
778
779 store.navigateFolder(refA, "files", "apps/web/src", "apps/web/src");
780 expect(state().activeSurfaceId).toBe("file:apps/web/src");
781 expect(state().surfaces[1]).toEqual({
782 id: "file:apps/web/src",
783 kind: "file",
784 relativePath: "apps/web/src",
785 revealLine: null,
786 revealRequestId: 1,
787 });
788 });
789
790 it("switches to a tab that already shows the folder instead of replacing it", () => {
791 const store = useRightPanelStore.getState();
792 store.openFile(refA, "apps");
793 store.openFile(refA, "apps/web/src", 12);
794
795 store.navigateFolder(refA, "file:apps/web/src", "apps", "apps/web/src");
796 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
797 expect(state.activeSurfaceId).toBe("file:apps");
798 expect(state.surfaces).toEqual([
799 { id: "file:apps", kind: "file", relativePath: "apps", revealLine: null, revealRequestId: 1 },
800 ]);
801 });
802
803 it("keeps a file's tab when walking up from it, and comes back to it", () => {
804 const store = useRightPanelStore.getState();
805 const state = () =>
806 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
807 store.openFile(refA, "src/app.ts", 40);
808
809 store.navigateFolder(refA, "file:src/app.ts", "src", "src/app.ts", true);
810 expect(state().activeSurfaceId).toBe("file:src");
811 expect(state().surfaces).toEqual([
812 {
813 id: "file:src/app.ts",
814 kind: "file",
815 relativePath: "src/app.ts",
816 revealLine: 40,
817 revealRequestId: 1,
818 },
819 expect.objectContaining({ id: "file:src", folderTrail: "src/app.ts" }),
820 ]);
821
822 store.navigateFolder(refA, "file:src", "src/app.ts", "src/app.ts");
823 expect(state().activeSurfaceId).toBe("file:src/app.ts");
824 expect(state().surfaces).toEqual([
825 {
826 id: "file:src/app.ts",
827 kind: "file",
828 relativePath: "src/app.ts",
829 revealLine: 40,
830 revealRequestId: 1,
831 },
832 ]);
833 });
834
835 // regression: #15045 breadcrumb navigation from a closed tab must not resurrect it
836 it("ignores navigation from a tab that was closed", () => {
837 const store = useRightPanelStore.getState();
838 store.openFile(refA, "apps");
839 const before = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
840 store.navigateFolder(refA, "file:gone", "", "gone");
841 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toBe(
842 before,
843 );
844 });
845
846 it("replaces the standalone explorer with peer file surfaces", () => {
847 useRightPanelStore.getState().open(refA, "files");
848 useRightPanelStore.getState().openFile(refA, "src/index.ts");
849 useRightPanelStore.getState().openFile(refA, "src/index.ts");
850 useRightPanelStore.getState().openFile(refA, "README.md");
851
852 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
853 isOpen: true,
854 activeSurfaceId: "file:README.md",
855 surfaces: [
856 {
857 id: "file:src/index.ts",
858 kind: "file",
859 relativePath: "src/index.ts",
860 revealLine: null,
861 revealRequestId: 2,
862 },
863 {
864 id: "file:README.md",
865 kind: "file",
866 relativePath: "README.md",
867 revealLine: null,
868 revealRequestId: 1,
869 },
870 ],
871 });
872 });
873
874 it.each([
875 ["generated\\", "generated"],
876 ["notes/meeting ", "notes/meeting"],
877 [" notes/meeting", "notes/meeting"],
878 ])("keeps %j and %j in separate file tabs", (firstPath, secondPath) => {
879 useRightPanelStore.getState().openFile(refA, firstPath);
880 useRightPanelStore.getState().openFile(refA, secondPath);
881
882 expect(
883 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
884 ).toMatchObject([
885 { id: `file:${firstPath}`, relativePath: firstPath },
886 { id: `file:${secondPath}`, relativePath: secondPath },
887 ]);
888 });
889
890 it("keeps attachment and workspace file ids disjoint", () => {
891 useRightPanelStore.getState().openFile(refA, "attachment:shared-id");
892 useRightPanelStore.getState().openAttachment(refA, {
893 type: "file",
894 id: "shared-id",
895 name: "report.pdf",
896 mimeType: "application/pdf",
897 sizeBytes: 42,
898 });
899
900 expect(
901 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map(
902 (surface) => surface.id,
903 ),
904 ).toEqual(["file:attachment:shared-id", "attachment:shared-id"]);
905 });
906
907 it("updates line reveal requests when reopening a file surface", () => {
908 useRightPanelStore.getState().openFile(refA, "src/index.ts", 42);
909 useRightPanelStore.getState().openFile(refA, "src/index.ts", 87);
910
911 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
912 isOpen: true,
913 activeSurfaceId: "file:src/index.ts",
914 surfaces: [
915 {
916 id: "file:src/index.ts",
917 kind: "file",
918 relativePath: "src/index.ts",
919 revealLine: 87,
920 revealRequestId: 2,
921 },
922 ],
923 });
924
925 useRightPanelStore.getState().openFile(refA, "src/index.ts");
926
927 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
928 isOpen: true,
929 activeSurfaceId: "file:src/index.ts",
930 surfaces: [
931 {
932 id: "file:src/index.ts",
933 kind: "file",
934 relativePath: "src/index.ts",
935 revealLine: null,
936 revealRequestId: 3,
937 },
938 ],
939 });
940 });
941
942 it("removes persisted file surfaces when their workspace no longer exists", () => {
943 useRightPanelStore.getState().openFile(refA, "src/index.ts");
944 useRightPanelStore.getState().open(refA, "device");
945 useRightPanelStore.getState().openFile(refA, "README.md");
946
947 useRightPanelStore.getState().reconcileFileSurfaces(refA, false);
948
949 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
950 isOpen: true,
951 activeSurfaceId: "device",
952 surfaces: [{ id: "device", kind: "device" }],
953 });
954
955 useRightPanelStore.getState().openFile(refB, "conductor.json");
956 useRightPanelStore.getState().reconcileFileSurfaces(refB, false);
957 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB)).toEqual({
958 isOpen: false,
959 activeSurfaceId: null,
960 surfaces: [],
961 });
962 });
963
964 it("keeps attachment previews when their workspace is unavailable", () => {
965 const attachment = {
966 type: "file" as const,
967 id: "thread-A-attachment-pdf",
968 name: "report.pdf",
969 mimeType: "application/pdf",
970 sizeBytes: 42,
971 };
972 useRightPanelStore.getState().openFile(refA, "README.md");
973 useRightPanelStore.getState().openAttachment(refA, attachment);
974
975 useRightPanelStore.getState().reconcileFileSurfaces(refA, false);
976
977 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
978 isOpen: true,
979 activeSurfaceId: "attachment:thread-A-attachment-pdf",
980 surfaces: [
981 {
982 id: "attachment:thread-A-attachment-pdf",
983 kind: "file",
984 relativePath: "report.pdf",
985 revealLine: null,
986 revealRequestId: 0,
987 attachment,
988 },
989 ],
990 });
991 });
992
993 it("tracks one surface per pull request", () => {
994 const first = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4909 };
995 const second = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4910 };
996 useRightPanelStore.getState().openPullRequest(refA, first);
997 useRightPanelStore.getState().openPullRequest(refA, second);
998 const url = "https://gitlab.example.com/pingdotgg/t3code/-/merge_requests/4909";
999 useRightPanelStore.getState().openPullRequest(refA, { ...first, url });
1000 useRightPanelStore.getState().openPullRequest(refA, first);
1001
1002 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
1003 expect(state.surfaces.map((surface) => surface.id)).toEqual([
1004 pullRequestSurfaceId(first),
1005 pullRequestSurfaceId(second),
1006 ]);
1007 expect(state.activeSurfaceId).toBe(pullRequestSurfaceId(first));
1008 expect(
1009 selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA),
1010 ).toMatchObject({ url });
1011 expect(state.surfaces[1]).not.toHaveProperty("url");
1012 expect(state.surfaces[0]).not.toHaveProperty("revealUrl");
1013 });
1014
1015 it("holds a clicked link until the panel settles it, and a later link replaces it", async () => {
1016 const pr = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4909 };
1017 const id = pullRequestSurfaceId(pr);
1018 const url = "https://github.com/pingdotgg/t3code/pull/4909";
1019 const surface = () =>
1020 selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA);
1021 const comment = (n: number) => `${url}#issuecomment-${n}`;
1022 const store = useRightPanelStore.getState();
1023 store.openPullRequest(refA, { ...pr, url: comment(1), revealUrl: comment(1) });
1024 expect(surface()).toMatchObject({ url: comment(1), revealUrl: comment(1) });
1025 store.openPullRequest(refA, { ...pr, url });
1026 expect(surface()).not.toHaveProperty("revealUrl");
1027 store.openPullRequest(refA, { ...pr, url: comment(2), revealUrl: comment(2) });
1028 // A host read started for an earlier link settles only that link.
1029 store.settlePullRequestReveal(refA, id, comment(1));
1030 expect(surface()).toMatchObject({ revealUrl: comment(2) });
1031 store.settlePullRequestReveal(refA, id, comment(2));
1032 expect(surface()).toMatchObject({ url: comment(2) });
1033 expect(surface()).not.toHaveProperty("revealUrl");
1034 // Reopening a closed tab hands back its surface, whose url still names the comment.
1035 store.openPullRequest(refA, { ...pr, url: comment(2) });
1036 expect(surface()).not.toHaveProperty("revealUrl");
1037 store.openPullRequest(refA, { ...pr, url: comment(3), revealUrl: comment(3) });
1038 await reloadRightPanelStore();
1039 expect(surface()).toMatchObject({ url: comment(3) });
1040 expect(surface()).not.toHaveProperty("revealUrl");
1041 store.openPullRequest(refA, { ...pr, url: comment(4), revealUrl: comment(4) });
1042 store.closeSurface(refA, id);
1043 await reopenClosedView(useClosedViewStore.getState().entries[0]!, {
1044 workspaceAvailable: true,
1045 openPreview: () => {
1046 throw new Error("A PR tab must not create a browser session");
1047 },
1048 });
1049 expect(surface()).toMatchObject({ url: comment(4) });
1050 expect(surface()).not.toHaveProperty("revealUrl");
1051 });
1052
1053 it("restores each environment, thread and PR surface's detail tab after reload and URL reopen", async () => {
1054 const pr = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4909 };
1055 const otherPr = { ...pr, projectId: "project-b" };
1056 const otherEnvironment = scopeThreadRef("env-2" as EnvironmentId, refA.threadId);
1057 const selections = [
1058 [refA, pr, "code"],
1059 [refB, pr, "guide"],
1060 [otherEnvironment, pr, "timeline"],
1061 [refA, otherPr, "summary"],
1062 ] as const;
1063 const store = useRightPanelStore.getState();
1064 for (const [ref, reference, tab] of selections) {
1065 store.openPullRequest(ref, reference);
1066 store.setPullRequestDetailTab(ref, pullRequestSurfaceId(reference), "guide");
1067 store.setPullRequestDetailTab(ref, pullRequestSurfaceId(reference), tab);
1068 }
1069
1070 store.openPullRequest(refA, pr);
1071 const revision = store.getUserActionRevision(refA);
1072 store.setPullRequestDetailTab(refA, pullRequestSurfaceId(pr), "timeline");
1073 expect(store.openProactive(refA, linkedPullRequest, revision)).toBe(true);
1074 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual(
1075 linkedPullRequest,
1076 );
1077 store.setPullRequestDetailTab(refA, pullRequestSurfaceId(pr), "code");
1078
1079 const { storage, name } = useRightPanelStore.persist.getOptions();
1080 const saved = await storage!.getItem(name!);
1081 expect(saved).not.toBeNull();
1082 useRightPanelStore.setState({ byThreadKey: {} });
1083 await storage!.setItem(name!, saved!);
1084 await useRightPanelStore.persist.rehydrate();
1085
1086 for (const [ref, reference, tab] of selections) {
1087 store.openPullRequest(ref, {
1088 ...reference,
1089 url: `https://github.com/${reference.repository}/pull/${reference.number}`,
1090 });
1091 const active = selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref);
1092 expect(active?.id).toBe(pullRequestSurfaceId(reference));
1093 expect(active?.kind === "pull-request" && (active.detailTab ?? "summary")).toBe(tab);
1094 store.closeSurface(ref, active!.id);
1095 await reopenClosedView(useClosedViewStore.getState().entries[0]!, {
1096 workspaceAvailable: true,
1097 openPreview: () => {
1098 throw new Error("A PR tab must not create a browser session");
1099 },
1100 });
1101 const reopened = selectActiveRightPanelSurface(
1102 useRightPanelStore.getState().byThreadKey,
1103 ref,
1104 );
1105 expect(reopened?.kind === "pull-request" && (reopened.detailTab ?? "summary")).toBe(tab);
1106 }
1107 });
1108
1109 it("keeps matching repository and number on different hosts as separate tabs", () => {
1110 const first = { projectId: "project-a", repository: "acme/api", number: 7, host: "github.com" };
1111 const second = { ...first, host: "github.example.com" };
1112 useRightPanelStore.getState().openPullRequest(refA, first);
1113 useRightPanelStore.getState().openPullRequest(refA, second);
1114 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
1115 expect(state.surfaces).toEqual([pullRequestSurface(first), pullRequestSurface(second)]);
1116 expect(pullRequestSurfaceId({ ...first, host: "GITHUB.COM" })).toBe(
1117 pullRequestSurfaceId(first),
1118 );
1119 });
1120
1121 it("keeps the page's panel tabs reachable when the set of connected servers changes", () => {
1122 const panelId = ThreadId.make("pull-requests-panel");
1123 const stableRef = scopeThreadRef("pull-requests-panel" as EnvironmentId, panelId);
1124 const fromServerA = {
1125 environmentId: "server-a",
1126 projectId: "project-a",
1127 repository: "pingdotgg/t3code",
1128 number: 1,
1129 };
1130 const fromServerB = {
1131 environmentId: "server-b",
1132 projectId: "project-b",
1133 repository: "pingdotgg/t3code",
1134 number: 2,
1135 };
1136
1137 useRightPanelStore.getState().openPullRequest(stableRef, fromServerA);
1138 useRightPanelStore.getState().openPullRequest(stableRef, fromServerB);
1139
1140 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, stableRef);
1141 expect(state.surfaces.map((surface) => surface.id)).toEqual([
1142 pullRequestSurfaceId(fromServerA),
1143 pullRequestSurfaceId(fromServerB),
1144 ]);
1145
1146 const refWhileBothConnected = scopeThreadRef("server-a" as EnvironmentId, panelId);
1147 const refAfterServerADisconnects = scopeThreadRef("server-b" as EnvironmentId, panelId);
1148 expect(refWhileBothConnected).not.toEqual(refAfterServerADisconnects);
1149 expect(
1150 selectThreadRightPanelState(
1151 useRightPanelStore.getState().byThreadKey,
1152 refAfterServerADisconnects,
1153 ).surfaces,
1154 ).toEqual([]);
1155 });
1156
1157 it("tracks split panes and the active pane within a terminal surface", () => {
1158 useRightPanelStore.getState().openTerminal(refA, "term-1");
1159 useRightPanelStore.getState().splitTerminal(refA, "terminal:term-1", "term-2");
1160
1161 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
1162 id: "terminal:term-1",
1163 kind: "terminal",
1164 resourceId: "term-1",
1165 terminalIds: ["term-1", "term-2"],
1166 activeTerminalId: "term-2",
1167 });
1168
1169 useRightPanelStore.getState().activateTerminal(refA, "terminal:term-1", "term-1");
1170 useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1");
1171 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
1172 id: "terminal:term-1",
1173 kind: "terminal",
1174 resourceId: "term-1",
1175 terminalIds: ["term-2"],
1176 activeTerminalId: "term-2",
1177 });
1178 });
1179
1180 it("closing the final terminal pane removes its surface and closes the panel", () => {
1181 useRightPanelStore.getState().openTerminal(refA, "term-1");
1182 useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1");
1183
1184 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
1185 isOpen: false,
1186 activeSurfaceId: null,
1187 surfaces: [],
1188 });
1189 });
1190
1191 it("closing the active surface activates a neighboring surface", () => {
1192 useRightPanelStore.getState().openBrowser(refA, "tab-a");
1193 useRightPanelStore.getState().openTerminal(refA, "term-1");
1194 useRightPanelStore.getState().closeSurface(refA, "terminal:term-1");
1195
1196 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe(
1197 "browser:tab-a",
1198 );
1199 });
1200
1201 it("closing surfaces to the right activates the selected surface when active was removed", () => {
1202 useRightPanelStore.getState().openBrowser(refA, "tab-a");
1203 useRightPanelStore.getState().openFile(refA, "src/index.ts");
1204 useRightPanelStore.getState().openTerminal(refA, "term-1");
1205
1206 useRightPanelStore.getState().closeSurfacesToRight(refA, "browser:tab-a");
1207
1208 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
1209 isOpen: true,
1210 activeSurfaceId: "browser:tab-a",
1211 surfaces: [{ id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }],
1212 });
1213 });
1214
1215 it("does not replace a stale surface with a hidden tab, but preserves explicit opens", () => {
1216 const store = useRightPanelStore.getState();
1217 store.openBrowser(refA, "stale-tab");
1218 store.reconcileBrowserSurfaces(refA, ["hidden-tab"], new Set(["hidden-tab"]));
1219 expect(
1220 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA),
1221 ).toMatchObject({
1222 activeSurfaceId: null,
1223 surfaces: [],
1224 });
1225
1226 store.openBrowser(refA, "hidden-tab");
1227 store.reconcileBrowserSurfaces(
1228 refA,
1229 ["hidden-tab", "other-tab"],
1230 new Set(["hidden-tab", "other-tab"]),
1231 );
1232 expect(
1233 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA),
1234 ).toMatchObject({
1235 isOpen: true,
1236 activeSurfaceId: "browser:hidden-tab",
1237 surfaces: [{ id: "browser:hidden-tab", kind: "preview", resourceId: "hidden-tab" }],
1238 });
1239 });
1240
1241 it("hides the panel when browser reconciliation removes its last session tab", () => {
1242 const store = useRightPanelStore.getState();
1243 store.openBrowser(refA, "tab-a");
1244 store.reconcileBrowserSurfaces(refA, []);
1245 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
1246 isOpen: false,
1247 activeSurfaceId: null,
1248 surfaces: [],
1249 });
1250
1251 store.openBrowser(refA, "tab-b");
1252 store.open(refA, "diff");
1253 store.reconcileBrowserSurfaces(refA, []);
1254 expect(
1255 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA),
1256 ).toMatchObject({ isOpen: true, activeSurfaceId: "diff" });
1257 });
1258
1259 it("moves a surface to a new index and keeps it there through browser reconciliation", () => {
1260 const store = useRightPanelStore.getState();
1261 store.openTerminal(refA, "term-1");
1262 store.openBrowser(refA, "tab-a");
1263 store.open(refA, "diff");
1264 const revision = store.getUserActionRevision(refA);
1265 const surfaceIds = () =>
1266 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map(
1267 (surface) => surface.id,
1268 );
1269
1270 store.moveSurface(refA, "browser:tab-a", 0);
1271 expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff"]);
1272 store.moveSurface(refA, "browser:tab-a", 2);
1273 expect(surfaceIds()).toEqual(["terminal:term-1", "diff", "browser:tab-a"]);
1274 store.moveSurface(refA, "browser:tab-a", 0);
1275
1276 store.reconcileBrowserSurfaces(refA, ["tab-a", "tab-b"]);
1277 expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff", "browser:tab-b"]);
1278 expect(store.getUserActionRevision(refA)).toBe(revision);
1279 });
1280});