| 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,7 +97,11 @@ import { |
| 97 | import { onOpenCommandPalette } from "../commandPaletteBus"; | 97 | import { onOpenCommandPalette } from "../commandPaletteBus"; |
| 98 | import { isPreviewFocused } from "../lib/previewFocus"; | 98 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 99 | import { isTerminalFocused } from "../lib/terminalFocus"; | 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 | import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; | 105 | import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; |
| 102 | import { | 106 | import { |
| 103 | cn, | 107 | cn, |
| ... | @@ -599,17 +603,25 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -599,17 +603,25 @@ function OpenCommandPaletteDialog(props: { |
| 599 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = | 603 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = |
| 600 | useHandleNewThread(); | 604 | useHandleNewThread(); |
| 601 | const projects = useProjects(); | 605 | const projects = useProjects(); |
| 602 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl( | 606 | const referenceThreadRef = |
| 603 | activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null, | 607 | pathname === "/pull-requests" |
| 604 | ); | 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 | const activeThreadReferenceCopyTarget = | 617 | const activeThreadReferenceCopyTarget = |
| 606 | activeThread == null | 618 | referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl) |
| 607 | ? null | 619 | ? null |
| 608 | : resolveThreadReferenceCopyTarget({ | 620 | : resolveThreadReferenceCopyTarget({ |
| 609 | threadId: activeThread.id, | 621 | threadId: referenceThreadRef.threadId, |
| 610 | openPanelPullRequestUrl, | 622 | openPanelPullRequestUrl, |
| 611 | linkedPullRequestUrl: | 623 | linkedPullRequestUrl: |
| 612 | activeThread.linkedPullRequest?.url ?? activeThread.branchPullRequest?.url ?? null, | 624 | activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null, |
| 613 | }); | 625 | }); |
| 614 | const copyActiveThreadReference = useCallback(async () => { | 626 | const copyActiveThreadReference = useCallback(async () => { |
| 615 | const target = activeThreadReferenceCopyTarget; | 627 | const target = activeThreadReferenceCopyTarget; |
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+1| ... | @@ -50,6 +50,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { | ... | @@ -50,6 +50,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { |
| 50 | ); | 50 | ); |
| 51 | return reference | 51 | return reference |
| 52 | ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ?? | 52 | ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ?? |
| 53 | reference.url ?? | ||
| 53 | gitHubPullRequestBrowserUrl( | 54 | gitHubPullRequestBrowserUrl( |
| 54 | project?.repositoryIdentity, | 55 | project?.repositoryIdentity, |
| 55 | reference.repository, | 56 | reference.repository, |
apps/web/src/rightPanelStore.test.ts+6| ... | @@ -587,6 +587,8 @@ describe("rightPanelStore", () => { | ... | @@ -587,6 +587,8 @@ describe("rightPanelStore", () => { |
| 587 | const second = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4910 }; | 587 | const second = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4910 }; |
| 588 | useRightPanelStore.getState().openPullRequest(refA, first); | 588 | useRightPanelStore.getState().openPullRequest(refA, first); |
| 589 | useRightPanelStore.getState().openPullRequest(refA, second); | 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 | useRightPanelStore.getState().openPullRequest(refA, first); | 592 | useRightPanelStore.getState().openPullRequest(refA, first); |
| 591 | 593 | ||
| 592 | const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); | 594 | const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); |
| ... | @@ -595,6 +597,10 @@ describe("rightPanelStore", () => { | ... | @@ -595,6 +597,10 @@ describe("rightPanelStore", () => { |
| 595 | pullRequestSurfaceId(second), | 597 | pullRequestSurfaceId(second), |
| 596 | ]); | 598 | ]); |
| 597 | expect(state.activeSurfaceId).toBe(pullRequestSurfaceId(first)); | 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 | it("keeps one pull request read from two servers as two tabs", () => { | 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,8 +7,13 @@ |
| 7 | * terminal surfaces point at terminal session ids, file surfaces point at | 7 | * terminal surfaces point at terminal session ids, file surfaces point at |
| 8 | * workspace paths, and diff/files remain singleton surfaces. | 8 | * workspace paths, and diff/files remain singleton surfaces. |
| 9 | */ | 9 | */ |
| 10 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | 10 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 11 | import type { ChatFileAttachment, ScopedThreadRef } from "@t3tools/contracts"; | 11 | import { |
| 12 | EnvironmentId, | ||
| 13 | ThreadId, | ||
| 14 | type ChatFileAttachment, | ||
| 15 | type ScopedThreadRef, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 12 | import { create } from "zustand"; | 17 | import { create } from "zustand"; |
| 13 | import { createJSONStorage, persist } from "zustand/middleware"; | 18 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 14 | 19 | ||
| ... | @@ -65,6 +70,7 @@ export type RightPanelSurface = | ... | @@ -65,6 +70,7 @@ export type RightPanelSurface = |
| 65 | projectId: string; | 70 | projectId: string; |
| 66 | repository: string; | 71 | repository: string; |
| 67 | number: number; | 72 | number: number; |
| 73 | url?: string; | ||
| 68 | } | 74 | } |
| 69 | | { id: "agents"; kind: "agents" }; | 75 | | { id: "agents"; kind: "agents" }; |
| 70 | 76 | ||
| ... | @@ -74,8 +80,14 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; | ... | @@ -74,8 +80,14 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; |
| 74 | // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. | 80 | // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. |
| 75 | const RIGHT_PANEL_STORAGE_VERSION = 11; | 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 | * state: reopening the app should show the list, not last session's tabs and detail fetches. | 91 | * state: reopening the app should show the list, not last session's tabs and detail fetches. |
| 80 | */ | 92 | */ |
| 81 | const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel"); | 93 | const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel"); |
| ... | @@ -109,7 +121,13 @@ interface RightPanelStoreState { | ... | @@ -109,7 +121,13 @@ interface RightPanelStoreState { |
| 109 | openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; | 121 | openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; |
| 110 | openPullRequest: ( | 122 | openPullRequest: ( |
| 111 | ref: ScopedThreadRef, | 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 | ) => void; | 131 | ) => void; |
| 114 | openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; | 132 | openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; |
| 115 | splitTerminal: ( | 133 | splitTerminal: ( |
| ... | @@ -210,6 +228,7 @@ export function pullRequestSurface(target: { | ... | @@ -210,6 +228,7 @@ export function pullRequestSurface(target: { |
| 210 | projectId: string; | 228 | projectId: string; |
| 211 | repository: string; | 229 | repository: string; |
| 212 | number: number; | 230 | number: number; |
| 231 | url?: string; | ||
| 213 | }): PullRequestSurface { | 232 | }): PullRequestSurface { |
| 214 | return { | 233 | return { |
| 215 | id: pullRequestSurfaceId(target), | 234 | id: pullRequestSurfaceId(target), |
| ... | @@ -218,6 +237,7 @@ export function pullRequestSurface(target: { | ... | @@ -218,6 +237,7 @@ export function pullRequestSurface(target: { |
| 218 | projectId: target.projectId, | 237 | projectId: target.projectId, |
| 219 | repository: target.repository, | 238 | repository: target.repository, |
| 220 | number: target.number, | 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,7 +464,16 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 444 | openPullRequest: (ref, target) => | 464 | openPullRequest: (ref, target) => |
| 445 | set((state) => | 465 | set((state) => |
| 446 | userAction(state, scopedThreadKey(ref), (current) => { | 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 | openFile: (ref, relativePath, line) => | 479 | openFile: (ref, relativePath, line) => |
apps/web/src/routes/_chat.pull-requests.tsx+30-21| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { Spinner } from "~/components/ui/spinner"; | 2 | import { Spinner } from "~/components/ui/spinner"; |
| 3 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 3 | import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 4 | import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts"; | ||
| 5 | import type { | 4 | import type { |
| 6 | EnvironmentId, | 5 | EnvironmentId, |
| 7 | ProjectId, | 6 | ProjectId, |
| ... | @@ -120,8 +119,12 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ | ... | @@ -120,8 +119,12 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ |
| 120 | import { SidebarInset } from "../components/ui/sidebar"; | 119 | import { SidebarInset } from "../components/ui/sidebar"; |
| 121 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; | 120 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; |
| 122 | import { useLiveRefresh } from "../hooks/useLiveRefresh"; | 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 | import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; | 125 | import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; |
| 124 | import { | 126 | import { |
| 127 | PULL_REQUESTS_PANEL_REF, | ||
| 125 | pullRequestSurfaceId, | 128 | pullRequestSurfaceId, |
| 126 | selectActiveRightPanelSurface, | 129 | selectActiveRightPanelSurface, |
| 127 | selectSelectedRightPanelSurface, | 130 | selectSelectedRightPanelSurface, |
| ... | @@ -205,15 +208,6 @@ const PAGE_SIZE = 99; | ... | @@ -205,15 +208,6 @@ const PAGE_SIZE = 99; |
| 205 | const MAX_PAGE_SIZE = 500; | 208 | const MAX_PAGE_SIZE = 500; |
| 206 | /** Stable empty map so the memos below do not see a new object on every render. */ | 209 | /** Stable empty map so the memos below do not see a new object on every render. */ |
| 207 | const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {}; | 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 | /** Stable so a read that is not wanted right now does not re-key on every render. */ | 211 | /** Stable so a read that is not wanted right now does not re-key on every render. */ |
| 218 | const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = []; | 212 | const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = []; |
| 219 | const EMPTY_PREVIEW_SESSIONS = {}; | 213 | const EMPTY_PREVIEW_SESSIONS = {}; |
| ... | @@ -424,13 +418,8 @@ function PullRequestsRouteView() { | ... | @@ -424,13 +418,8 @@ function PullRequestsRouteView() { |
| 424 | // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref | 418 | // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref |
| 425 | // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab | 419 | // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab |
| 426 | // strip survives a capable server disconnecting or losing the pull-requests capability. | 420 | // strip survives a capable server disconnecting or losing the pull-requests capability. |
| 427 | const rightPanelRef = useMemo( | 421 | const rightPanelRef = capableEnvironments.length === 0 ? null : PULL_REQUESTS_PANEL_REF; |
| 428 | () => | 422 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(rightPanelRef); |
| 429 | capableEnvironments.length === 0 | ||
| 430 | ? null | ||
| 431 | : scopeThreadRef(PULL_REQUESTS_PANEL_ENVIRONMENT_ID, PULL_REQUESTS_PANEL_ID), | ||
| 432 | [capableEnvironments.length], | ||
| 433 | ); | ||
| 434 | const rightPanelState = useRightPanelStore((state) => | 423 | const rightPanelState = useRightPanelStore((state) => |
| 435 | selectThreadRightPanelState(state.byThreadKey, rightPanelRef), | 424 | selectThreadRightPanelState(state.byThreadKey, rightPanelRef), |
| 436 | ); | 425 | ); |
| ... | @@ -453,7 +442,7 @@ function PullRequestsRouteView() { | ... | @@ -453,7 +442,7 @@ function PullRequestsRouteView() { |
| 453 | rightPanelState.isOpen && selectedPullRequestSurface !== null, | 442 | rightPanelState.isOpen && selectedPullRequestSurface !== null, |
| 454 | rightPanelPresenceValue, | 443 | rightPanelPresenceValue, |
| 455 | panelAnimationsActive, | 444 | panelAnimationsActive, |
| 456 | rightPanelRef === null ? null : PULL_REQUESTS_PANEL_ID, | 445 | rightPanelRef?.threadId ?? null, |
| 457 | panelAnimationDurationMs, | 446 | panelAnimationDurationMs, |
| 458 | ); | 447 | ); |
| 459 | const rightPanelPresent = rightPanelPresence.present; | 448 | const rightPanelPresent = rightPanelPresence.present; |
| ... | @@ -1852,8 +1841,27 @@ function PullRequestsRouteView() { | ... | @@ -1852,8 +1841,27 @@ function PullRequestsRouteView() { |
| 1852 | selectSurfaceInUrl(null); | 1841 | selectSurfaceInUrl(null); |
| 1853 | }; | 1842 | }; |
| 1854 | 1843 | ||
| 1855 | // This page has no ChatView, so the shared panel handles `rightPanel.close` | 1844 | // This page has no ChatView, so it handles the shared panel shortcuts itself. |
| 1856 | // itself. With nothing open the event falls through to its native meaning. | 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 | const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => { | 1865 | const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => { |
| 1858 | if (activePullRequestSurface === null) return; | 1866 | if (activePullRequestSurface === null) return; |
| 1859 | event.preventDefault(); | 1867 | event.preventDefault(); |
| ... | @@ -1867,6 +1875,7 @@ function PullRequestsRouteView() { | ... | @@ -1867,6 +1875,7 @@ function PullRequestsRouteView() { |
| 1867 | context: { terminalFocus: isTerminalFocused() }, | 1875 | context: { terminalFocus: isTerminalFocused() }, |
| 1868 | }); | 1876 | }); |
| 1869 | if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); | 1877 | if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); |
| 1878 | if (command === "thread.copyReference") copyPullRequestFromShortcut(event); | ||
| 1870 | }; | 1879 | }; |
| 1871 | window.addEventListener("keydown", onKeyDown); | 1880 | window.addEventListener("keydown", onKeyDown); |
| 1872 | return () => window.removeEventListener("keydown", onKeyDown); | 1881 | return () => window.removeEventListener("keydown", onKeyDown); |