| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | |
| 5 | import { useClosedViewStore } from "./closedViewStore"; |
| 6 | import { |
| 7 | migratePersistedRightPanelState, |
| 8 | pullRequestSurface, |
| 9 | pullRequestSurfaceId, |
| 10 | selectActiveRightPanel, |
| 11 | selectActiveRightPanelSurface, |
| 12 | selectSelectedRightPanelSurface, |
| 13 | selectThreadPanelOpen, |
| 14 | selectThreadPanelVisibility, |
| 15 | selectThreadRightPanelState, |
| 16 | useRightPanelStore, |
| 17 | } from "./rightPanelStore"; |
| 18 | |
| 19 | const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); |
| 20 | const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); |
| 21 | |
| 22 | beforeEach(() => { |
| 23 | useClosedViewStore.setState({ entries: [] }); |
| 24 | useRightPanelStore.setState({ |
| 25 | byThreadKey: {}, |
| 26 | threadPanelVisibilityByThreadKey: {}, |
| 27 | userActionRevisionByThreadKey: {}, |
| 28 | closeRevisionByThreadKey: {}, |
| 29 | }); |
| 30 | }); |
| 31 | |
| 32 | describe("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 | |
| 1244 | describe("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 | }); |