diff --git a/apps/desktop/plans/20260413-1600-v2-review-tab.md b/apps/desktop/plans/20260413-1600-v2-review-tab.md new file mode 100644 index 00000000000..a620fab261c --- /dev/null +++ b/apps/desktop/plans/20260413-1600-v2-review-tab.md @@ -0,0 +1,488 @@ +# V2 Workspace Sidebar: Review Tab (PR Info, Checks, Comments) + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +Reference: This plan follows conventions from AGENTS.md and this template. + + +## Purpose / Big Picture + +Today the v2 workspace sidebar has three tabs: "All files", "Changes", and "Checks". The Checks tab is a placeholder that reads "Coming soon." There is no way for a user in the v2 workspace to see their pull request status, CI check results, or PR review comments. In the v1 workspace, all of this lives in a fully-featured "Review" tab inside the right sidebar's ChangesView component. That v1 tab shows the PR title and state, the review decision badge, requested reviewers, a collapsible list of CI checks with pass/fail/pending icons and links, and a full list of PR comments with avatar, author, age, preview text, copy-to-clipboard, resolve/unresolve, and a "mark all done" batch action. + +After this plan is implemented, a v2 workspace user will be able to click the "Review" tab in the right sidebar and see all of that information. They will be able to resolve and unresolve comment threads, copy individual comments or all comments to the clipboard, and click through to GitHub for checks and comments. The existing "Checks — Coming soon" stub will be replaced by this richer "Review" tab. + + +## Assumptions + +1. The v1 `electronTrpc` endpoints (`workspaces.getGitHubStatus`, `workspaces.getGitHubPRComments`, `workspaces.resolveReviewThread`) are the correct data sources. The v2 host-service has `git.getPullRequest` and `git.getPullRequestThreads`, but these return different shapes and the resolve-thread mutation only exists on the electron router. Using the v1 endpoints avoids needing to add a new host-service mutation and means the data shapes match the proven v1 UI exactly. + +2. The review tab replaces the "Checks" stub entirely (the tab ID changes from `"checks"` to `"review"`). Checks are shown inside the review tab as a collapsible section, matching v1 behavior. + +3. The `PRIcon` component at `renderer/screens/main/components/PRIcon/PRIcon.tsx` and the ReviewPanel utility functions at `renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/utils.ts` can be imported directly from their current locations. No need to move them into the v2 directory tree since they are general-purpose and already used by v1. + +4. The v2 sidebar tab architecture (the `useChangesTab` hook pattern that returns a `SidebarTabDefinition`) is the correct pattern to follow. The review tab will be built as a `useReviewTab` hook. + + +## Open Questions + +1. **Should we use the v1 electron endpoints or the v2 host-service endpoints for PR data?** Using v1 endpoints is simpler and proven. Using v2 endpoints would be more architecturally consistent but requires adding a resolve-thread mutation to the host-service and adapting the data shapes. Pre-linked to Decision Log entry D1. + +2. **Should the badge on the Review tab show open comment count, total comment count, or checks status?** V1 shows open comment count on the "Review" sub-tab. Pre-linked to Decision Log entry D2. + + +## Progress + +- [ ] Plan drafted and awaiting approval. +- [ ] Milestone 1: useReviewTab hook + ReviewTabContent shell. +- [ ] Milestone 2: PR header section (title, state, decision, reviewers). +- [ ] Milestone 3: Checks section (collapsible, icons, links). +- [ ] Milestone 4: Comments section (list, copy, resolve, batch resolve). +- [ ] Milestone 5: Wire into WorkspaceSidebar, remove Checks stub. +- [ ] Milestone 6: Validation and cleanup. + + +## Surprises & Discoveries + +(None yet.) + + +## Decision Log + +- **D1 — Use v1 electron endpoints for PR data.** + Rationale: The v1 endpoints (`electronTrpc.workspaces.getGitHubStatus`, `.getGitHubPRComments`, `.resolveReviewThread`) are battle-tested, return the exact shapes the v1 ReviewPanel already consumes, and include the resolve-thread mutation. The v2 host-service endpoints (`workspaceTrpc.git.getPullRequest`, `.getPullRequestThreads`) return different shapes (e.g., `CheckRun` with `conclusion` field vs. `CheckItem` with `status` field, and threads as nested objects vs. flat `PullRequestComment[]`). Using v1 endpoints lets us reuse the v1 utility functions directly and avoids adding a new mutation to the host-service. A future migration to host-service endpoints can happen independently. + Date: 2026-04-13 / Plan author. + +- **D2 — Badge shows open (unresolved) comment count, matching v1.** + Rationale: This is what v1 does and it is the most actionable number for a reviewer. + Date: 2026-04-13 / Plan author. + + +## Outcomes & Retrospective + +(To be filled at completion.) + + +## Context and Orientation + +This section explains the relevant parts of the codebase for someone who has never seen it. + +### The v2 workspace sidebar + +The v2 workspace lives at `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/`. Its right sidebar is rendered by the `WorkspaceSidebar` component at `components/WorkspaceSidebar/WorkspaceSidebar.tsx`. The sidebar shows tabs defined by the `SidebarTabDefinition` interface (from `components/WorkspaceSidebar/types.ts`): + + export interface SidebarTabDefinition { + id: string; + label: string; + badge?: number; + actions?: ReactNode; + content: ReactNode; + } + +Currently three tabs are assembled in `WorkspaceSidebar.tsx`: + + const tabs = [filesTab, changesTab, checksTab]; + +The `changesTab` is built by a hook, `useChangesTab`, which lives at `components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx`. It returns a `SidebarTabDefinition` with the "Changes" label, a badge showing the number of changed files, and a `ChangesTabContent` component as its `content`. This hook-returns-tab-definition pattern is the model we will follow. + +The `checksTab` is an inline stub: + + const checksTab: SidebarTabDefinition = useMemo( + () => ({ + id: "checks", + label: "Checks", + content: ( +
+ Coming soon +
+ ), + }), + [], + ); + +This stub will be replaced by the new review tab. + +### The v1 ReviewPanel + +The v1 review UI lives at `renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx` (590 lines). It is a single component that receives these props: + + interface ReviewPanelProps { + pr: GitHubStatus["pr"] | null; + comments?: PullRequestComment[]; + isLoading?: boolean; + isCommentsLoading?: boolean; + workspaceId?: string; + onCommentsChange?: () => void; + } + +The data is fetched by its parent (`ChangesView.tsx`) using: + +- `electronTrpc.workspaces.getGitHubStatus` — returns `{ pr, repoUrl, ... }` where `pr` has `number`, `title`, `url`, `state`, `reviewDecision`, `checksStatus`, `checks[]`, `requestedReviewers[]`. +- `electronTrpc.workspaces.getGitHubPRComments` — returns `PullRequestComment[]` with `id`, `authorLogin`, `avatarUrl`, `body`, `createdAt`, `url`, `kind`, `path`, `line`, `isResolved`, `threadId`. +- `electronTrpc.workspaces.resolveReviewThread` — mutation taking `{ workspaceId, threadId, resolve }`. + +Refetch policies are controlled by `getGitHubStatusQueryPolicy` and `getGitHubPRCommentsQueryPolicy` from `renderer/lib/githubQueryPolicy`. GitHub status polls every 10 seconds when active; comments poll every 30 seconds. + +The v1 ReviewPanel utility functions live at `components/ReviewPanel/utils.ts` and export: `reviewDecisionConfig`, `checkIconConfig`, `checkSummaryIconConfig`, `prStateLabel`, `formatShortAge`, `getCommentPreviewText`, `getCommentAvatarFallback`, `buildCommentClipboardText`, `buildAllCommentsClipboardText`, `splitPullRequestComments`, `countOpenPullRequestComments`, `getCommentCopyActionKey`, `resolveCheckDestinationUrl`, `getCommentKindText`. + +The `PRIcon` component at `renderer/screens/main/components/PRIcon/PRIcon.tsx` renders different colored git icons based on PR state (open/merged/closed/draft). + +### tRPC clients in v2 + +The v2 workspace code uses two tRPC clients: + +- `workspaceTrpc` (from `@superset/workspace-client`) — a React Query tRPC client that talks to the host-service server. Used for `git.*`, `workspace.*` queries. This is the primary v2 data layer. +- `electronTrpcClient` (from `renderer/lib/trpc-client`) — an imperative (non-hook) proxy client that talks to the Electron main process via IPC. Used for things like `external.openFileInEditor`. +- `electronTrpc` (from `renderer/lib/electron-trpc`) — a React Query tRPC client that also talks to the Electron main process via IPC (same router as `electronTrpcClient`, but with `useQuery`/`useMutation` hooks). This is what v1 uses extensively. It is available in v2 renderer code and already imported in several places. + +For the review tab, we will use `electronTrpc` (the React hooks client) since the GitHub endpoints live on the electron router. + +### Clipboard in v2 + +The v2 code already has a `useCopyToClipboard` hook at `renderer/hooks/useCopyToClipboard.ts` that wraps `electronTrpc.external.copyPath.useMutation()`. It returns `{ copyToClipboard, copied }`. We will use this instead of the v1 pattern of calling `electronTrpc.external.copyText.useMutation()` directly. + +### Types + +`GitHubStatus` and `PullRequestComment` are defined in `packages/local-db/src/schema/zod.ts` and exported from `@superset/local-db`. They are Zod-inferred types. Key shapes: + +- `GitHubStatus.pr.checks[n]` has `{ name, status, url?, durationText? }` where `status` is `"success" | "failure" | "pending" | "skipped" | "cancelled"`. +- `PullRequestComment` has `{ id, authorLogin, avatarUrl?, body, createdAt?, url?, kind?, path?, line?, isResolved?, threadId? }`. + + +## Plan of Work + +The work is split into a data hook and four UI sub-components, following the v2 co-location pattern from AGENTS.md: each component gets its own folder with `ComponentName.tsx` + `index.ts`. + +### File structure to create + +All new files live under the v2 sidebar hooks directory, mirroring how `useChangesTab` is structured: + + components/WorkspaceSidebar/ + hooks/ + useChangesTab/ # existing + useReviewTab/ # NEW — hook + components + useReviewTab.tsx + index.ts + components/ + ReviewTabContent/ + ReviewTabContent.tsx + index.ts + PRHeader/ + PRHeader.tsx + index.ts + ChecksSection/ + ChecksSection.tsx + index.ts + CommentsSection/ + CommentsSection.tsx + index.ts + +### Milestone 1: useReviewTab hook + ReviewTabContent shell + +Create the `useReviewTab` hook that fetches GitHub status and comments, and returns a `SidebarTabDefinition`. + +**File: `hooks/useReviewTab/useReviewTab.tsx`** + +This hook accepts `{ workspaceId }` and does the following: + +1. Calls `electronTrpc.workspaces.getGitHubStatus.useQuery({ workspaceId })` with `getGitHubStatusQueryPolicy("changes-sidebar", { hasWorkspaceId: true, isActive: true })`. +2. Extracts `activePullRequest` from the result. +3. Calls `electronTrpc.workspaces.getGitHubPRComments.useQuery(...)` with `getGitHubPRCommentsQueryPolicy(...)`, only enabled when a PR exists. +4. Calls `countOpenPullRequestComments(comments)` to compute the badge count. +5. Returns a `SidebarTabDefinition` with `id: "review"`, `label: "Review"`, `badge` set to the open comment count (or undefined if zero), and `content` set to ``. + +**File: `hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx`** + +A `memo`'d wrapper component that handles the loading/empty/error states and renders three sections: `PRHeader`, `ChecksSection`, `CommentsSection`. Props: + + interface ReviewTabContentProps { + pr: GitHubStatus["pr"] | null; + comments: PullRequestComment[]; + isLoading: boolean; + isCommentsLoading: boolean; + workspaceId: string; + onRefresh: () => void; + } + +When `isLoading && !pr`, show "Loading review..." centered text. When `!pr`, show "Open a pull request to view review status, checks, and comments." centered text. Otherwise render the three sections. + +**File: `hooks/useReviewTab/index.ts`** + +Barrel export of `useReviewTab`. + +At the end of this milestone, the review tab renders with loading/empty states but no real content sections yet. Verify by running `bun dev`, opening a v2 workspace, and seeing the "Review" tab appear in the sidebar. If no PR exists, it should show the placeholder message. + +### Milestone 2: PR header section + +**File: `hooks/useReviewTab/components/PRHeader/PRHeader.tsx`** + +Renders the PR title row and review decision badge, matching v1's layout. Structure: + +1. A clickable row with `PRIcon` (from `renderer/screens/main/components/PRIcon`), the PR title (truncated), and an external-link icon on hover. +2. Below that, a row with the review decision badge (using `reviewDecisionConfig` from the v1 utils) and requested reviewers list. + +Props: + + interface PRHeaderProps { + pr: NonNullable; + } + +The PR title links to `pr.url` via ``. The review decision badge uses the same Tailwind classes as v1 (`reviewDecisionConfig[pr.reviewDecision]`). Requested reviewers are shown as "Awaiting reviewer1, reviewer2" in muted text, truncated. + +### Milestone 3: Checks section + +**File: `hooks/useReviewTab/components/ChecksSection/ChecksSection.tsx`** + +A collapsible section showing CI checks. Uses `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` from `@superset/ui/collapsible`. Props: + + interface ChecksSectionProps { + checks: NonNullable["checks"]; + checksStatus: NonNullable["checksStatus"]; + prUrl: string; + } + +Behavior: + +1. Filter out checks with status `"skipped"` or `"cancelled"` to get `relevantChecks`. +2. Compute `passingChecks` count and `checksSummary` string (e.g., "3/5 checks passing"). +3. Render a collapsible trigger with "Checks" label, count badge, and summary icon (from `checkSummaryIconConfig`). Pending checks get `animate-spin` on the icon. +4. In the collapsible content, render each check as a row with its status icon (from `checkIconConfig`), name, optional duration text, and an external link icon. If `resolveCheckDestinationUrl` returns a URL, wrap the row in an `` tag. Otherwise render a plain `
`. + +Starts open by default (`useState(true)`). + +### Milestone 4: Comments section + +**File: `hooks/useReviewTab/components/CommentsSection/CommentsSection.tsx`** + +The most complex section. Shows active and resolved comments with actions. Props: + + interface CommentsSectionProps { + comments: PullRequestComment[]; + isLoading: boolean; + workspaceId: string; + onCommentsChange: () => void; + } + +Internal state: + +- `commentsOpen: boolean` (default true) — controls the active comments collapsible. +- `resolvedOpen: boolean` (default false) — controls the resolved comments collapsible. +- `copiedActionKey: string | null` — tracks which comment was just copied (for showing the check icon briefly). +- `resolvingThreadIds: Set` — tracks which threads have an in-flight resolve mutation. +- `isResolvingAll: boolean` — tracks the batch resolve-all operation. + +Data flow: + +1. Split comments into `active` and `resolved` using `splitPullRequestComments`. +2. Get the clipboard hook via `useCopyToClipboard`. +3. Set up the resolve mutation via `electronTrpc.workspaces.resolveReviewThread.useMutation()`. + +Actions: + +- **Copy single comment**: Calls `copyToClipboard(buildCommentClipboardText(comment))` and sets `copiedActionKey`. +- **Copy all comments**: Calls `copyToClipboard(buildAllCommentsClipboardText(activeComments))`. +- **Resolve/unresolve thread**: Calls the resolve mutation with `{ workspaceId, threadId, resolve: !comment.isResolved }`. On success, calls `onCommentsChange()` to refetch. +- **Mark all done**: Iterates unique resolvable thread IDs, calls the resolve mutation for each via `Promise.allSettled`, then calls `onCommentsChange()`. + +Each comment row renders: avatar (using `Avatar` from `@superset/ui/avatar`), author login, kind badge ("Review" or "Comment" via `getCommentKindText`), age (via `formatShortAge`), and a one-line body preview (via `getCommentPreviewText`). On hover, action buttons appear: resolve/unresolve, copy, and open-on-GitHub link. + +The resolved comments section appears below the active section only when `resolvedComments.length > 0`, collapsed by default. + +### Milestone 5: Wire into WorkspaceSidebar + +**Edit: `components/WorkspaceSidebar/WorkspaceSidebar.tsx`** + +1. Add import: `import { useReviewTab } from "./hooks/useReviewTab";` +2. Call the hook: `const reviewTab = useReviewTab({ workspaceId });` +3. Replace the `checksTab` useMemo block entirely. +4. Change the tabs array: `const tabs = [filesTab, changesTab, reviewTab];` +5. Remove the `checksTab` variable and its import (it's inline, so just delete lines 89-100). + +### Milestone 6: Validation and cleanup + +Run these commands from the repo root and verify: + + bun run typecheck + # Expected: No errors + + bun run lint:fix + # Expected: No unfixable lint errors + + bun dev + # Expected: Desktop app opens. Create or open a v2 workspace that has + # a GitHub repo with a pull request. Click the "Review" tab in the + # right sidebar. Verify: + # - PR title, state icon, and link to GitHub are shown + # - Review decision badge (approved/changes requested/pending) appears + # - Requested reviewers are listed + # - CI checks section is collapsible, shows pass/fail/pending icons + # - Comments section shows active comments with avatars and previews + # - Hovering a comment reveals copy/resolve/link action buttons + # - Clicking resolve marks the thread as done (spinner, then updates) + # - "Mark all done" resolves all active threads + # - "Copy all" copies all comments to clipboard + # - Resolved comments appear in a separate collapsed section + # - If no PR exists, the tab shows the placeholder message + # - The tab badge shows the count of unresolved comments + +Also verify the existing v1 ChangesView ReviewPanel still works (it imports from its own utils.ts and should be unaffected). + + +## Concrete Steps + +All paths are relative to the repository root. + +### Step 1: Create the useReviewTab hook + +Create the file `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx` with the hook implementation described in Milestone 1. The hook uses `electronTrpc` for data fetching and returns a `SidebarTabDefinition`. + +Create the barrel `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/index.ts` exporting `useReviewTab`. + +### Step 2: Create ReviewTabContent + +Create `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx` and its `index.ts`. + +### Step 3: Create PRHeader + +Create `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/PRHeader.tsx` and its `index.ts`. Import `PRIcon` from `renderer/screens/main/components/PRIcon` and `reviewDecisionConfig` from the v1 ReviewPanel utils. + +### Step 4: Create ChecksSection + +Create `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/ChecksSection.tsx` and its `index.ts`. Import `checkIconConfig`, `checkSummaryIconConfig`, `resolveCheckDestinationUrl` from the v1 ReviewPanel utils. + +### Step 5: Create CommentsSection + +Create `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/CommentsSection.tsx` and its `index.ts`. Import comment utilities from the v1 ReviewPanel utils and `useCopyToClipboard` from `renderer/hooks/useCopyToClipboard`. + +### Step 6: Wire into WorkspaceSidebar + +Edit `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx`: + +- Import `useReviewTab`. +- Call `useReviewTab({ workspaceId })`. +- Replace `checksTab` with the result. +- Update the `tabs` array. + +### Step 7: Validate + + cd /Users/avipeltz/.superset/worktrees/superset/review-v2-screen + bun run typecheck + bun run lint:fix + bun dev + + +## Validation and Acceptance + +After implementation, run: + + bun run typecheck + # Expected: 0 errors + + bun run lint:fix + # Expected: Clean or only pre-existing warnings + + bun dev + # Open the desktop app. Navigate to a v2 workspace with a GitHub PR. + +Manual verification checklist: + +1. The sidebar shows three tabs: "All files", "Changes", "Review". +2. The "Review" tab badge shows the count of unresolved comments (or no badge if zero). +3. Clicking "Review" when no PR exists shows: "Open a pull request to view review status, checks, and comments." +4. With a PR, the header shows: PR icon (colored by state), PR title (clickable to GitHub), review decision badge, requested reviewers. +5. The Checks section is collapsible, defaults to open, shows each check with status icon and name. Clicking a check with a URL opens it in the browser. +6. The Comments section shows active comments with: avatar, author, kind badge, age, one-line preview. Hover reveals action buttons. +7. Clicking the resolve button on a comment shows a spinner and then the comment moves to the "Resolved" section. +8. "Mark all done" resolves all threads with a loading state. +9. "Copy all" copies all active comments to clipboard. +10. The "Resolved" section appears collapsed when resolved comments exist. Expanding it shows the resolved comments. + + +## Idempotence and Recovery + +All steps create new files or make additive edits to one existing file (`WorkspaceSidebar.tsx`). Running the steps multiple times is safe — creating a file that already exists will overwrite it with the same content. The edit to `WorkspaceSidebar.tsx` replaces the checksTab stub, which is idempotent (replacing the same lines again produces the same result). + +If something goes wrong mid-implementation, `git checkout -- apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/\$workspaceId/components/WorkspaceSidebar/` will revert all changes. The new `hooks/useReviewTab/` directory can be deleted to start fresh. + + +## Interfaces and Dependencies + +### Imports from v1 code (already exist, no changes needed) + +- `electronTrpc` from `renderer/lib/electron-trpc` — React Query tRPC client for Electron IPC. +- `getGitHubStatusQueryPolicy`, `getGitHubPRCommentsQueryPolicy` from `renderer/lib/githubQueryPolicy` — refetch policy helpers. +- `reviewDecisionConfig`, `checkIconConfig`, `checkSummaryIconConfig`, `formatShortAge`, `getCommentPreviewText`, `getCommentAvatarFallback`, `buildCommentClipboardText`, `buildAllCommentsClipboardText`, `splitPullRequestComments`, `countOpenPullRequestComments`, `getCommentCopyActionKey`, `resolveCheckDestinationUrl`, `getCommentKindText` from `renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/utils`. +- `PRIcon` from `renderer/screens/main/components/PRIcon`. +- `useCopyToClipboard` from `renderer/hooks/useCopyToClipboard`. + +### Imports from shared packages (already exist) + +- `GitHubStatus`, `PullRequestComment` from `@superset/local-db`. +- `Avatar`, `AvatarFallback`, `AvatarImage` from `@superset/ui/avatar`. +- `Collapsible`, `CollapsibleContent`, `CollapsibleTrigger` from `@superset/ui/collapsible`. +- `Skeleton` from `@superset/ui/skeleton`. +- `cn` from `@superset/ui/utils`. + +### Key function signatures + + // The hook + function useReviewTab({ workspaceId }: { workspaceId: string }): SidebarTabDefinition + + // Sub-components + function ReviewTabContent(props: { + pr: GitHubStatus["pr"] | null; + comments: PullRequestComment[]; + isLoading: boolean; + isCommentsLoading: boolean; + workspaceId: string; + onRefresh: () => void; + }): ReactNode + + function PRHeader(props: { + pr: NonNullable; + }): ReactNode + + function ChecksSection(props: { + checks: NonNullable["checks"]; + checksStatus: NonNullable["checksStatus"]; + prUrl: string; + }): ReactNode + + function CommentsSection(props: { + comments: PullRequestComment[]; + isLoading: boolean; + workspaceId: string; + onCommentsChange: () => void; + }): ReactNode + + +## Artifacts and Notes + +### V1 ReviewPanel data flow (for reference) + +In v1, `ChangesView.tsx` (the parent) fetches all data and passes it as props to `ReviewPanel`: + + const { data: githubStatus } = electronTrpc.workspaces.getGitHubStatus.useQuery( + { workspaceId }, + githubStatusQueryPolicy, + ); + const activePullRequest = githubStatus?.pr ?? null; + + const { data: githubComments = [] } = electronTrpc.workspaces.getGitHubPRComments.useQuery( + { workspaceId, prNumber: activePullRequest?.number, repoUrl: githubStatus?.repoUrl, ... }, + githubPRCommentsQueryPolicy, + ); + + + +In v2, the `useReviewTab` hook will own this data fetching internally (rather than having the sidebar parent do it), keeping the pattern consistent with how `useChangesTab` works. + +### Why not reuse the v1 ReviewPanel component directly + +The v1 `ReviewPanel.tsx` is 590 lines and mixes data concerns (resolve mutation state, clipboard mutation) with rendering. Splitting it into focused sub-components (PRHeader, ChecksSection, CommentsSection) improves readability and aligns with the v2 convention of smaller, single-responsibility components. The utility functions from `utils.ts` are reused directly without modification. diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 546734e1248..84167cdfd0f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -3,14 +3,17 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { Search } from "lucide-react"; import { useMemo, useState } from "react"; import { useGitStatus } from "renderer/hooks/host-service/useGitStatus"; +import type { CommentPaneData } from "../../types"; import { FilesTab } from "./components/FilesTab"; import { SidebarHeader } from "./components/SidebarHeader"; import { useChangesTab } from "./hooks/useChangesTab"; +import { useReviewTab } from "./hooks/useReviewTab"; import type { SidebarTabDefinition } from "./types"; interface WorkspaceSidebarProps { onSelectFile: (absolutePath: string, openInNewTab?: boolean) => void; onSelectDiffFile?: (path: string) => void; + onOpenComment?: (comment: CommentPaneData) => void; onSearch?: () => void; selectedFilePath?: string; workspaceId: string; @@ -46,6 +49,7 @@ function IconButton({ export function WorkspaceSidebar({ onSelectFile, onSelectDiffFile, + onOpenComment, onSearch, selectedFilePath, workspaceId, @@ -61,6 +65,8 @@ export function WorkspaceSidebar({ onSelectFile: onSelectDiffFile, }); + const reviewTab = useReviewTab({ workspaceId, onOpenComment }); + const filesTab: SidebarTabDefinition = useMemo( () => ({ id: "files", @@ -86,20 +92,7 @@ export function WorkspaceSidebar({ ], ); - const checksTab: SidebarTabDefinition = useMemo( - () => ({ - id: "checks", - label: "Checks", - content: ( -
- Coming soon -
- ), - }), - [], - ); - - const tabs = [filesTab, changesTab, checksTab]; + const tabs = [filesTab, changesTab, reviewTab]; const activeTabDef = tabs.find((t) => t.id === activeTab); return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx index 3b70448d54d..0ca94f1a1ac 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx @@ -30,7 +30,7 @@ export function SidebarHeader({ )} > {tab.label} - {tab.badge != null && tab.badge > 0 && ( + {tab.badge != null && ( {tab.badge} )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/ChecksSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/ChecksSection.tsx new file mode 100644 index 00000000000..365a7c4721a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/ChecksSection.tsx @@ -0,0 +1,175 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { useMemo, useState } from "react"; +import { + LuArrowUpRight, + LuCheck, + LuLoaderCircle, + LuMinus, + LuX, +} from "react-icons/lu"; +import { VscChevronRight } from "react-icons/vsc"; +import type { NormalizedCheck, NormalizedPR } from "../../types"; + +const checkIconConfig = { + success: { + icon: LuCheck, + className: "text-emerald-600 dark:text-emerald-400", + }, + failure: { icon: LuX, className: "text-red-600 dark:text-red-400" }, + pending: { + icon: LuLoaderCircle, + className: "text-amber-600 dark:text-amber-400", + }, + skipped: { icon: LuMinus, className: "text-muted-foreground" }, + cancelled: { icon: LuMinus, className: "text-muted-foreground" }, +} as const; + +const checkSummaryIconConfig = { + success: checkIconConfig.success, + failure: checkIconConfig.failure, + pending: checkIconConfig.pending, + none: { icon: LuMinus, className: "text-muted-foreground" }, +} as const; + +interface ChecksSectionProps { + checks: NormalizedCheck[]; + checksStatus: NormalizedPR["checksStatus"]; + prUrl: string; +} + +export function ChecksSection({ + checks, + checksStatus, + prUrl, +}: ChecksSectionProps) { + const [open, setOpen] = useState(true); + + const relevantChecks = useMemo( + () => + checks.filter( + (check) => check.status !== "skipped" && check.status !== "cancelled", + ), + [checks], + ); + + const passingChecks = relevantChecks.filter( + (check) => check.status === "success", + ).length; + const checksSummary = + relevantChecks.length > 0 + ? `${passingChecks}/${relevantChecks.length} checks passing` + : "No checks reported"; + const checksStatusConfig = checkSummaryIconConfig[checksStatus]; + const ChecksStatusIcon = checksStatusConfig.icon; + + return ( + + +
+ + Checks + + {relevantChecks.length} + +
+
+ + + {checksSummary} + +
+
+ + {relevantChecks.length === 0 ? ( +
+ No checks reported. +
+ ) : ( + relevantChecks.map((check, index) => ( + + )) + )} +
+
+ ); +} + +function resolveCheckUrl( + check: NormalizedCheck, + prUrl: string, +): string | undefined { + if (check.url) return check.url; + const name = check.name.trim().toLowerCase(); + if (name.includes("coderabbit") || name.includes("code rabbit")) return prUrl; + return undefined; +} + +function CheckRow({ check, prUrl }: { check: NormalizedCheck; prUrl: string }) { + const { icon: CheckIcon, className } = checkIconConfig[check.status]; + const checkUrl = resolveCheckUrl(check, prUrl); + + const inner = ( +
+ +
+ {check.name} + {checkUrl && ( + + )} +
+ {check.durationText && ( + + {check.durationText} + + )} +
+ ); + + return checkUrl ? ( +
+ {inner} + + ) : ( + inner + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/index.ts new file mode 100644 index 00000000000..093db2ed3bf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/index.ts @@ -0,0 +1 @@ +export { ChecksSection } from "./ChecksSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/CommentsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/CommentsSection.tsx new file mode 100644 index 00000000000..e907c55ab39 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/CommentsSection.tsx @@ -0,0 +1,354 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { Skeleton } from "@superset/ui/skeleton"; +import { cn } from "@superset/ui/utils"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuArrowUpRight, LuCheck, LuCopy } from "react-icons/lu"; +import { VscChevronRight } from "react-icons/vsc"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { CommentPaneData } from "../../../../../../types"; +import type { NormalizedComment } from "../../types"; + +interface CommentsSectionProps { + comments: NormalizedComment[]; + isLoading: boolean; + onOpenComment?: (comment: CommentPaneData) => void; +} + +export function CommentsSection({ + comments, + isLoading, + onOpenComment, +}: CommentsSectionProps) { + const [commentsOpen, setCommentsOpen] = useState(true); + const [resolvedOpen, setResolvedOpen] = useState(false); + const [copiedActionKey, setCopiedActionKey] = useState(null); + const copiedResetRef = useRef | null>(null); + const isMountedRef = useRef(true); + + const copyToClipboard = useCallback( + (text: string) => electronTrpcClient.external.copyText.mutate(text), + [], + ); + + useEffect(() => { + return () => { + isMountedRef.current = false; + if (copiedResetRef.current) clearTimeout(copiedResetRef.current); + }; + }, []); + + const activeComments = useMemo( + () => comments.filter((c) => !c.isResolved), + [comments], + ); + const resolvedComments = useMemo( + () => comments.filter((c) => c.isResolved), + [comments], + ); + + const markCopied = useCallback((key: string) => { + if (!isMountedRef.current) return; + if (copiedResetRef.current) clearTimeout(copiedResetRef.current); + setCopiedActionKey(key); + copiedResetRef.current = setTimeout(() => { + if (!isMountedRef.current) return; + setCopiedActionKey(null); + copiedResetRef.current = null; + }, 1500); + }, []); + + const handleCopySingle = useCallback( + (comment: NormalizedComment) => { + void copyToClipboard(comment.body.trim() || "No comment body") + .then(() => { + markCopied(`comment:${comment.id}`); + }) + .catch((err) => { + console.warn("Failed to copy comment", err); + }); + }, + [copyToClipboard, markCopied], + ); + + const handleCopyAll = useCallback(() => { + const text = activeComments + .map((c) => { + const location = c.path + ? c.line + ? `${c.path}:${c.line}` + : c.path + : c.kind === "conversation" + ? "Conversation" + : null; + const meta = [ + c.authorLogin, + c.kind === "review" ? "Review" : "Comment", + location, + ] + .filter(Boolean) + .join(" \u2022 "); + return [meta, c.body.trim() || "No comment body"] + .filter(Boolean) + .join("\n"); + }) + .join("\n\n---\n\n"); + void copyToClipboard(text) + .then(() => { + markCopied("comments:all"); + }) + .catch((err) => { + console.warn("Failed to copy comments", err); + }); + }, [copyToClipboard, activeComments, markCopied]); + + const commentsCountLabel = isLoading ? "..." : comments.length; + const copyAllLabel = + copiedActionKey === "comments:all" ? "Copied" : "Copy all"; + + return ( + <> + +
+ + + Comments + + {commentsCountLabel} + + + {activeComments.length > 0 && ( +
+ +
+ )} +
+ + {isLoading ? ( +
+ + + +
+ ) : comments.length === 0 ? ( +
+ No comments yet. +
+ ) : ( + activeComments.map((comment) => ( + + )) + )} +
+
+ + {resolvedComments.length > 0 && ( + + + + Resolved + + {resolvedComments.length} + + + + {resolvedComments.map((comment) => ( + + ))} + + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatShortAge(isoDate?: string): string | null { + if (!isoDate) return null; + const ms = Date.now() - new Date(isoDate).getTime(); + if (Number.isNaN(ms) || ms < 0) return null; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${Math.max(1, seconds)}s`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.round(hours / 24)}d`; +} + +function getPreviewText(body: string): string { + return ( + body + .replace(//g, "\n") + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) + ?.replace(/^[-*+>]\s*/, "") + ?.replace(/\s+/g, " ") ?? "No preview available" + ); +} + +// --------------------------------------------------------------------------- +// CommentRow +// --------------------------------------------------------------------------- + +interface CommentRowProps { + comment: NormalizedComment; + copiedActionKey: string | null; + onCopy: (comment: NormalizedComment) => void; + onOpen?: (comment: CommentPaneData) => void; +} + +function CommentRow({ + comment, + copiedActionKey, + onCopy, + onOpen, +}: CommentRowProps) { + const age = formatShortAge(comment.createdAt); + const isCopied = copiedActionKey === `comment:${comment.id}`; + + const handleClick = () => { + onOpen?.({ + commentId: comment.id, + authorLogin: comment.authorLogin, + avatarUrl: comment.avatarUrl, + body: comment.body, + url: comment.url, + path: comment.path, + line: comment.line, + }); + }; + + const content = ( + <> + + {comment.avatarUrl ? ( + + ) : null} + + {comment.authorLogin.slice(0, 2).toUpperCase()} + + +
+
+ + {comment.authorLogin} + + + {comment.kind === "review" ? "Review" : "Comment"} + + + {age ? ( + + {age} + + ) : null} +
+

+ {getPreviewText(comment.body)} +

+
+ + ); + + return ( +
+ +
+ + {comment.url ? ( + + + + ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/index.ts new file mode 100644 index 00000000000..2a154c68e68 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/index.ts @@ -0,0 +1 @@ +export { CommentsSection } from "./CommentsSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/PRHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/PRHeader.tsx new file mode 100644 index 00000000000..1a48e0f154c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/PRHeader.tsx @@ -0,0 +1,61 @@ +import { cn } from "@superset/ui/utils"; +import { LuArrowUpRight } from "react-icons/lu"; +import { PRIcon } from "renderer/screens/main/components/PRIcon"; +import type { NormalizedPR } from "../../types"; + +const reviewDecisionConfig = { + approved: { + label: "Approved", + className: + "border border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + }, + changes_requested: { + label: "Changes requested", + className: + "border border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300", + }, + pending: { + label: "Review pending", + className: + "border border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300", + }, +} as const; + +interface PRHeaderProps { + pr: NormalizedPR; +} + +export function PRHeader({ pr }: PRHeaderProps) { + return ( +
+ + + + {pr.title} + + +
+ + {reviewDecisionConfig[pr.reviewDecision].label} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/index.ts new file mode 100644 index 00000000000..d374e0690ed --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/index.ts @@ -0,0 +1 @@ +export { PRHeader } from "./PRHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx new file mode 100644 index 00000000000..55aa0e989c8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx @@ -0,0 +1,70 @@ +import { memo } from "react"; +import type { CommentPaneData } from "../../../../../../types"; +import type { NormalizedComment, NormalizedPR } from "../../types"; +import { ChecksSection } from "../ChecksSection"; +import { CommentsSection } from "../CommentsSection"; +import { PRHeader } from "../PRHeader"; + +interface ReviewTabContentProps { + pr: NormalizedPR | null; + comments: NormalizedComment[]; + isLoading: boolean; + isError: boolean; + isCommentsLoading: boolean; + onOpenComment?: (comment: CommentPaneData) => void; +} + +export const ReviewTabContent = memo(function ReviewTabContent({ + pr, + comments, + isLoading, + isError, + isCommentsLoading, + onOpenComment, +}: ReviewTabContentProps) { + if (isError) { + return ( +
+ Unable to load review status +
+ ); + } + + if (isLoading && !pr) { + return ( +
+ Loading review... +
+ ); + } + + if (!pr) { + return ( +
+ Open a pull request to view review status, checks, and comments. +
+ ); + } + + return ( +
+ + +
+ + + +
+ + +
+ ); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/index.ts new file mode 100644 index 00000000000..398e2cda815 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/index.ts @@ -0,0 +1 @@ +export { ReviewTabContent } from "./ReviewTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/index.ts new file mode 100644 index 00000000000..019d568ed51 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/index.ts @@ -0,0 +1 @@ +export { useReviewTab } from "./useReviewTab"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/types.ts new file mode 100644 index 00000000000..890df920c3d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/types.ts @@ -0,0 +1,32 @@ +/** Normalized PR shape used by review tab UI components. */ +export interface NormalizedPR { + number: number; + url: string; + title: string; + state: "open" | "closed" | "merged" | "draft"; + reviewDecision: "approved" | "changes_requested" | "pending"; + checksStatus: "success" | "failure" | "pending" | "none"; + checks: NormalizedCheck[]; +} + +export interface NormalizedCheck { + name: string; + status: "success" | "failure" | "pending" | "skipped" | "cancelled"; + url?: string; + durationText?: string; +} + +/** Normalized comment shape, flattened from review threads + conversation comments. */ +export interface NormalizedComment { + id: string; + authorLogin: string; + avatarUrl?: string; + body: string; + createdAt?: string; + url?: string; + kind: "review" | "conversation"; + path?: string; + line?: number; + isResolved: boolean; + threadId?: string; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx new file mode 100644 index 00000000000..1fe31030f21 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx @@ -0,0 +1,228 @@ +import type { AppRouter } from "@superset/host-service"; +import { workspaceTrpc } from "@superset/workspace-client"; +import type { inferRouterOutputs } from "@trpc/server"; +import { useMemo } from "react"; +import type { CommentPaneData } from "../../../../types"; +import type { SidebarTabDefinition } from "../../types"; +import { ReviewTabContent } from "./components/ReviewTabContent"; +import type { NormalizedComment, NormalizedPR } from "./types"; + +type RouterOutputs = inferRouterOutputs; +type V2PullRequest = NonNullable; +type V2CheckRun = V2PullRequest["checks"][number]; +type V2ThreadsData = RouterOutputs["git"]["getPullRequestThreads"]; + +interface UseReviewTabParams { + workspaceId: string; + onOpenComment?: (comment: CommentPaneData) => void; +} + +export function useReviewTab({ + workspaceId, + onOpenComment, +}: UseReviewTabParams): SidebarTabDefinition { + const prQuery = workspaceTrpc.git.getPullRequest.useQuery( + { workspaceId }, + { + enabled: !!workspaceId, + refetchInterval: 10_000, + refetchOnWindowFocus: true, + staleTime: 10_000, + }, + ); + + const hasPR = prQuery.isSuccess && prQuery.data != null; + const threadsQuery = workspaceTrpc.git.getPullRequestThreads.useQuery( + { workspaceId }, + { + enabled: !!workspaceId && hasPR, + refetchInterval: 30_000, + refetchOnWindowFocus: true, + }, + ); + + const pr = useMemo(() => { + const raw = prQuery.data; + if (!raw) return null; + return { + number: raw.number, + url: raw.url, + title: raw.title, + state: raw.isDraft ? "draft" : raw.state, + reviewDecision: normalizeReviewDecision(raw.reviewDecision), + checksStatus: computeChecksStatus(raw.checks), + checks: raw.checks.map((c) => ({ + name: c.name, + // The DB stores the already-resolved effective status (success/failure/ + // pending/skipped/cancelled) in the `status` field, even though the + // tRPC type calls it CheckStatusState. Fall back to coercing it. + status: coerceCheckStatus(c.status, c.conclusion), + url: c.detailsUrl ?? undefined, + durationText: computeDurationText(c.startedAt, c.completedAt), + })), + }; + }, [prQuery.data]); + + // FORK NOTE: gate comments on `pr` existence. React Query keeps the last + // successful threadsQuery.data in cache even after `getPullRequest` + // returns null (e.g. PR closed/unlinked), so without this guard the + // Review tab would keep showing stale comments and badge count after + // the PR disappears. + const comments = useMemo(() => { + if (!pr) return []; + const data = threadsQuery.data; + if (!data) return []; + return normalizeThreadsToComments(data); + }, [pr, threadsQuery.data]); + + const openCommentCount = comments.filter((c) => !c.isResolved).length; + + const content = ( + + ); + + return { + id: "review", + label: "Review", + // FORK NOTE: suppress `0` badge when there are no open comments — + // Changes tab does the same. + badge: openCommentCount > 0 ? openCommentCount : undefined, + content, + }; +} + +// --------------------------------------------------------------------------- +// Normalization helpers +// --------------------------------------------------------------------------- + +function normalizeReviewDecision( + decision: string | null, +): "approved" | "changes_requested" | "pending" { + if (decision === "approved") return "approved"; + if (decision === "changes_requested") return "changes_requested"; + return "pending"; +} + +type EffectiveCheckStatus = + | "success" + | "failure" + | "pending" + | "skipped" + | "cancelled"; + +const KNOWN_CHECK_STATUSES = new Set([ + "success", + "failure", + "pending", + "skipped", + "cancelled", +]); + +/** + * The DB stores the already-resolved effective status in `checksJson[].status` + * (e.g. "success", "failure"). But the tRPC router re-parses it into a + * CheckRun whose `status` field is typed as CheckStatusState ("completed" etc.) + * and whose `conclusion` is always null. So we first check whether the status + * value is already one of the effective statuses; if not, fall back to the + * status+conclusion logic for raw GitHub data. + */ +function coerceCheckStatus( + status: string, + conclusion: string | null, +): EffectiveCheckStatus { + if (KNOWN_CHECK_STATUSES.has(status)) return status as EffectiveCheckStatus; + // Raw GitHub data path: status is "completed"/"in_progress"/etc. + if (status !== "completed") return "pending"; + if (!conclusion) return "pending"; + if (conclusion === "success" || conclusion === "neutral") return "success"; + if (conclusion === "skipped") return "skipped"; + if (conclusion === "cancelled") return "cancelled"; + return "failure"; +} + +function computeChecksStatus( + checks: V2CheckRun[], +): "success" | "failure" | "pending" | "none" { + let hasFailure = false; + let hasPending = false; + let relevantCount = 0; + for (const c of checks) { + const s = coerceCheckStatus(c.status, c.conclusion); + if (s === "skipped" || s === "cancelled") continue; + relevantCount++; + if (s === "failure") hasFailure = true; + else if (s === "pending") hasPending = true; + } + if (relevantCount === 0) return "none"; + if (hasFailure) return "failure"; + if (hasPending) return "pending"; + return "success"; +} + +function computeDurationText( + startedAt: string | null, + completedAt: string | null, +): string | undefined { + if (!startedAt || !completedAt) return undefined; + const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime(); + if (Number.isNaN(ms) || ms < 0) return undefined; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.round(seconds / 60); + return `${minutes}m`; +} + +function normalizeThreadsToComments(data: V2ThreadsData): NormalizedComment[] { + const comments: NormalizedComment[] = []; + + // FORK NOTE: upstream #3463 only picked thread.comments[0] which dropped + // every reply in a review thread. v1 flattened all comments in a thread. + // Keep that behavior so the comment count badge and listing match the + // real PR state. + for (const thread of data.reviewThreads) { + for (const c of thread.comments) { + comments.push({ + id: c.id, + authorLogin: c.author.login, + avatarUrl: c.author.avatarUrl || undefined, + body: c.body, + createdAt: c.createdAt, + url: undefined, + kind: "review", + path: thread.path || undefined, + line: thread.line ?? undefined, + isResolved: thread.isResolved, + threadId: thread.id, + }); + } + } + + for (const c of data.conversationComments) { + comments.push({ + id: String(c.id), + authorLogin: c.user.login, + avatarUrl: c.user.avatarUrl || undefined, + body: c.body, + createdAt: c.createdAt, + url: c.htmlUrl || undefined, + kind: "conversation", + isResolved: false, + threadId: undefined, + }); + } + + comments.sort((a, b) => { + const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return ta - tb; + }); + + return comments; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/CommentPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/CommentPane.tsx new file mode 100644 index 00000000000..2ce60b51616 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/CommentPane.tsx @@ -0,0 +1,317 @@ +import { mermaid } from "@streamdown/mermaid"; +import type { RendererContext } from "@superset/panes"; +import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { LuCheck, LuCopy } from "react-icons/lu"; +import ReactMarkdown from "react-markdown"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { + oneDark, + oneLight, +} from "react-syntax-highlighter/dist/esm/styles/prism"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; +import { SafeImage } from "renderer/components/MarkdownRenderer/components/SafeImage"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useTheme } from "renderer/stores"; +import { Streamdown } from "streamdown"; +import type { CommentPaneData, PaneViewerData } from "../../../../types"; +import "./comment-pane.css"; + +interface CommentPaneProps { + context: RendererContext; +} + +export function CommentPane({ context }: CommentPaneProps) { + const data = context.pane.data as CommentPaneData; + const [copied, setCopied] = useState(false); + const copyTimerRef = useRef | null>(null); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }; + }, []); + + const handleCopyAll = useCallback(() => { + void electronTrpcClient.external.copyText + .mutate(data.body) + .then(() => { + if (!isMountedRef.current) return; + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + setCopied(true); + copyTimerRef.current = setTimeout(() => { + if (!isMountedRef.current) return; + setCopied(false); + copyTimerRef.current = null; + }, 1500); + }) + .catch((err) => { + console.warn("Failed to copy comment text", err); + }); + }, [data.body]); + + return ( +
+
+ + {data.avatarUrl ? ( + + ) : null} + + {data.authorLogin.slice(0, 2).toUpperCase()} + + + + {data.authorLogin} + + {data.path && ( + + {data.path} + {data.line != null ? `:${data.line}` : ""} + + )} + +
+
+
+ + {data.body} + +
+
+
+ ); +} + +const mermaidPlugins = { mermaid }; + +const MERMAID_DARK_VARS = { + background: "#1e1e2e", + primaryColor: "#313244", + primaryTextColor: "#cdd6f4", + primaryBorderColor: "#45475a", + secondaryColor: "#313244", + secondaryTextColor: "#cdd6f4", + secondaryBorderColor: "#45475a", + tertiaryColor: "#313244", + tertiaryTextColor: "#cdd6f4", + tertiaryBorderColor: "#45475a", + nodeBorder: "#45475a", + nodeTextColor: "#cdd6f4", + mainBkg: "#313244", + clusterBkg: "#1e1e2e", + titleColor: "#cdd6f4", + edgeLabelBackground: "transparent", + lineColor: "#6c7086", + textColor: "#cdd6f4", +}; + +const MERMAID_LIGHT_VARS = { + background: "#ffffff", + primaryColor: "#f0f0f4", + primaryTextColor: "#1e1e2e", + primaryBorderColor: "#d0d0d8", + lineColor: "#888", + textColor: "#1e1e2e", +}; + +function CommentCodeBlock({ + className, + children, +}: { + className?: string; + children?: ReactNode; +}) { + const theme = useTheme(); + const isDark = theme?.type !== "light"; + + const match = /language-(\w+)/.exec(className || ""); + const language = match ? match[1] : undefined; + const codeString = String(children).replace(/\n$/, ""); + + if (language === "mermaid") { + return ( + + {`\`\`\`mermaid\n${codeString}\n\`\`\``} + + ); + } + + if (!language) { + return ( + + {children} + + ); + } + + return ( + + } + language={language} + PreTag="div" + className="rounded-md text-sm" + > + {codeString} + + ); +} + +// FORK NOTE: override and so comment body markdown from GitHub +// can't navigate the main window away or load arbitrary remote images. +// Matches the shared MarkdownRenderer guards. +function CommentLink({ + href, + children, +}: { + href?: string; + children?: ReactNode; +}) { + const onClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (!href) return; + electronTrpcClient.external.openUrl.mutate(href).catch((err) => { + console.warn("Failed to open external URL", err); + }); + }, + [href], + ); + return ( + + {children} + + ); +} + +function CommentImage({ + src, + alt, + className, +}: { + src?: string; + alt?: string; + className?: string; +}) { + return ; +} + +const commentComponents = { + code: CommentCodeBlock, + table: ({ children }: { children?: ReactNode }) => ( + {children} + ), + a: CommentLink, + img: CommentImage, +}; + +function CopyableTable({ children }: { children?: ReactNode }) { + const tableRef = useRef(null); + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const handleCopy = useCallback(() => { + const el = tableRef.current; + if (!el) return; + + const rows = el.querySelectorAll("tr"); + const lines: string[] = []; + for (const row of rows) { + const cells = row.querySelectorAll("th, td"); + const values: string[] = []; + for (const cell of cells) { + values.push((cell.textContent ?? "").trim()); + } + lines.push(values.join("\t")); + } + const text = lines.join("\n"); + void electronTrpcClient.external.copyText + .mutate(text) + .then(() => { + if (!isMountedRef.current) return; + if (timerRef.current) clearTimeout(timerRef.current); + setCopied(true); + timerRef.current = setTimeout(() => { + if (!isMountedRef.current) return; + setCopied(false); + timerRef.current = null; + }, 1500); + }) + .catch((err) => { + console.warn("Failed to copy table text", err); + }); + }, []); + + return ( +
+ +
+ + {children} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/comment-pane.css b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/comment-pane.css new file mode 100644 index 00000000000..c0333e827ce --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/comment-pane.css @@ -0,0 +1,263 @@ +.comment-pane-markdown { + color: var(--foreground); + background: var(--background); + font-size: 0.875rem; + line-height: 1.625; + -webkit-font-smoothing: antialiased; +} + +.comment-pane-markdown article { + width: 100%; +} + +/* Headings */ +.comment-pane-markdown h1 { + font-size: 1.75rem; + font-weight: 700; + line-height: 1.25; + margin-top: 0; + margin-bottom: 0.75rem; + letter-spacing: -0.02em; +} + +.comment-pane-markdown h2 { + font-size: 1.35rem; + font-weight: 600; + line-height: 1.3; + margin-top: 1.5rem; + margin-bottom: 0.625rem; +} + +.comment-pane-markdown h3 { + font-size: 1.1rem; + font-weight: 600; + line-height: 1.4; + margin-top: 1.25rem; + margin-bottom: 0.5rem; +} + +.comment-pane-markdown h4, +.comment-pane-markdown h5, +.comment-pane-markdown h6 { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.5; + margin-top: 1rem; + margin-bottom: 0.375rem; +} + +/* First child no top margin */ +.comment-pane-markdown article > *:first-child { + margin-top: 0; +} + +/* Paragraphs */ +.comment-pane-markdown p { + margin-top: 0; + margin-bottom: 0.75rem; +} + +/* Links */ +.comment-pane-markdown a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.comment-pane-markdown a:hover { + opacity: 0.8; +} + +/* Strong & emphasis */ +.comment-pane-markdown strong { + font-weight: 600; +} + +.comment-pane-markdown em { + font-style: italic; +} + +/* Lists */ +.comment-pane-markdown ul, +.comment-pane-markdown ol { + margin-top: 0; + margin-bottom: 0.75rem; + padding-left: 1.5rem; +} + +.comment-pane-markdown ul { + list-style-type: disc; +} + +.comment-pane-markdown ol { + list-style-type: decimal; +} + +.comment-pane-markdown li { + margin-bottom: 0.25rem; +} + +.comment-pane-markdown li > ul, +.comment-pane-markdown li > ol { + margin-top: 0.25rem; + margin-bottom: 0; +} + +/* Tables — full width with borders */ +.comment-pane-markdown table { + width: 100%; + border-collapse: collapse; + margin: 0.75rem 0; + font-size: 0.8125rem; +} + +.comment-pane-markdown th, +.comment-pane-markdown td { + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; + vertical-align: middle; +} + +.comment-pane-markdown th { + font-weight: 500; + text-align: left; + background: color-mix(in srgb, var(--muted) 50%, transparent); +} + +.comment-pane-markdown td img { + display: inline-block; + vertical-align: middle; +} + +/* Blockquotes */ +.comment-pane-markdown blockquote { + border-left: 3px solid var(--border); + padding-left: 1rem; + margin: 0.75rem 0; + color: var(--muted-foreground); +} + +.comment-pane-markdown blockquote p:last-child { + margin-bottom: 0; +} + +/* Horizontal rules */ +.comment-pane-markdown hr { + border: none; + border-top: 1px solid var(--border); + margin: 1.5rem 0; +} + +/* Code — inline */ +.comment-pane-markdown code { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8125rem; + background: color-mix(in srgb, var(--muted) 60%, transparent); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; +} + +/* Code — blocks */ +.comment-pane-markdown pre { + margin: 0.75rem 0; + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--muted) 40%, transparent); + border-radius: 0.375rem; + overflow-x: auto; +} + +.comment-pane-markdown pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: 0.8125rem; + line-height: 1.5; +} + +/* Images */ +.comment-pane-markdown img { + max-width: 100%; + height: auto; + border-radius: 0.375rem; +} + +/* Details/summary (common in GitHub bot comments) */ +.comment-pane-markdown details { + margin: 0.75rem 0; + border: 1px solid var(--border); + border-radius: 0.375rem; + overflow: hidden; +} + +.comment-pane-markdown details > summary { + cursor: pointer; + padding: 0.5rem 0.75rem; + font-weight: 500; + background: color-mix(in srgb, var(--muted) 30%, transparent); + user-select: none; +} + +.comment-pane-markdown details > summary:hover { + background: color-mix(in srgb, var(--muted) 50%, transparent); +} + +.comment-pane-markdown details[open] > summary { + border-bottom: 1px solid var(--border); +} + +.comment-pane-markdown details > *:not(summary) { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.comment-pane-markdown details > p:first-of-type { + margin-top: 0.5rem; +} + +/* Task lists (checkboxes) */ +.comment-pane-markdown .task-list-item { + list-style: none; + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.comment-pane-markdown .task-list-item input[type="checkbox"] { + margin-top: 0.25rem; +} + +/* Sub text */ +.comment-pane-markdown sub { + font-size: 0.75rem; + color: var(--muted-foreground); +} + +/* Strikethrough */ +.comment-pane-markdown del { + text-decoration: line-through; + opacity: 0.6; +} + +/* Mermaid diagrams */ +.comment-pane-markdown [data-streamdown="mermaid-block"] { + margin: 0.75rem 0; + border-radius: 0.375rem; + background: transparent; + border: none; + padding: 0; +} + +/* Hide "mermaid" label + action buttons */ +.comment-pane-markdown [data-streamdown="mermaid-block"] > .flex.h-8 { + display: none; +} + +.comment-pane-markdown [data-streamdown="mermaid-block-actions"] { + display: none; +} + +/* Remove the inner wrapper background */ +.comment-pane-markdown [data-streamdown="mermaid-block"] > div:last-child { + background: transparent; + border: none; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/index.ts new file mode 100644 index 00000000000..ed0e956694b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/index.ts @@ -0,0 +1 @@ +export { CommentPane } from "./CommentPane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 49b9b266c65..aa85c5c283f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -15,8 +15,10 @@ import { TerminalSquare, } from "lucide-react"; import { useMemo } from "react"; +import { FaGithub } from "react-icons/fa"; import { LuArrowDownToLine, + LuArrowUpRight, LuClipboard, LuClipboardCopy, LuEraser, @@ -29,6 +31,7 @@ import { useSettings } from "renderer/stores/settings"; import type { BrowserPaneData, ChatPaneData, + CommentPaneData, DevtoolsPaneData, FilePaneData, PaneViewerData, @@ -40,6 +43,7 @@ import { browserRuntimeRegistry, } from "./components/BrowserPane"; import { ChatPane } from "./components/ChatPane"; +import { CommentPane } from "./components/CommentPane"; import { DiffPane } from "./components/DiffPane"; import { FilePane } from "./components/FilePane"; import { TerminalPane } from "./components/TerminalPane"; @@ -301,6 +305,44 @@ export function usePaneRegistry( d.key === "close-pane" ? { ...d, label: "Close Chat" } : d, ), }, + comment: { + getIcon: (ctx: RendererContext) => { + const data = ctx.pane.data as CommentPaneData; + if (!data.avatarUrl) { + return ; + } + return ( + + ); + }, + getTitle: (pane) => { + const data = pane.data as CommentPaneData; + return data.authorLogin; + }, + renderPane: (ctx: RendererContext) => ( + + ), + renderHeaderExtras: (ctx: RendererContext) => { + const data = ctx.pane.data as CommentPaneData; + if (!data.url) return null; + return ( + + + + + ); + }, + contextMenuActions: (_ctx, defaults) => + defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close Comment" } : d, + ), + }, devtools: { getTitle: () => "DevTools", renderPane: (ctx: RendererContext) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 3a3acd2514f..a3fbe94da08 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -45,6 +45,7 @@ import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; import type { BrowserPaneData, ChatPaneData, + CommentPaneData, DiffPaneData, FilePaneData, PaneViewerData, @@ -363,6 +364,36 @@ function WorkspaceContent({ }); }, [openFilePane, workspaceId]); + // FORK NOTE: upstream #3463 introduces openCommentPane for the new + // v2 Review tab. Keep it alongside fork's useCommandPalette-based + // quick open (fork uses a hook, upstream uses a simple boolean state). + const openCommentPane = useCallback( + (comment: CommentPaneData) => { + const state = store.getState(); + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "comment") continue; + state.setPaneData({ + paneId: pane.id, + data: comment as PaneViewerData, + }); + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); + return; + } + } + state.addTab({ + panes: [ + { + kind: "comment", + data: comment as PaneViewerData, + }, + ], + }); + }, + [store], + ); + const commandPalette = useCommandPalette({ workspaceId, navigate, @@ -537,6 +568,7 @@ function WorkspaceContent({ workspaceName={workspaceName} onSelectFile={openSidebarFilePane} onSelectDiffFile={openDiffPane} + onOpenComment={openCommentPane} onSearch={handleQuickOpen} selectedFilePath={selectedFilePath} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts index 61db8ea3edf..d16e1b4c5d3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts @@ -34,10 +34,21 @@ export interface DiffPaneData { collapsedFiles: string[]; } +export interface CommentPaneData { + commentId: string; + authorLogin: string; + avatarUrl?: string; + body: string; + url?: string; + path?: string; + line?: number; +} + export type PaneViewerData = | FilePaneData | TerminalPaneData | ChatPaneData | BrowserPaneData | DevtoolsPaneData - | DiffPaneData; + | DiffPaneData + | CommentPaneData;