| author | |
| committer | |
| log | ea2983afbbcd5ad6ee2e7db80c2a9270ff4964a9 |
| tree | 22faea49e626fbea449a785c76f299042cdbf86a |
| parent | d64335bb5913c21869633a4236d9409911e3da52 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 90 insertions(+), 33 deletions(-)
apps/web/src/components/CommandPalette.tsx+19-7| ... | ... | @@ -97,7 +97,11 @@ import { |
| 97 | 97 | import { onOpenCommandPalette } from "../commandPaletteBus"; |
| 98 | 98 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 99 | 99 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 100 | import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; | |
| 100 | import { | |
| 101 | PULL_REQUESTS_PANEL_REF, | |
| 102 | selectActiveRightPanel, | |
| 103 | useRightPanelStore, | |
| 104 | } from "../rightPanelStore"; | |
| 101 | 105 | import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; |
| 102 | 106 | import { |
| 103 | 107 | cn, |
| ... | ... | @@ -599,17 +603,25 @@ function OpenCommandPaletteDialog(props: { |
| 599 | 603 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = |
| 600 | 604 | useHandleNewThread(); |
| 601 | 605 | const projects = useProjects(); |
| 602 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl( | |
| 603 | activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null, | |
| 604 | ); | |
| 606 | const referenceThreadRef = | |
| 607 | pathname === "/pull-requests" | |
| 608 | ? environments.some( | |
| 609 | (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, | |
| 610 | ) | |
| 611 | ? PULL_REQUESTS_PANEL_REF | |
| 612 | : null | |
| 613 | : activeThread | |
| 614 | ? scopeThreadRef(activeThread.environmentId, activeThread.id) | |
| 615 | : null; | |
| 616 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef); | |
| 605 | 617 | const activeThreadReferenceCopyTarget = |
| 606 | activeThread == null | |
| 618 | referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl) | |
| 607 | 619 | ? null |
| 608 | 620 | : resolveThreadReferenceCopyTarget({ |
| 609 | threadId: activeThread.id, | |
| 621 | threadId: referenceThreadRef.threadId, | |
| 610 | 622 | openPanelPullRequestUrl, |
| 611 | 623 | linkedPullRequestUrl: |
| 612 | activeThread.linkedPullRequest?.url ?? activeThread.branchPullRequest?.url ?? null, | |
| 624 | activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null, | |
| 613 | 625 | }); |
| 614 | 626 | const copyActiveThreadReference = useCallback(async () => { |
| 615 | 627 | const target = activeThreadReferenceCopyTarget; |
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+1| ... | ... | @@ -50,6 +50,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { |
| 50 | 50 | ); |
| 51 | 51 | return reference |
| 52 | 52 | ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ?? |
| 53 | reference.url ?? | |
| 53 | 54 | gitHubPullRequestBrowserUrl( |
| 54 | 55 | project?.repositoryIdentity, |
| 55 | 56 | reference.repository, |
apps/web/src/rightPanelStore.test.ts+6| ... | ... | @@ -587,6 +587,8 @@ describe("rightPanelStore", () => { |
| 587 | 587 | const second = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4910 }; |
| 588 | 588 | useRightPanelStore.getState().openPullRequest(refA, first); |
| 589 | 589 | useRightPanelStore.getState().openPullRequest(refA, second); |
| 590 | const url = "https://gitlab.example.com/pingdotgg/t3code/-/merge_requests/4909"; | |
| 591 | useRightPanelStore.getState().openPullRequest(refA, { ...first, url }); | |
| 590 | 592 | useRightPanelStore.getState().openPullRequest(refA, first); |
| 591 | 593 | |
| 592 | 594 | const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); |
| ... | ... | @@ -595,6 +597,10 @@ describe("rightPanelStore", () => { |
| 595 | 597 | pullRequestSurfaceId(second), |
| 596 | 598 | ]); |
| 597 | 599 | expect(state.activeSurfaceId).toBe(pullRequestSurfaceId(first)); |
| 600 | expect( | |
| 601 | selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA), | |
| 602 | ).toMatchObject({ url }); | |
| 603 | expect(state.surfaces[1]).not.toHaveProperty("url"); | |
| 598 | 604 | }); |
| 599 | 605 | |
| 600 | 606 | it("keeps one pull request read from two servers as two tabs", () => { |
apps/web/src/rightPanelStore.ts+34-5| ... | ... | @@ -7,8 +7,13 @@ |
| 7 | 7 | * terminal surfaces point at terminal session ids, file surfaces point at |
| 8 | 8 | * workspace paths, and diff/files remain singleton surfaces. |
| 9 | 9 | */ |
| 10 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 11 | import type { ChatFileAttachment, ScopedThreadRef } from "@t3tools/contracts"; | |
| 10 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 11 | import { | |
| 12 | EnvironmentId, | |
| 13 | ThreadId, | |
| 14 | type ChatFileAttachment, | |
| 15 | type ScopedThreadRef, | |
| 16 | } from "@t3tools/contracts"; | |
| 12 | 17 | import { create } from "zustand"; |
| 13 | 18 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 14 | 19 | |
| ... | ... | @@ -65,6 +70,7 @@ export type RightPanelSurface = |
| 65 | 70 | projectId: string; |
| 66 | 71 | repository: string; |
| 67 | 72 | number: number; |
| 73 | url?: string; | |
| 68 | 74 | } |
| 69 | 75 | | { id: "agents"; kind: "agents" }; |
| 70 | 76 | |
| ... | ... | @@ -74,8 +80,14 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; |
| 74 | 80 | // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. |
| 75 | 81 | const RIGHT_PANEL_STORAGE_VERSION = 11; |
| 76 | 82 | |
| 83 | /** A fixed workspace-level ref: each PR surface carries its own real environment. */ | |
| 84 | export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( | |
| 85 | EnvironmentId.make("pull-requests-panel"), | |
| 86 | ThreadId.make("pull-requests-panel"), | |
| 87 | ); | |
| 88 | ||
| 77 | 89 | /** |
| 78 | * The pull-request list's shared panel (see PULL_REQUESTS_PANEL_ID in the route) is session | |
| 90 | * The pull-request list's shared panel is session | |
| 79 | 91 | * state: reopening the app should show the list, not last session's tabs and detail fetches. |
| 80 | 92 | */ |
| 81 | 93 | const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel"); |
| ... | ... | @@ -109,7 +121,13 @@ interface RightPanelStoreState { |
| 109 | 121 | openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; |
| 110 | 122 | openPullRequest: ( |
| 111 | 123 | ref: ScopedThreadRef, |
| 112 | target: { environmentId?: string; projectId: string; repository: string; number: number }, | |
| 124 | target: { | |
| 125 | environmentId?: string; | |
| 126 | projectId: string; | |
| 127 | repository: string; | |
| 128 | number: number; | |
| 129 | url?: string; | |
| 130 | }, | |
| 113 | 131 | ) => void; |
| 114 | 132 | openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; |
| 115 | 133 | splitTerminal: ( |
| ... | ... | @@ -210,6 +228,7 @@ export function pullRequestSurface(target: { |
| 210 | 228 | projectId: string; |
| 211 | 229 | repository: string; |
| 212 | 230 | number: number; |
| 231 | url?: string; | |
| 213 | 232 | }): PullRequestSurface { |
| 214 | 233 | return { |
| 215 | 234 | id: pullRequestSurfaceId(target), |
| ... | ... | @@ -218,6 +237,7 @@ export function pullRequestSurface(target: { |
| 218 | 237 | projectId: target.projectId, |
| 219 | 238 | repository: target.repository, |
| 220 | 239 | number: target.number, |
| 240 | ...(typeof target.url === "string" ? { url: target.url } : {}), | |
| 221 | 241 | }; |
| 222 | 242 | } |
| 223 | 243 | |
| ... | ... | @@ -444,7 +464,16 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 444 | 464 | openPullRequest: (ref, target) => |
| 445 | 465 | set((state) => |
| 446 | 466 | userAction(state, scopedThreadKey(ref), (current) => { |
| 447 | return upsertSurface(current, pullRequestSurface(target)); | |
| 467 | const surface = pullRequestSurface(target); | |
| 468 | const next = upsertSurface(current, surface); | |
| 469 | return target.url | |
| 470 | ? { | |
| 471 | ...next, | |
| 472 | surfaces: next.surfaces.map((entry) => | |
| 473 | entry.id === surface.id ? surface : entry, | |
| 474 | ), | |
| 475 | } | |
| 476 | : next; | |
| 448 | 477 | }), |
| 449 | 478 | ), |
| 450 | 479 | openFile: (ref, relativePath, line) => |
apps/web/src/routes/_chat.pull-requests.tsx+30-21| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | 2 | import { Spinner } from "~/components/ui/spinner"; |
| 3 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 4 | import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; | |
| 5 | 4 | import type { |
| 6 | 5 | EnvironmentId, |
| 7 | 6 | ProjectId, |
| ... | ... | @@ -120,8 +119,12 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ |
| 120 | 119 | import { SidebarInset } from "../components/ui/sidebar"; |
| 121 | 120 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; |
| 122 | 121 | import { useLiveRefresh } from "../hooks/useLiveRefresh"; |
| 122 | import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; | |
| 123 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; | |
| 124 | import { toastManager } from "../components/ui/toast"; | |
| 123 | 125 | import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; |
| 124 | 126 | import { |
| 127 | PULL_REQUESTS_PANEL_REF, | |
| 125 | 128 | pullRequestSurfaceId, |
| 126 | 129 | selectActiveRightPanelSurface, |
| 127 | 130 | selectSelectedRightPanelSurface, |
| ... | ... | @@ -205,15 +208,6 @@ const PAGE_SIZE = 99; |
| 205 | 208 | const MAX_PAGE_SIZE = 500; |
| 206 | 209 | /** Stable empty map so the memos below do not see a new object on every render. */ |
| 207 | 210 | const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {}; |
| 208 | /** The list owns one environment-scoped right panel rather than borrowing a real thread's. */ | |
| 209 | const PULL_REQUESTS_PANEL_ID = ThreadId.make("pull-requests-panel"); | |
| 210 | /** | |
| 211 | * A fixed sentinel, not a real server: the panel is one workspace-level surface list (each | |
| 212 | * surface already carries the server it was read from), so its store key must not move when a | |
| 213 | * capable server disconnects or reconnects. Real environment ids are server-generated UUIDs, so | |
| 214 | * this string can never collide with one. | |
| 215 | */ | |
| 216 | const PULL_REQUESTS_PANEL_ENVIRONMENT_ID = "pull-requests-panel" as EnvironmentId; | |
| 217 | 211 | /** Stable so a read that is not wanted right now does not re-key on every render. */ |
| 218 | 212 | const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = []; |
| 219 | 213 | const EMPTY_PREVIEW_SESSIONS = {}; |
| ... | ... | @@ -424,13 +418,8 @@ function PullRequestsRouteView() { |
| 424 | 418 | // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref |
| 425 | 419 | // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab |
| 426 | 420 | // strip survives a capable server disconnecting or losing the pull-requests capability. |
| 427 | const rightPanelRef = useMemo( | |
| 428 | () => | |
| 429 | capableEnvironments.length === 0 | |
| 430 | ? null | |
| 431 | : scopeThreadRef(PULL_REQUESTS_PANEL_ENVIRONMENT_ID, PULL_REQUESTS_PANEL_ID), | |
| 432 | [capableEnvironments.length], | |
| 433 | ); | |
| 421 | const rightPanelRef = capableEnvironments.length === 0 ? null : PULL_REQUESTS_PANEL_REF; | |
| 422 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(rightPanelRef); | |
| 434 | 423 | const rightPanelState = useRightPanelStore((state) => |
| 435 | 424 | selectThreadRightPanelState(state.byThreadKey, rightPanelRef), |
| 436 | 425 | ); |
| ... | ... | @@ -453,7 +442,7 @@ function PullRequestsRouteView() { |
| 453 | 442 | rightPanelState.isOpen && selectedPullRequestSurface !== null, |
| 454 | 443 | rightPanelPresenceValue, |
| 455 | 444 | panelAnimationsActive, |
| 456 | rightPanelRef === null ? null : PULL_REQUESTS_PANEL_ID, | |
| 445 | rightPanelRef?.threadId ?? null, | |
| 457 | 446 | panelAnimationDurationMs, |
| 458 | 447 | ); |
| 459 | 448 | const rightPanelPresent = rightPanelPresence.present; |
| ... | ... | @@ -1852,8 +1841,27 @@ function PullRequestsRouteView() { |
| 1852 | 1841 | selectSurfaceInUrl(null); |
| 1853 | 1842 | }; |
| 1854 | 1843 | |
| 1855 | // This page has no ChatView, so the shared panel handles `rightPanel.close` | |
| 1856 | // itself. With nothing open the event falls through to its native meaning. | |
| 1844 | // This page has no ChatView, so it handles the shared panel shortcuts itself. | |
| 1845 | const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => { | |
| 1846 | if (!openPanelPullRequestUrl) return; | |
| 1847 | event.preventDefault(); | |
| 1848 | event.stopPropagation(); | |
| 1849 | if (event.repeat) return; | |
| 1850 | const url = openPanelPullRequestUrl; | |
| 1851 | void writeTextToClipboard(url, "pull request link").then( | |
| 1852 | (didCopy) => { | |
| 1853 | if (didCopy) | |
| 1854 | toastManager.add({ type: "success", title: "PR link copied", description: url }); | |
| 1855 | }, | |
| 1856 | (error) => { | |
| 1857 | toastManager.add({ | |
| 1858 | type: "error", | |
| 1859 | title: "Failed to copy PR link", | |
| 1860 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 1861 | }); | |
| 1862 | }, | |
| 1863 | ); | |
| 1864 | }); | |
| 1857 | 1865 | const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => { |
| 1858 | 1866 | if (activePullRequestSurface === null) return; |
| 1859 | 1867 | event.preventDefault(); |
| ... | ... | @@ -1867,6 +1875,7 @@ function PullRequestsRouteView() { |
| 1867 | 1875 | context: { terminalFocus: isTerminalFocused() }, |
| 1868 | 1876 | }); |
| 1869 | 1877 | if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); |
| 1878 | if (command === "thread.copyReference") copyPullRequestFromShortcut(event); | |
| 1870 | 1879 | }; |
| 1871 | 1880 | window.addEventListener("keydown", onKeyDown); |
| 1872 | 1881 | return () => window.removeEventListener("keydown", onKeyDown); |