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