From 0de538753c81753dcb0624022b11b5a590e7ba2a Mon Sep 17 00:00:00 2001 From: Defmon3 <111503233+Defmon3@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:06:47 +0200 Subject: [PATCH] feat(#6315): combine repository panels --- .../BranchToolbarBranchSelector.tsx | 4 +- apps/web/src/components/ChatView.tsx | 51 +- apps/web/src/components/CommandPalette.tsx | 4 +- .../src/components/RepositoryPanel.test.tsx | 16 + apps/web/src/components/RepositoryPanel.tsx | 113 ++++ .../src/components/RightPanelTabs.test.tsx | 3 +- apps/web/src/components/RightPanelTabs.tsx | 62 +-- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/rightPanelStore.test.ts | 198 ++++++- apps/web/src/rightPanelStore.ts | 512 +++++++++++------- apps/web/src/routes/_chat.issues.tsx | 1 - apps/web/src/routes/_chat.pull-requests.tsx | 1 - docs/user/source-control.md | 2 +- 13 files changed, 699 insertions(+), 270 deletions(-) create mode 100644 apps/web/src/components/RepositoryPanel.test.tsx create mode 100644 apps/web/src/components/RepositoryPanel.tsx diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9838593a23f7..e645a36232e9 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -795,7 +795,9 @@ export function BranchToolbarBranchSelector({ number={prNumber} url={prUrl} status={displayedPrStatus} - onOpenStack={() => useRightPanelStore.getState().open(threadRef, "pull-requests")} + onOpenStack={() => + useRightPanelStore.getState().openRepository(threadRef, "pull-requests") + } onOpenPullRequest={(event) => { if (prUrl) openPrLink(event, prUrl); }} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d34c5654615f..f40f8ff89310 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -223,7 +223,6 @@ import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavaila import { RightPanelTabs, type IssueTabStatus } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; -import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { useDeviceState } from "~/state/device"; import { DeviceSetup } from "./device/DeviceSetup"; import { Dialog } from "./ui/dialog"; @@ -634,7 +633,7 @@ const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPrevie const DevicePanel = lazy(() => import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), ); -const GitHistoryPanel = lazy(() => import("./GitHistoryPanel")); +const RepositoryPanel = lazy(() => import("./RepositoryPanel")); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ @@ -4591,10 +4590,6 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); - const addGitHistorySurface = useCallback(() => { - if (!activeThreadRef || !isGitRepo || !supportsGitHistory) return; - useRightPanelStore.getState().open(activeThreadRef, "git-history"); - }, [activeThreadRef, isGitRepo, supportsGitHistory]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -4613,8 +4608,18 @@ export default function ChatView(props: ChatViewProps) { isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; const addPullRequestsSurface = useCallback(() => { if (!activeThreadRef || !pullRequestsSurfaceAvailable) return; - useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); + useRightPanelStore.getState().openRepository(activeThreadRef, "pull-requests"); }, [activeThreadRef, pullRequestsSurfaceAvailable]); + const addRepositorySurface = useCallback(() => { + if (!activeThreadRef || (!(isGitRepo && supportsGitHistory) && !pullRequestsSurfaceAvailable)) + return; + useRightPanelStore + .getState() + .openRepository( + activeThreadRef, + isGitRepo && supportsGitHistory ? "history" : "pull-requests", + ); + }, [activeThreadRef, isGitRepo, pullRequestsSurfaceAvailable, supportsGitHistory]); const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); @@ -4864,7 +4869,7 @@ export default function ChatView(props: ChatViewProps) { ) { panels.openProactive( activeThreadRef, - { id: "pull-requests", kind: "pull-requests" }, + { id: "repository", kind: "repository", view: "pull-requests" }, userActionRevision, ); } else if ( @@ -9750,17 +9755,19 @@ export default function ChatView(props: ChatViewProps) { workspaceMutationId={workspaceMutationId} /> - ) : renderedRightPanelSurface?.kind === "git-history" && - supportsGitHistory && - isGitRepo && - gitCwd !== null ? ( + ) : renderedRightPanelSurface?.kind === "repository" ? ( - + useRightPanelStore.getState().selectRepositoryView(activeThreadRef, view) + } /> ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( @@ -9865,8 +9872,6 @@ export default function ChatView(props: ChatViewProps) { }} onStateChange={handleIssueTabStatusChange} /> - ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( - ) : renderedRightPanelSurface?.kind === "agents" ? ( , run: async () => { - useRightPanelStore.getState().open(threadRef, "pull-requests"); + useRightPanelStore.getState().openRepository(threadRef, "pull-requests"); }, }); } diff --git a/apps/web/src/components/RepositoryPanel.test.tsx b/apps/web/src/components/RepositoryPanel.test.tsx new file mode 100644 index 000000000000..0219de84f08c --- /dev/null +++ b/apps/web/src/components/RepositoryPanel.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { repositoryViewFromKey } from "./RepositoryPanel"; + +describe("RepositoryPanel", () => { + it("moves through repository views with the tablist keys", () => { + expect(repositoryViewFromKey("history", "ArrowRight")).toBe("pull-requests"); + expect(repositoryViewFromKey("pull-requests", "ArrowLeft")).toBe("history"); + expect(repositoryViewFromKey("pull-requests", "Home")).toBe("history"); + expect(repositoryViewFromKey("history", "End")).toBe("pull-requests"); + }); + + it("leaves unrelated keys to the browser", () => { + expect(repositoryViewFromKey("history", "Enter")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/RepositoryPanel.tsx b/apps/web/src/components/RepositoryPanel.tsx new file mode 100644 index 000000000000..aa363e688f1b --- /dev/null +++ b/apps/web/src/components/RepositoryPanel.tsx @@ -0,0 +1,113 @@ +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import { Activity, lazy, Suspense, useRef, useState, type KeyboardEvent } from "react"; + +import type { RepositoryView } from "~/rightPanelStore"; +import type { GitHistoryPanelStore } from "./git-history/GitHistoryPanelState"; + +import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; +import { Button } from "./ui/button"; + +const GitHistoryPanel = lazy(() => import("./GitHistoryPanel")); + +const views = ["history", "pull-requests"] as const; + +export function repositoryViewFromKey(view: RepositoryView, key: string): RepositoryView | null { + const index = views.indexOf(view); + if (key === "ArrowRight") return views[(index + 1) % views.length] ?? null; + if (key === "ArrowLeft") return views[(index + views.length - 1) % views.length] ?? null; + if (key === "Home") return views[0] ?? null; + if (key === "End") return views.at(-1) ?? null; + return null; +} + +export default function RepositoryPanel(props: { + readonly environmentId: EnvironmentId; + readonly cwd: string | null; + readonly threadRef: ScopedThreadRef; + readonly view: RepositoryView; + readonly active: boolean; + readonly gitHistoryAvailable: boolean; + readonly gitHistoryPanelStore: GitHistoryPanelStore; + readonly onViewChange: (view: RepositoryView) => void; +}) { + const tabs = useRef>([]); + const [historyActivated, setHistoryActivated] = useState(props.view === "history"); + if (props.view === "history" && !historyActivated) setHistoryActivated(true); + const select = (view: RepositoryView) => props.onViewChange(view); + const onKeyDown = (event: KeyboardEvent, view: RepositoryView) => { + const next = repositoryViewFromKey(view, event.key); + if (next === null) return; + event.preventDefault(); + select(next); + tabs.current[views.indexOf(next)]?.focus(); + }; + + return ( +
+
+ {views.map((view, index) => ( + + ))} +
+ {historyActivated ? ( + +
+ {props.gitHistoryAvailable && props.cwd !== null ? ( + + + + ) : ( +
+ Update the environment server to browse Git History. +
+ )} +
+
+ ) : null} + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 2dd2cd0a4de7..f6e22893661c 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -119,8 +119,9 @@ function renderTabs( onAddBrowserInProfile={() => undefined} onAddTerminal={() => undefined} onAddPullRequest={() => undefined} + onAddRepository={() => undefined} + repositoryAvailable={false} onAddIssue={() => undefined} - onAddPullRequests={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} onAddAgents={() => undefined} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 37fff904997a..f92e89c20e7a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -122,10 +122,9 @@ interface RightPanelTabsProps { onAddBrowserInProfile: (profileId: string) => void; onAddTerminal: () => void; onAddDiff: () => void; - onAddGitHistory?: (() => void) | undefined; + onAddRepository?: (() => void) | undefined; onAddFiles: () => void; onAddPullRequest: () => void; - onAddPullRequests: () => void; onAddAgents: () => void; /** * Picking an issue needs a project to pick from, which only a thread has: the list pages reuse @@ -137,7 +136,7 @@ interface RightPanelTabsProps { browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; - gitHistoryAvailable?: boolean | undefined; + repositoryAvailable?: boolean | undefined; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; @@ -179,10 +178,9 @@ const SURFACE_DISABLED_REASONS = { terminal: "Terminal surfaces are only available from a project thread.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", - gitHistory: "Git History is only available for Git repositories on updated servers.", + gitHistory: "Repository is available for Git repositories or threads with linked pull requests.", pullRequest: "This thread's branch has no pull request yet.", issue: "Issues are only available from a project checked out from a host.", - pullRequests: "No linked pull requests are available for this thread.", agents: "Agents are only available from a thread.", device: "Devices are only available from a thread.", } as const; @@ -205,10 +203,9 @@ const SURFACE_UNAVAILABLE_HINTS = { terminal: "Available when a project is open.", files: "Available when a project is open.", diff: "Available for Git repositories.", - gitHistory: "Available for Git repositories on updated servers.", + gitHistory: "Available for Git repositories or threads with linked pull requests.", pullRequest: "No pull request on this branch yet.", issue: "Available for projects with a host.", - pullRequests: "No linked pull requests available.", agents: "Available from a thread.", device: "Available from a thread.", } as const; @@ -346,17 +343,16 @@ function RightPanelEmptyState(props: { browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; onAddTerminal: () => void; onAddDiff: () => void; - onAddGitHistory?: (() => void) | undefined; + onAddRepository?: (() => void) | undefined; onAddFiles: () => void; onAddPullRequest: () => void; onAddIssue: () => void; - onAddPullRequests: () => void; onAddAgents: () => void; onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; - gitHistoryAvailable?: boolean | undefined; + repositoryAvailable?: boolean | undefined; filesAvailable: boolean; pullRequestAvailable: boolean; issueAvailable: boolean; @@ -406,13 +402,13 @@ function RightPanelEmptyState(props: { badgeCount: 0, }, { - label: "History", - description: "Browse commits and branches.", + label: "Repository", + description: "Browse history and linked pull requests.", icon: GitBranch, shortcut: "H", - available: props.gitHistoryAvailable ?? false, + available: props.repositoryAvailable ?? false, disabledReason: SURFACE_UNAVAILABLE_HINTS.gitHistory, - onClick: props.onAddGitHistory ?? (() => undefined), + onClick: props.onAddRepository ?? (() => undefined), badgeCount: 0, }, { @@ -434,15 +430,6 @@ function RightPanelEmptyState(props: { onClick: props.onAddIssue, badgeCount: 0, }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, - onClick: props.onAddPullRequests, - badgeCount: 0, - }, { label: "Agents", icon: Bot, @@ -667,8 +654,8 @@ function surfaceTitle( switch (surface.kind) { case "diff": return "Diff"; - case "git-history": - return "History"; + case "repository": + return "Repository"; case "files": return "Files"; case "file": @@ -686,8 +673,6 @@ function surfaceTitle( // The strip says what the tab is showing, which for the browser is either of two things. case "issues": return surface.selected ? `#${surface.selected.number}` : "Issues"; - case "pull-requests": - return "Pull requests"; case "agents": return "Agents"; case "device": @@ -752,7 +737,7 @@ function SurfaceIcon({ } case "diff": return ; - case "git-history": + case "repository": return ; case "files": return ; @@ -795,8 +780,6 @@ function SurfaceIcon({ /> ); } - case "pull-requests": - return ; case "agents": return ; case "device": @@ -986,12 +969,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onClick: props.onAddDiff, }, { - label: "History", + label: "Repository", icon: GitBranch, shortcut: "H", - available: props.gitHistoryAvailable ?? false, + available: props.repositoryAvailable ?? false, disabledReason: SURFACE_DISABLED_REASONS.gitHistory, - onClick: props.onAddGitHistory ?? (() => undefined), + onClick: props.onAddRepository ?? (() => undefined), }, { label: "Pull request", @@ -1009,14 +992,6 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.issue, onClick: props.onAddIssue, }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequests, - onClick: props.onAddPullRequests, - }, { label: "Agents", icon: Bot, @@ -1504,17 +1479,16 @@ export function RightPanelTabs(props: RightPanelTabsProps) { browserProfiles={browserProfiles} onAddTerminal={props.onAddTerminal} onAddDiff={props.onAddDiff} - onAddGitHistory={props.onAddGitHistory} + onAddRepository={props.onAddRepository} onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} onAddIssue={props.onAddIssue} - onAddPullRequests={props.onAddPullRequests} onAddAgents={props.onAddAgents} onAddDevice={props.onAddDevice} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} - gitHistoryAvailable={props.gitHistoryAvailable} + repositoryAvailable={props.repositoryAvailable} filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} issueAvailable={props.issueAvailable} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 23c55fe8ecce..0923d1ee7d21 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1504,7 +1504,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ? resolveThreadPullRequestBadge(thread.pullRequests) : null; const handlePrStackClick = useCallback(() => { - useRightPanelStore.getState().open(threadRef, "pull-requests"); + useRightPanelStore.getState().openRepository(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); const prBadge = diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index a71491535a88..008ca5cc0414 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -114,11 +114,6 @@ describe("rightPanelStore", () => { it.each([ { order: "diff-first", surface: linkedPullRequest }, { order: "pull-request-first", surface: linkedPullRequest }, - { order: "diff-first", surface: { id: "pull-requests", kind: "pull-requests" } as const }, - { - order: "pull-request-first", - surface: { id: "pull-requests", kind: "pull-requests" } as const, - }, ])( "prioritizes $surface.kind over browser and diff with $order delivery", ({ order, surface }) => { @@ -138,12 +133,32 @@ describe("rightPanelStore", () => { }, ); - it("opens Git History as a singleton surface", () => { - useRightPanelStore.getState().open(refA, "git-history"); + it("opens Repository as a singleton surface", () => { + useRightPanelStore.getState().openRepository(refA, "pull-requests"); expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - id: "git-history", - kind: "git-history", + id: "repository", + kind: "repository", + view: "pull-requests", + }); + }); + + it("updates Repository proactively without advancing the user revision", () => { + const store = useRightPanelStore.getState(); + const revision = store.getUserActionRevision(refA); + + expect( + store.openProactive( + refA, + { id: "repository", kind: "repository", view: "pull-requests" }, + revision, + ), + ).toBe(true); + expect(store.getUserActionRevision(refA)).toBe(revision); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "repository", + kind: "repository", + view: "pull-requests", }); }); @@ -195,9 +210,6 @@ describe("rightPanelStore", () => { expect(store.openProactive(refA, completedDiff, revision)).toBe(false); expect(store.openProactive(refA, linkedPullRequest, revision)).toBe(false); - expect( - store.openProactive(refA, { id: "pull-requests", kind: "pull-requests" }, revision), - ).toBe(false); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toBe( chosen, ); @@ -395,6 +407,168 @@ describe("rightPanelStore", () => { ).toEqual({ byThreadKey: { "env-1:thread-A": panelState } }); }); + it("migrates separate History and linked pull request tabs into Repository", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "pull-requests", + surfaces: [ + { id: "git-history", kind: "git-history" }, + { id: "pull-requests", kind: "pull-requests" }, + ], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "repository", + surfaces: [{ id: "repository", kind: "repository", view: "pull-requests" }], + }, + }, + }); + }); + + it.each([ + ["git-history", "history"], + ["pull-requests", "pull-requests"], + ] as const)("preserves the active %s repository view", (activeSurfaceId, view) => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId, + surfaces: [ + { id: "diff", kind: "diff" }, + { id: activeSurfaceId, kind: activeSurfaceId }, + ], + }, + }, + }), + ).toMatchObject({ + byThreadKey: { + "env-1:thread-A": { + activeSurfaceId: "repository", + surfaces: [ + { id: "diff", kind: "diff" }, + { id: "repository", kind: "repository", view }, + ], + }, + }, + }); + }); + + it("coalesces duplicate legacy repository tabs without moving their first tab", () => { + const migrated = migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "pull-requests", + surfaces: [ + { id: "diff", kind: "diff" }, + { id: "git-history", kind: "git-history" }, + { id: "pull-requests", kind: "pull-requests" }, + { id: "pull-requests", kind: "pull-requests" }, + { id: "files", kind: "files" }, + ], + }, + }, + }); + expect(migrated.byThreadKey["env-1:thread-A"]?.surfaces).toEqual([ + { id: "diff", kind: "diff" }, + { id: "repository", kind: "repository", view: "pull-requests" }, + { id: "files", kind: "files" }, + ]); + }); + + it("drops malformed persisted surfaces", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "repository", + surfaces: [null, { id: "unknown", kind: "unknown" }], + }, + }, + }), + ).toEqual({ + byThreadKey: { "env-1:thread-A": { isOpen: false, activeSurfaceId: null, surfaces: [] } }, + }); + }); + + it("preserves valid persisted surface payloads", () => { + const attachment = { + type: "file", + id: "attachment-1", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 1, + }; + const migrated = migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "repository", + surfaces: [ + { id: "repository", kind: "repository", view: "pull-requests" }, + { id: "browser:new", kind: "preview", resourceId: null }, + { + id: "device:nucbox:pixel", + kind: "device", + target: { hostId: "nucbox", deviceId: "pixel", platform: "android", name: "Pixel" }, + title: "Pixel", + }, + { + id: "attachment:attachment-1", + kind: "file", + relativePath: "notes.txt", + revealLine: null, + revealRequestId: 2, + attachment, + }, + { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }, + ], + }, + }, + }); + expect(migrated.byThreadKey["env-1:thread-A"]?.surfaces).toEqual([ + { id: "repository", kind: "repository", view: "pull-requests" }, + { id: "browser:new", kind: "preview", resourceId: null }, + { + id: "device:nucbox:pixel", + kind: "device", + target: { hostId: "nucbox", deviceId: "pixel", platform: "android", name: "Pixel" }, + title: "Pixel", + }, + { + id: "attachment:attachment-1", + kind: "file", + relativePath: "notes.txt", + revealLine: null, + revealRequestId: 2, + attachment, + }, + { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }, + ]); + }); + it("drops the issues list's shared panel so a restart opens the page fresh", () => { const id = issueSurfaceId({ projectId: "project-a", diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 88e23a0f910c..f8907714f8fb 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -11,9 +11,11 @@ import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environ import { EnvironmentId, ThreadId, - type ChatFileAttachment, + ChatFileAttachment, type ScopedThreadRef, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -21,7 +23,7 @@ import { resolveStorage } from "./lib/storage"; const RIGHT_PANEL_KINDS = [ "diff", - "git-history", + "repository", "files", "file", "preview", @@ -30,11 +32,12 @@ const RIGHT_PANEL_KINDS = [ "pull-request", "issue", "issues", - "pull-requests", "agents", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; +export type RepositoryView = "history" | "pull-requests"; + export interface DeviceTabTarget { hostId: string; deviceId: string; @@ -55,7 +58,7 @@ export type RightPanelSurface = splitDirection?: "horizontal" | "vertical"; } | { id: "diff"; kind: "diff" } - | { id: "git-history"; kind: "git-history" } + | { id: "repository"; kind: "repository"; view: RepositoryView } | { id: "files"; kind: "files" } | { id: `file:${string}` | `attachment:${string}`; @@ -110,8 +113,6 @@ export type RightPanelSurface = kind: "issues"; selected: { projectId: string; provider?: string; repository: string; number: number } | null; } - /** The thread's linked pull requests, one singleton tab beside any number of `pull-request` tabs. */ - | { id: "pull-requests"; kind: "pull-requests" } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; @@ -120,7 +121,118 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. // v12 adds the device and issue surfaces. // v13 adds the issues browser surface and stops persisting the issues list panel. -const RIGHT_PANEL_STORAGE_VERSION = 13; +// v14 combines Git History and linked pull requests into Repository. +const RIGHT_PANEL_STORAGE_VERSION = 14; + +const PersistedRightPanelState = Schema.Struct({ + byThreadKey: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}); +type PersistedRightPanelState = typeof PersistedRightPanelState.Type; +const PersistedThreadRightPanelState = Schema.Struct({ + isOpen: Schema.optionalKey(Schema.Boolean), + activeSurfaceId: Schema.optionalKey(Schema.NullOr(Schema.String)), + surfaces: Schema.optionalKey(Schema.Array(Schema.Unknown)), + dismissedDeviceSurfaceIds: Schema.optionalKey(Schema.Array(Schema.String)), +}); +type PersistedThreadRightPanelState = typeof PersistedThreadRightPanelState.Type; +const SurfaceId = (prefix: Prefix) => + Schema.TemplateLiteral([prefix, Schema.String]); +const DeviceTabTarget = Schema.Struct({ + hostId: Schema.String, + deviceId: Schema.String, + platform: Schema.Literals(["ios", "android"]), + name: Schema.String, +}); +const PersistedIssueSelection = Schema.Struct({ + projectId: Schema.String, + provider: Schema.optionalKey(Schema.String), + repository: Schema.String, + number: Schema.Int.check(Schema.isGreaterThan(0)), +}); +const PersistedRightPanelSurface = Schema.Union([ + Schema.Struct({ id: Schema.Literal("diff"), kind: Schema.Literal("diff") }), + Schema.Struct({ id: Schema.Literal("files"), kind: Schema.Literal("files") }), + Schema.Struct({ id: Schema.Literal("agents"), kind: Schema.Literal("agents") }), + Schema.Struct({ + id: Schema.Literal("repository"), + kind: Schema.Literal("repository"), + view: Schema.Literals(["history", "pull-requests"]), + }), + Schema.Struct({ + id: Schema.Literal("browser:new"), + kind: Schema.Literal("preview"), + resourceId: Schema.Null, + }), + Schema.Struct({ + id: SurfaceId("browser:"), + kind: Schema.Literal("preview"), + resourceId: Schema.String, + }), + Schema.Struct({ + id: Schema.Union([Schema.Literal("device"), SurfaceId("device:")]), + kind: Schema.Literal("device"), + target: Schema.optionalKey(DeviceTabTarget), + title: Schema.optionalKey(Schema.String), + }), + Schema.Struct({ + id: SurfaceId("file:"), + kind: Schema.Literal("file"), + relativePath: Schema.String, + revealLine: Schema.optionalKey(Schema.NullOr(Schema.Finite)), + revealRequestId: Schema.optionalKey(Schema.Finite), + }), + Schema.Struct({ + id: SurfaceId("attachment:"), + kind: Schema.Literal("file"), + relativePath: Schema.String, + revealLine: Schema.optionalKey(Schema.NullOr(Schema.Finite)), + revealRequestId: Schema.optionalKey(Schema.Finite), + attachment: ChatFileAttachment, + }), + Schema.Struct({ + id: SurfaceId("terminal:"), + kind: Schema.Literal("terminal"), + resourceId: Schema.String, + terminalIds: Schema.optionalKey(Schema.Array(Schema.String)), + activeTerminalId: Schema.optionalKey(Schema.String), + splitDirection: Schema.optionalKey(Schema.Literals(["horizontal", "vertical"])), + }), + Schema.Struct({ + id: Schema.String, + kind: Schema.Literal("pull-request"), + projectId: Schema.String, + repository: Schema.String, + number: Schema.Int.check(Schema.isGreaterThan(0)), + environmentId: Schema.optionalKey(Schema.String), + host: Schema.optionalKey(Schema.String), + url: Schema.optionalKey(Schema.String), + }), + Schema.Struct({ + id: Schema.String, + kind: Schema.Literal("issue"), + projectId: Schema.String, + repository: Schema.String, + number: Schema.Int.check(Schema.isGreaterThan(0)), + environmentId: Schema.optionalKey(Schema.String), + provider: Schema.optionalKey(Schema.String), + }), + Schema.Struct({ + id: Schema.Literal("issues"), + kind: Schema.Literal("issues"), + selected: Schema.optionalKey(Schema.Unknown), + }), + Schema.Struct({ id: Schema.Literal("pull-requests"), kind: Schema.Literal("pull-requests") }), + Schema.Struct({ id: Schema.Literal("git-history"), kind: Schema.Literal("git-history") }), + Schema.Struct({ id: Schema.Literal("plan"), kind: Schema.Literal("plan") }), +]); +type PersistedRightPanelSurface = typeof PersistedRightPanelSurface.Type; + +const decodePersistedRightPanelState = Schema.decodeUnknownOption(PersistedRightPanelState); +const decodePersistedThreadRightPanelState = Schema.decodeUnknownOption( + PersistedThreadRightPanelState, +); +const decodePersistedRightPanelSurface = Schema.decodeUnknownOption(PersistedRightPanelSurface); +const decodePersistedIssueSelection = Schema.decodeUnknownOption(PersistedIssueSelection); /** A fixed workspace-level ref: each PR surface carries its own real environment. */ export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( @@ -155,7 +267,7 @@ interface RightPanelStoreState { */ openProactive: ( ref: ScopedThreadRef, - surface: Extract, + surface: Extract, expectedUserActionRevision: number, ) => boolean; open: ( @@ -189,6 +301,8 @@ interface RightPanelStoreState { }, ) => void; openIssues: (ref: ScopedThreadRef) => void; + openRepository: (ref: ScopedThreadRef, view: RepositoryView) => void; + selectRepositoryView: (ref: ScopedThreadRef, view: RepositoryView) => void; /** What the issue browser is showing: an issue, or null for the list it was picked from. */ selectIssueInPanel: ( ref: ScopedThreadRef, @@ -235,12 +349,10 @@ const singletonSurface = ( switch (kind) { case "diff": return { id: "diff", kind }; - case "git-history": - return { id: "git-history", kind }; + case "repository": + return { id: "repository", kind, view: "history" }; case "files": return { id: "files", kind }; - case "pull-requests": - return { id: "pull-requests", kind }; case "agents": return { id: "agents", kind }; case "device": @@ -359,27 +471,6 @@ function issueSurface(target: { export type IssuesSurface = Extract; -/** A persisted selection is only usable if it still names an issue, so a broken one reads as none. */ -function normalizeIssueSelection(value: unknown): IssuesSurface["selected"] { - if (!value || typeof value !== "object") return null; - const { projectId, provider, repository, number } = value as Record; - if ( - typeof projectId !== "string" || - typeof repository !== "string" || - typeof number !== "number" || - !Number.isSafeInteger(number) || - number < 1 - ) { - return null; - } - return { - projectId, - ...(typeof provider === "string" ? { provider } : {}), - repository, - number, - }; -} - export function updateIssueTabStatus( statuses: Readonly>, surfaceId: string, @@ -473,160 +564,177 @@ function normalizeRevealLine(line: number | undefined): number | null { export function migratePersistedRightPanelState(persistedState: unknown): { byThreadKey: Record; } { - if (!persistedState || typeof persistedState !== "object") { - return { byThreadKey: {} }; - } - const byThreadKey = - "byThreadKey" in persistedState && - persistedState.byThreadKey && - typeof persistedState.byThreadKey === "object" - ? Object.fromEntries( - Object.entries(persistedState.byThreadKey as Record) - .filter( - ([threadKey]) => !isPullRequestsPanelKey(threadKey) && !isIssuesPanelKey(threadKey), - ) - .map(([threadKey, threadState]) => { - const validThreadState = - threadState && typeof threadState === "object" ? threadState : null; - const surfaces = Array.isArray(validThreadState?.surfaces) - ? validThreadState.surfaces.flatMap((surface) => { - // Dropped surface kind: plans now render inline in the - // transcript (v9). - if ((surface as { kind?: string }).kind === "plan") return []; - if (surface.kind === "file") { - const revealLine = - typeof surface.revealLine === "number" && - Number.isFinite(surface.revealLine) - ? Math.max(1, Math.trunc(surface.revealLine)) - : null; - const revealRequestId = - typeof surface.revealRequestId === "number" && - Number.isSafeInteger(surface.revealRequestId) && - surface.revealRequestId >= 0 - ? surface.revealRequestId - : 0; - return [{ ...surface, revealLine, revealRequestId }]; - } - if (surface.kind === "pull-request") { - if ( - typeof surface.projectId !== "string" || - typeof surface.repository !== "string" || - typeof surface.number !== "number" || - !Number.isSafeInteger(surface.number) || - surface.number < 1 - ) { - return []; - } - const { environmentId, ...rest } = surface; - // Anything else stored under that name is not an environment. - return [ - pullRequestSurface({ - ...rest, - ...(typeof environmentId === "string" ? { environmentId } : {}), - }), - ]; - } - if (surface.kind === "issue") { - if ( - typeof surface.projectId !== "string" || - typeof surface.repository !== "string" || - typeof surface.number !== "number" || - !Number.isSafeInteger(surface.number) || - surface.number < 1 - ) { - return []; - } - const { environmentId, provider, ...rest } = surface; - return [ - issueSurface({ - ...rest, - ...(typeof environmentId === "string" ? { environmentId } : {}), - ...(typeof provider === "string" ? { provider } : {}), - }), - ]; - } - if (surface.kind === "issues") { - return [ - { - id: "issues", - kind: "issues", - selected: normalizeIssueSelection(surface.selected), - }, - ]; - } - if (surface.kind !== "terminal") return [surface]; - if ( - !("resourceId" in surface) || - typeof surface.resourceId !== "string" || - surface.id !== `terminal:${surface.resourceId}` - ) { - return []; - } - const terminalIds = - "terminalIds" in surface && Array.isArray(surface.terminalIds) - ? [ - ...new Set( - surface.terminalIds.filter( - (terminalId): terminalId is string => - typeof terminalId === "string", - ), - ), - ] - : [surface.resourceId]; - const activeTerminalId = - "activeTerminalId" in surface && - typeof surface.activeTerminalId === "string" && - terminalIds.includes(surface.activeTerminalId) - ? surface.activeTerminalId - : (terminalIds[0] ?? surface.resourceId); - return [ + const state = Option.getOrElse( + decodePersistedRightPanelState(persistedState), + (): PersistedRightPanelState => ({}), + ); + const byThreadKey = state.byThreadKey + ? Object.fromEntries( + Object.entries(state.byThreadKey) + .filter( + ([threadKey]) => !isPullRequestsPanelKey(threadKey) && !isIssuesPanelKey(threadKey), + ) + .map(([threadKey, rawThreadState]) => { + const threadState = Option.getOrElse( + decodePersistedThreadRightPanelState(rawThreadState), + (): PersistedThreadRightPanelState => ({}), + ); + const rawActiveSurfaceId = threadState.activeSurfaceId; + const rawSurfaces = threadState.surfaces ?? []; + const repositoryView = + rawActiveSurfaceId === "pull-requests" ? "pull-requests" : "history"; + let repositoryIncluded = false; + const surfaces = rawSurfaces.flatMap((rawSurface) => { + const surface = Option.getOrElse( + decodePersistedRightPanelSurface(rawSurface), + (): PersistedRightPanelSurface | null => null, + ); + if (surface === null) return []; + // Dropped surface kind: plans now render inline in the + // transcript (v9). + if (surface.kind === "plan") return []; + if (surface.kind === "git-history" || surface.kind === "pull-requests") { + if (repositoryIncluded) return []; + repositoryIncluded = true; + return [{ id: "repository", kind: "repository", view: repositoryView }]; + } + if (surface.kind === "file") { + const revealLine = + surface.revealLine === undefined || surface.revealLine === null + ? null + : Number.isFinite(surface.revealLine) + ? Math.max(1, Math.trunc(surface.revealLine)) + : null; + const revealRequestId = + surface.revealRequestId !== undefined && + Number.isSafeInteger(surface.revealRequestId) && + surface.revealRequestId >= 0 + ? surface.revealRequestId + : 0; + return "attachment" in surface + ? [ + { + id: surface.id, + kind: "file", + relativePath: surface.relativePath, + revealLine, + revealRequestId, + attachment: surface.attachment, + }, + ] + : [ { - ...surface, - terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId], - activeTerminalId, + id: surface.id, + kind: "file", + relativePath: surface.relativePath, + revealLine, + revealRequestId, }, ]; - }) - : []; - const rawActiveSurfaceId = validThreadState?.activeSurfaceId; - const persistedActiveSurfaceId = surfaces.some( - (surface) => surface.id === rawActiveSurfaceId, - ) - ? (rawActiveSurfaceId ?? null) - : rawActiveSurfaceId === "pull-request" - ? (surfaces.find((surface) => surface.kind === "pull-request")?.id ?? null) - : null; - // A migration that dropped every surface (e.g. plan-only panels - // in v9) must not reopen an empty panel. - const isOpen = - surfaces.length > 0 && - (typeof validThreadState?.isOpen === "boolean" - ? validThreadState.isOpen - : persistedActiveSurfaceId !== null); - // An open panel needs an active surface: if migration dropped - // the persisted one (e.g. plan was active), fall back to the - // first survivor instead of rendering an open empty panel. - const activeSurfaceId = - persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); + } + if (surface.kind === "pull-request") { + return [ + pullRequestSurface({ + projectId: surface.projectId, + repository: surface.repository, + number: surface.number, + ...(surface.host === undefined ? {} : { host: surface.host }), + ...(surface.url === undefined ? {} : { url: surface.url }), + ...(surface.environmentId === undefined + ? {} + : { environmentId: surface.environmentId }), + }), + ]; + } + if (surface.kind === "issue") { + return [ + issueSurface({ + projectId: surface.projectId, + repository: surface.repository, + number: surface.number, + ...(surface.provider === undefined ? {} : { provider: surface.provider }), + ...(surface.environmentId === undefined + ? {} + : { environmentId: surface.environmentId }), + }), + ]; + } + if (surface.kind === "issues") { + return [ + { + id: "issues", + kind: "issues", + selected: Option.getOrElse( + decodePersistedIssueSelection(surface.selected), + () => null, + ), + }, + ]; + } + if (surface.kind === "diff" || surface.kind === "files" || surface.kind === "agents") + return [surface]; + if (surface.kind === "repository") return [surface]; + if (surface.kind === "preview") return [surface]; + if (surface.kind === "device") return [surface]; + if (surface.id !== `terminal:${surface.resourceId}`) return []; + const terminalIds = surface.terminalIds + ? [...new Set(surface.terminalIds)] + : [surface.resourceId]; + const activeTerminalId = + surface.activeTerminalId !== undefined && + terminalIds.includes(surface.activeTerminalId) + ? surface.activeTerminalId + : (terminalIds[0] ?? surface.resourceId); return [ - threadKey, { - isOpen, - surfaces, - activeSurfaceId, - ...(Array.isArray(validThreadState?.dismissedDeviceSurfaceIds) - ? { - dismissedDeviceSurfaceIds: - validThreadState.dismissedDeviceSurfaceIds.filter( - (id): id is string => typeof id === "string", - ), - } - : {}), + id: `terminal:${surface.resourceId}`, + kind: "terminal", + resourceId: surface.resourceId, + terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId], + activeTerminalId, + ...(surface.splitDirection === undefined + ? {} + : { splitDirection: surface.splitDirection }), }, ]; - }), - ) - : {}; + }); + const persistedActiveSurfaceId = surfaces.some( + (surface) => surface.id === rawActiveSurfaceId, + ) + ? (rawActiveSurfaceId ?? null) + : rawActiveSurfaceId === "pull-request" + ? (surfaces.find((surface) => surface.kind === "pull-request")?.id ?? null) + : rawActiveSurfaceId === "git-history" || rawActiveSurfaceId === "pull-requests" + ? (surfaces.find((surface) => surface.kind === "repository")?.id ?? null) + : null; + // A migration that dropped every surface (e.g. plan-only panels + // in v9) must not reopen an empty panel. + const isOpen = + surfaces.length > 0 && + (threadState.isOpen !== undefined + ? threadState.isOpen + : persistedActiveSurfaceId !== null); + // An open panel needs an active surface: if migration dropped + // the persisted one (e.g. plan was active), fall back to the + // first survivor instead of rendering an open empty panel. + const activeSurfaceId = + persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); + return [ + threadKey, + { + isOpen, + surfaces, + activeSurfaceId, + ...(threadState.dismissedDeviceSurfaceIds + ? { + dismissedDeviceSurfaceIds: [...threadState.dismissedDeviceSurfaceIds], + } + : {}), + }, + ]; + }), + ) + : {}; return { byThreadKey }; } @@ -651,12 +759,24 @@ export const useRightPanelStore = create()( if ( surface.kind === "diff" && (selectActiveRightPanel(state.byThreadKey, ref) === "pull-request" || - selectActiveRightPanel(state.byThreadKey, ref) === "pull-requests") + selectActiveRightPanel(state.byThreadKey, ref) === "repository") ) { return state; } opened = true; - return automaticUpdate(state, threadKey, (current) => upsertSurface(current, surface)); + return automaticUpdate(state, threadKey, (current) => + surface.kind === "repository" + ? upsertSurface( + { + ...current, + surfaces: current.surfaces.map((entry) => + entry.id === surface.id ? surface : entry, + ), + }, + surface, + ) + : upsertSurface(current, surface), + ); }); return opened; }, @@ -741,6 +861,34 @@ export const useRightPanelStore = create()( upsertSurface(current, { id: "issues", kind: "issues", selected: null }), ), ), + openRepository: (ref, view) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { + const existing = current.surfaces.find( + (surface): surface is Extract => + surface.kind === "repository", + ); + const surface = { id: "repository" as const, kind: "repository" as const, view }; + return upsertSurface( + { + ...current, + surfaces: existing + ? current.surfaces.map((entry) => (entry.id === surface.id ? surface : entry)) + : current.surfaces, + }, + surface, + ); + }), + ), + selectRepositoryView: (ref, view) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ + ...current, + surfaces: current.surfaces.map((surface) => + surface.kind === "repository" ? { ...surface, view } : surface, + ), + })), + ), selectIssueInPanel: (ref, target) => set((state) => userAction(state, scopedThreadKey(ref), (current) => ({ diff --git a/apps/web/src/routes/_chat.issues.tsx b/apps/web/src/routes/_chat.issues.tsx index 1de1d4249e63..cbdf131cd870 100644 --- a/apps/web/src/routes/_chat.issues.tsx +++ b/apps/web/src/routes/_chat.issues.tsx @@ -1410,7 +1410,6 @@ function IssuesRouteView() { onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddIssue={() => undefined} - onAddPullRequests={() => undefined} onAddAgents={() => undefined} onAddDevice={() => undefined} browserAvailable={false} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 2ace5cbf2168..43d467cd6548 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2127,7 +2127,6 @@ function PullRequestsRouteView() { onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddIssue={() => undefined} - onAddPullRequests={() => undefined} onAddAgents={() => undefined} onAddDevice={() => undefined} browserAvailable={false} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index e1063c32e4bc..0ae85575abf4 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -245,7 +245,7 @@ even when its changes look independent. Stack actions require an environment tha ### Browse Repository History -Open the right panel menu and choose **History** to explore the repository without leaving your thread. The history view shows a connected commit graph, branches, remotes, and tags. Select a ref to focus on it, or choose **All refs** to inspect the wider repository. +Open the right panel menu and choose **Repository** to explore history and linked pull requests without leaving your thread. Select **History** to browse a connected commit graph, branches, remotes, and tags. Select a ref to focus on it, or choose **All refs** to inspect the wider repository. History is currently available in web and desktop project views. Mobile keeps its existing Git status and actions, but does not yet include this dense history workspace.