Display GitHub PR comments inline in diff view (Vibe Kanban) (#1991)

* The GitHub comments integration is now complete. Here's a summary of what was implemented:

## Summary

The GitHub PR comments integration for the new UI (ui-new) is now complete. All tasks have been implemented:

### Files Modified:
1. **`useUiPreferencesStore.ts`** - Added persistence key for `showGitHubComments` toggle
2. **`WorkspaceContext.tsx`** - Extended with PR comments data, helpers (`getGitHubCommentsForFile`, `gitHubFileCommentCounts`), and toggle state
3. **`CommentCard.tsx`** (new) - Shared primitive for displaying comments with three variants: `user`, `github`, `input`
4. **`GitHubCommentRenderer.tsx`** (new) - Read-only renderer for GitHub comments with GitHub logo, author, timestamp, and external link
5. **`ReviewCommentRenderer.tsx`** - Refactored to use `CommentCard` primitive
6. **`CommentWidgetLine.tsx`** - Refactored to use `CommentCard` primitive
7. **`DiffViewCardWithComments.tsx`** - Integrated GitHub comments into diff view, showing both user and GitHub comments with separate counts in the header
8. **`FileTreeNode.tsx`** - Added GitHub comment badge showing count next to files
9. **`FileTree.tsx`** - Added toggle button in header to show/hide GitHub comments
10. **`FileTreeContainer.tsx`** - Wired up the context to pass toggle state and comment counts to FileTree
11. **`common.json`** (i18n) - Added translation keys for show/hide GitHub comments

### Features:
- GitHub PR comments displayed inline next to relevant code in diff views
- Toggle button in file tree to show/hide GitHub comments (persisted)
- Comment count badges on files in the file tree
- Visual differentiation: user comments have orange styling (`bg-brand/20 border-brand`), GitHub comments have neutral styling (`bg-secondary border-border`)
- User comments take priority when both exist on the same line

* Cleanup script changes for workspace 881e2956-9cca-406d-835f-7ea8acbe550e

* All checks pass. Let me show a summary of the changes made:

## Summary of Fixes

All code review feedback has been addressed:

| # | Fix | File | Change |
|---|-----|------|--------|
| 1 | Date parsing | `GitHubCommentRenderer.tsx:9-27` | Replaced `try/catch` with `isNaN(date.getTime())` check |
| 2 | Comment priority | `DiffViewCardWithComments.tsx:258-260` | Added comment explaining user comments take priority |
| 3 | SplitSide.new | `WorkspaceContext.tsx:163-165` | Added comment explaining why `SplitSide.new` is correct |
| 4 | Multi-repo TODO | `WorkspaceContext.tsx:133-134` | Added TODO about supporting multiple repos |
| 5 | Loading state | `FileTreeContainer.tsx`, `FileTree.tsx` | Passed `isGitHubCommentsLoading` prop and added `opacity-50 animate-pulse` to button |

Type checks pass. Ready for commit.

* Fixed! The `FileTree.tsx` now uses the correct `Tooltip` component from `@/components/ui-new/primitives/Tooltip` instead of the old `@/components/ui/tooltip`.

Changes made:
- Updated import to use `import { Tooltip } from '../primitives/Tooltip'`
- Simplified the tooltip usage from the compound component pattern (`Tooltip` + `TooltipTrigger` + `TooltipContent`) to the simpler API (`<Tooltip content="...">...</Tooltip>`)

* The path matching bug has been fixed. Here's a summary of the changes:

## Summary of Bug Fix

**Problem**: GitHub comments weren't displaying because paths didn't match.
- GitHub API returns paths like: `frontend/src/components/ui-new/actions/index.ts`
- Diff paths are prefixed with repo name: `vibe-kanban/frontend/src/components/ui-new/actions/index.ts`

**Solution**: Use `endsWith` matching instead of exact string equality.

### Files Changed:

1. **`WorkspaceContext.tsx`**:
   - Added `pathMatches` helper function that checks if paths match using `endsWith`
   - Updated `getGitHubCommentsForFile` to use `pathMatches`
   - Replaced `gitHubFileCommentCounts: Record<string, number>` with `getGitHubCommentCountForFile: (filePath: string) => number` function that also uses `pathMatches`

2. **`FileTree.tsx`**:
   - Changed prop from `fileCommentCounts?: Record<string, number>` to `getGitHubCommentCountForFile?: (filePath: string) => number`
   - Updated usage from `fileCommentCounts?.[node.path]` to `getGitHubCommentCountForFile?.(node.path)`

3. **`FileTreeContainer.tsx`**:
   - Updated to pass the new `getGitHubCommentCountForFile` function instead of `gitHubFileCommentCounts` Record

* Cleanup script changes for workspace 881e2956-9cca-406d-835f-7ea8acbe550e

* Fixed! The `renderExtendLine` function now includes a null check for `lineData.data` to prevent the TypeError when switching diff modes. The fix adds:

```typescript
// Guard against undefined data (can happen when switching diff modes)
if (!lineData.data) return null;
```

This prevents the crash when the `@git-diff-view` library passes undefined data during mode transitions.

* Done! GitHub comments will now be shown by default. The change was a simple one-liner in `WorkspaceContext.tsx`:

```typescript
// Before:
false // Default to hidden

// After:
true // Default to shown
```

* The proper fix is complete. Here's a summary of the changes:

## Summary: Comments on deleted lines fix

**Problem**: GitHub comments on deleted lines weren't showing because we were placing all comments on `SplitSide.new`.

**Solution**: Pass through the `side` field from GitHub's API which indicates whether the comment is on the "LEFT" (old/deleted) or "RIGHT" (new/added) side.

### Files Changed:

1. **`crates/services/src/services/git_host/types.rs`**
   - Added `side: Option<String>` field to `UnifiedPrComment::Review` variant

2. **`crates/services/src/services/git_host/github/mod.rs`**
   - Pass `c.side` through when creating `UnifiedPrComment::Review`

3. **`crates/services/src/services/git_host/azure/cli.rs`**
   - Added `side: None` to Azure DevOps (doesn't have this info)

4. **`shared/types.ts`** (regenerated)
   - Now includes `side: string | null` in the review comment type

5. **`frontend/src/contexts/WorkspaceContext.tsx`**
   - Updated normalization to use `comment.side === 'LEFT' ? SplitSide.old : SplitSide.new`

6. **`frontend/src/components/ui-new/containers/DiffViewCardWithComments.tsx`**
   - Updated `extendData` building to place GitHub comments on the correct side (`oldFileData` or `newFileData`) based on `comment.side`

* The refactoring is complete. Here's a summary of the changes:

**Created new file:** `frontend/src/hooks/useGitHubComments.ts`
- Contains the `NormalizedGitHubComment` interface
- Contains all GitHub comments logic: fetching, toggle state, normalization, path matching helpers

**Updated:** `frontend/src/contexts/WorkspaceContext.tsx`
- Removed ~70 lines of GitHub comments logic
- Now imports and uses `useGitHubComments` hook
- Re-exports `NormalizedGitHubComment` type for consumers that import it from the context

The context file is now smaller and more focused on workspace management, while the GitHub comments functionality is encapsulated in its own dedicated hook.

* Cleanup script changes for workspace 881e2956-9cca-406d-835f-7ea8acbe550e

* Done. Removed the duplicate `formatRelativeTime` function from `GitHubCommentRenderer.tsx` and replaced it with an import from the existing `@/utils/date` utility.

* The i18n check now passes. All translation keys are consistent across locales. I added the missing `showGitHubComments` and `hideGitHubComments` translations to:
- Spanish (es)
- Japanese (ja)
- Korean (ko)
- Simplified Chinese (zh-Hans)
- Traditional Chinese (zh-Hant)
This commit is contained in:
Louis Knight-Webb
2026-01-12 23:47:53 +00:00
committed by GitHub
parent bd034da2db
commit 4a0fad03c2
21 changed files with 504 additions and 73 deletions

View File

@@ -531,6 +531,7 @@ impl AzCli {
url: None,
path: path.clone(),
line,
side: None,
diff_hunk: None,
});
} else {

View File

@@ -352,6 +352,7 @@ impl GitHostProvider for GitHubProvider {
url: Some(c.html_url),
path: c.path,
line: c.line,
side: c.side,
diff_hunk: Some(c.diff_hunk),
});
}

View File

@@ -119,6 +119,7 @@ pub enum UnifiedPrComment {
url: Option<String>,
path: String,
line: Option<i64>,
side: Option<String>,
diff_hunk: Option<String>,
},
}

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PrimaryButton } from '../primitives/PrimaryButton';
import { CommentCard } from '../primitives/CommentCard';
import WYSIWYGEditor from '@/components/ui/wysiwyg';
import { useReview, type ReviewDraft } from '@/contexts/ReviewProvider';
@@ -43,18 +44,10 @@ export function CommentWidgetLine({
}, [value, draft, setDraft, widgetKey, onSave, addComment]);
return (
<div className="p-base bg-panel font-sans text-base">
<div className="p-base rounded-sm bg-brand/20 border border-brand">
<WYSIWYGEditor
value={value}
onChange={setValue}
placeholder={t('comments.addPlaceholder')}
className="w-full text-normal min-h-[60px]"
projectId={projectId}
onCmdEnter={handleSave}
autoFocus
/>
<div className="mt-half flex gap-half">
<CommentCard
variant="input"
actions={
<>
<PrimaryButton
variant="default"
onClick={handleSave}
@@ -65,8 +58,18 @@ export function CommentWidgetLine({
<PrimaryButton variant="secondary" onClick={handleCancel}>
{t('actions.cancel')}
</PrimaryButton>
</div>
</div>
</div>
</>
}
>
<WYSIWYGEditor
value={value}
onChange={setValue}
placeholder={t('comments.addPlaceholder')}
className="w-full text-normal min-h-[60px]"
projectId={projectId}
onCmdEnter={handleSave}
autoFocus
/>
</CommentCard>
);
}

View File

@@ -1,6 +1,10 @@
import { useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { CaretDownIcon, ChatCircleIcon } from '@phosphor-icons/react';
import {
CaretDownIcon,
ChatCircleIcon,
GithubLogoIcon,
} from '@phosphor-icons/react';
import { DiffView, DiffModeEnum, SplitSide } from '@git-diff-view/react';
import { generateDiffFile, type DiffFile } from '@git-diff-view/file';
import { cn } from '@/lib/utils';
@@ -15,8 +19,13 @@ import {
type ReviewDraft,
type ReviewComment,
} from '@/contexts/ReviewProvider';
import {
useWorkspaceContext,
type NormalizedGitHubComment,
} from '@/contexts/WorkspaceContext';
import { CommentWidgetLine } from './CommentWidgetLine';
import { ReviewCommentRenderer } from './ReviewCommentRenderer';
import { GitHubCommentRenderer } from './GitHubCommentRenderer';
import type { ToolStatus, DiffChangeKind } from 'shared/types';
import { ToolStatusDot } from '../primitives/conversation/ToolStatusDot';
import { OpenInIdeButton } from '@/components/ide/OpenInIdeButton';
@@ -24,6 +33,11 @@ import { useOpenInEditor } from '@/hooks/useOpenInEditor';
import '@/styles/diff-style-overrides.css';
import { DisplayTruncatedPath } from '@/utils/TruncatePath';
/** Discriminated union for comment data in extendData */
type ExtendLineData =
| { type: 'review'; comment: ReviewComment }
| { type: 'github'; comment: NormalizedGitHubComment };
// Discriminated union for input format flexibility
export type DiffInput =
| {
@@ -172,6 +186,8 @@ export function DiffViewCardWithComments({
const { diffFile, additions, deletions, filePath, isValid } =
useDiffData(input);
const { comments, drafts, setDraft } = useReview();
const { showGitHubComments, getGitHubCommentsForFile } =
useWorkspaceContext();
// Open in IDE functionality
const openInEditor = useOpenInEditor(attemptId);
@@ -212,17 +228,48 @@ export function DiffViewCardWithComments({
[comments, filePath]
);
// Transform comments to git-diff-view extendData format
const extendData = useMemo(() => {
const oldFileData: Record<string, { data: ReviewComment }> = {};
const newFileData: Record<string, { data: ReviewComment }> = {};
// Get GitHub comments for this file (only when enabled)
const githubCommentsForFile = useMemo(() => {
if (!showGitHubComments) return [];
return getGitHubCommentsForFile(filePath);
}, [showGitHubComments, getGitHubCommentsForFile, filePath]);
// Total comment count (user + GitHub)
const totalCommentCount =
commentsForFile.length + githubCommentsForFile.length;
// Transform comments to git-diff-view extendData format
// The library expects { data: T } where T is the actual data
const extendData = useMemo(() => {
const oldFileData: Record<string, { data: ExtendLineData }> = {};
const newFileData: Record<string, { data: ExtendLineData }> = {};
// Add user review comments first (higher priority)
commentsForFile.forEach((comment) => {
const lineKey = String(comment.lineNumber);
const entry: ExtendLineData = { type: 'review', comment };
if (comment.side === SplitSide.old) {
oldFileData[lineKey] = { data: comment };
oldFileData[lineKey] = { data: entry };
} else {
newFileData[lineKey] = { data: comment };
newFileData[lineKey] = { data: entry };
}
});
// Add GitHub comments (only if no user comment on that line).
// User comments take priority - if you're adding your own comment on a line,
// you've likely addressed the GitHub feedback, so we hide the GitHub comment.
githubCommentsForFile.forEach((comment) => {
const lineKey = String(comment.lineNumber);
const entry: ExtendLineData = { type: 'github', comment };
// Place comment on correct side based on GitHub's side field
if (comment.side === SplitSide.old) {
if (!oldFileData[lineKey]) {
oldFileData[lineKey] = { data: entry };
}
} else {
if (!newFileData[lineKey]) {
newFileData[lineKey] = { data: entry };
}
}
});
@@ -230,7 +277,7 @@ export function DiffViewCardWithComments({
oldFile: oldFileData,
newFile: newFileData,
};
}, [commentsForFile]);
}, [commentsForFile, githubCommentsForFile]);
// Handle click on "add widget" button in diff view
const handleAddWidgetClick = useCallback(
@@ -269,11 +316,21 @@ export function DiffViewCardWithComments({
[filePath, drafts, projectId]
);
// Render existing comments below lines
// Render existing comments below lines (handles both user and GitHub comments)
// The library wraps our data in { data: ExtendLineData }
const renderExtendLine = useCallback(
(lineData: { data: ReviewComment }) => {
(lineData: { data: ExtendLineData }) => {
// Guard against undefined data (can happen when switching diff modes)
if (!lineData.data) return null;
if (lineData.data.type === 'github') {
return <GitHubCommentRenderer comment={lineData.data.comment} />;
}
return (
<ReviewCommentRenderer comment={lineData.data} projectId={projectId} />
<ReviewCommentRenderer
comment={lineData.data.comment}
projectId={projectId}
/>
);
},
[projectId]
@@ -332,12 +389,22 @@ export function DiffViewCardWithComments({
{deletions > 0 && <span className="text-error">-{deletions}</span>}
</span>
)}
{totalCommentCount > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded shrink-0">
{commentsForFile.length > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-accent/10 text-accent rounded shrink-0">
<span className="inline-flex items-center gap-0.5 text-accent">
<ChatCircleIcon className="size-icon-xs" weight="fill" />
{commentsForFile.length}
</span>
)}
{githubCommentsForFile.length > 0 && (
<span className="inline-flex items-center gap-0.5 text-low">
<GithubLogoIcon className="size-icon-xs" weight="fill" />
{githubCommentsForFile.length}
</span>
)}
</span>
)}
<div className="flex items-center gap-1 shrink-0">
{attemptId && (
<span onClick={(e) => e.stopPropagation()}>
@@ -393,8 +460,8 @@ function DiffViewBodyWithComments({
theme: 'light' | 'dark';
diffMode: DiffModeEnum;
extendData: {
oldFile: Record<string, { data: ReviewComment }>;
newFile: Record<string, { data: ReviewComment }>;
oldFile: Record<string, { data: ExtendLineData }>;
newFile: Record<string, { data: ExtendLineData }>;
};
onAddWidgetClick: (lineNumber: number, side: SplitSide) => void;
renderWidgetLine: (props: {
@@ -402,7 +469,7 @@ function DiffViewBodyWithComments({
lineNumber: number;
onClose: () => void;
}) => React.ReactNode;
renderExtendLine: (lineData: { data: ReviewComment }) => React.ReactNode;
renderExtendLine: (lineData: { data: ExtendLineData }) => React.ReactNode;
}) {
const { t } = useTranslation('tasks');
if (!isValid || !diffFile) {

View File

@@ -7,6 +7,7 @@ import {
getAllFolderPaths,
} from '@/utils/fileTreeUtils';
import { usePersistedCollapsedPaths } from '@/stores/useUiPreferencesStore';
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
import type { Diff } from 'shared/types';
interface FileTreeContainerProps {
@@ -30,6 +31,14 @@ export function FileTreeContainer({
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Get GitHub comments state from workspace context
const {
showGitHubComments,
setShowGitHubComments,
getGitHubCommentCountForFile,
isGitHubCommentsLoading,
} = useWorkspaceContext();
// Sync selectedPath with external selectedFilePath prop and scroll into view
useEffect(() => {
if (selectedFilePath !== undefined) {
@@ -129,6 +138,10 @@ export function FileTreeContainer({
isAllExpanded={isAllExpanded}
onToggleExpandAll={handleToggleExpandAll}
className={className}
showGitHubComments={showGitHubComments}
onToggleGitHubComments={setShowGitHubComments}
getGitHubCommentCountForFile={getGitHubCommentCountForFile}
isGitHubCommentsLoading={isGitHubCommentsLoading}
/>
);
}

View File

@@ -0,0 +1,41 @@
import { GithubLogoIcon, ArrowSquareOutIcon } from '@phosphor-icons/react';
import { CommentCard } from '../primitives/CommentCard';
import { formatRelativeTime } from '@/utils/date';
import type { NormalizedGitHubComment } from '@/contexts/WorkspaceContext';
interface GitHubCommentRendererProps {
comment: NormalizedGitHubComment;
}
/**
* Read-only renderer for GitHub PR comments.
* Uses CommentCard primitive with 'github' variant for neutral styling.
*/
export function GitHubCommentRenderer({ comment }: GitHubCommentRendererProps) {
const header = (
<div className="flex items-center gap-half text-sm">
<GithubLogoIcon className="size-icon-sm text-low" weight="fill" />
<span className="font-medium text-normal">@{comment.author}</span>
<span className="text-low">{formatRelativeTime(comment.createdAt)}</span>
{comment.url && (
<a
href={comment.url}
target="_blank"
rel="noopener noreferrer"
className="text-low hover:text-normal ml-auto"
onClick={(e) => e.stopPropagation()}
>
<ArrowSquareOutIcon className="size-icon-xs" />
</a>
)}
</div>
);
return (
<CommentCard variant="github" header={header}>
<div className="text-sm text-normal whitespace-pre-wrap break-words">
{comment.body}
</div>
</CommentCard>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PrimaryButton } from '../primitives/PrimaryButton';
import { CommentCard } from '../primitives/CommentCard';
import WYSIWYGEditor from '@/components/ui/wysiwyg';
import { useReview, type ReviewComment } from '@/contexts/ReviewProvider';
@@ -41,18 +42,10 @@ export function ReviewCommentRenderer({
if (isEditing) {
return (
<div className="p-base bg-panel font-sans text-base">
<div className="p-base rounded-sm bg-brand/20 border border-brand">
<WYSIWYGEditor
value={editText}
onChange={setEditText}
placeholder={t('comments.editPlaceholder')}
className="w-full text-sm text-normal min-h-[60px]"
projectId={projectId}
onCmdEnter={handleSave}
autoFocus
/>
<div className="mt-half flex gap-half">
<CommentCard
variant="user"
actions={
<>
<PrimaryButton
variant="default"
onClick={handleSave}
@@ -63,15 +56,24 @@ export function ReviewCommentRenderer({
<PrimaryButton variant="tertiary" onClick={handleCancel}>
{t('actions.cancel')}
</PrimaryButton>
</div>
</div>
</div>
</>
}
>
<WYSIWYGEditor
value={editText}
onChange={setEditText}
placeholder={t('comments.editPlaceholder')}
className="w-full text-sm text-normal min-h-[60px]"
projectId={projectId}
onCmdEnter={handleSave}
autoFocus
/>
</CommentCard>
);
}
return (
<div className="p-base bg-panel font-sans text-base">
<div className="p-base rounded-sm bg-brand/20 border border-brand">
<CommentCard variant="user">
<WYSIWYGEditor
value={comment.text}
disabled={true}
@@ -79,7 +81,6 @@ export function ReviewCommentRenderer({
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
</div>
</CommentCard>
);
}

View File

@@ -0,0 +1,51 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export type CommentCardVariant = 'user' | 'github' | 'input';
interface CommentCardProps {
/** Determines the visual styling */
variant: CommentCardVariant;
/** Main content (editor, text, etc.) */
children: ReactNode;
/** Optional header (author, timestamp) */
header?: ReactNode;
/** Optional action buttons */
actions?: ReactNode;
/** Additional className for the outer wrapper */
className?: string;
}
const variantStyles: Record<CommentCardVariant, string> = {
user: 'bg-brand/20 border-brand',
github: 'bg-secondary border-border',
input: 'bg-brand/20 border-brand',
};
/**
* Shared primitive for displaying comments in diff views.
* Used by ReviewCommentRenderer, GitHubCommentRenderer, and CommentWidgetLine.
*/
export function CommentCard({
variant,
children,
header,
actions,
className,
}: CommentCardProps) {
return (
<div className="p-base bg-panel font-sans text-base">
<div
className={cn(
'p-base rounded-sm border',
variantStyles[variant],
className
)}
>
{header && <div className="mb-half">{header}</div>}
{children}
{actions && <div className="mt-half flex gap-half">{actions}</div>}
</div>
</div>
);
}

View File

@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next';
import { GithubLogoIcon } from '@phosphor-icons/react';
import { cn } from '@/lib/utils';
import { Tooltip } from '../primitives/Tooltip';
import { FileTreeSearchBar } from './FileTreeSearchBar';
import { FileTreeNode } from './FileTreeNode';
import type { TreeNode } from '../types/fileTree';
@@ -18,6 +20,14 @@ interface FileTreeProps {
isAllExpanded: boolean;
onToggleExpandAll: () => void;
className?: string;
/** Whether to show GitHub comments */
showGitHubComments?: boolean;
/** Callback to toggle GitHub comments visibility */
onToggleGitHubComments?: (show: boolean) => void;
/** Function to get comment count for a file path (handles prefixed paths) */
getGitHubCommentCountForFile?: (filePath: string) => number;
/** Whether GitHub comments are currently loading */
isGitHubCommentsLoading?: boolean;
}
export function FileTree({
@@ -32,6 +42,10 @@ export function FileTree({
isAllExpanded,
onToggleExpandAll,
className,
showGitHubComments,
onToggleGitHubComments,
getGitHubCommentCountForFile,
isGitHubCommentsLoading,
}: FileTreeProps) {
const { t } = useTranslation(['tasks', 'common']);
@@ -56,6 +70,8 @@ export function FileTree({
? () => onSelectFile(node.path)
: undefined
}
commentCount={getGitHubCommentCountForFile?.(node.path)}
showCommentBadge={showGitHubComments}
/>
{node.type === 'folder' &&
node.children &&
@@ -73,6 +89,8 @@ export function FileTree({
contentClassName="flex flex-col flex-1 min-h-0"
>
<div className="px-base pt-base">
<div className="flex items-center gap-half">
<div className="flex-1">
<FileTreeSearchBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
@@ -80,6 +98,34 @@ export function FileTree({
onToggleExpandAll={onToggleExpandAll}
/>
</div>
{onToggleGitHubComments && (
<Tooltip
content={
showGitHubComments
? t('common:fileTree.hideGitHubComments')
: t('common:fileTree.showGitHubComments')
}
>
<button
type="button"
onClick={() => onToggleGitHubComments(!showGitHubComments)}
className={cn(
'p-1 rounded hover:bg-panel transition-colors shrink-0',
showGitHubComments ? 'text-normal' : 'text-low',
isGitHubCommentsLoading && 'opacity-50 animate-pulse'
)}
aria-label={
showGitHubComments
? t('common:fileTree.hideGitHubComments')
: t('common:fileTree.showGitHubComments')
}
>
<GithubLogoIcon className="size-icon-sm" weight="fill" />
</button>
</Tooltip>
)}
</div>
</div>
<div className="p-base flex-1 min-h-0 overflow-auto scrollbar-thin scrollbar-thumb-panel scrollbar-track-transparent">
{nodes.length > 0 ? (
renderNodes(nodes)

View File

@@ -3,6 +3,7 @@ import {
CaretDownIcon,
CaretRightIcon,
FolderSimpleIcon,
GithubLogoIcon,
} from '@phosphor-icons/react';
import { cn } from '@/lib/utils';
import { getFileIcon } from '@/utils/fileTypeIcon';
@@ -17,11 +18,24 @@ interface FileTreeNodeProps {
isSelected?: boolean;
onToggle?: () => void;
onSelect?: () => void;
/** GitHub comment count for this file */
commentCount?: number;
/** Whether to show the comment badge */
showCommentBadge?: boolean;
}
export const FileTreeNode = forwardRef<HTMLDivElement, FileTreeNodeProps>(
function FileTreeNode(
{ node, depth, isExpanded = false, isSelected = false, onToggle, onSelect },
{
node,
depth,
isExpanded = false,
isSelected = false,
onToggle,
onSelect,
commentCount,
showCommentBadge,
},
ref
) {
const { theme } = useTheme();
@@ -128,6 +142,17 @@ export const FileTreeNode = forwardRef<HTMLDivElement, FileTreeNodeProps>(
)}
</span>
)}
{/* GitHub comment badge */}
{showCommentBadge &&
node.type === 'file' &&
commentCount != null &&
commentCount > 0 && (
<span className="inline-flex items-center gap-0.5 text-xs text-low shrink-0 ml-half">
<GithubLogoIcon className="size-icon-xs" weight="fill" />
{commentCount}
</span>
)}
</div>
</div>
);

View File

@@ -15,13 +15,20 @@ import {
import { useAttempt } from '@/hooks/useAttempt';
import { useAttemptRepo } from '@/hooks/useAttemptRepo';
import { useWorkspaceSessions } from '@/hooks/useWorkspaceSessions';
import {
useGitHubComments,
type NormalizedGitHubComment,
} from '@/hooks/useGitHubComments';
import { attemptsApi } from '@/lib/api';
import type {
Workspace as ApiWorkspace,
Session,
RepoWithTargetBranch,
UnifiedPrComment,
} from 'shared/types';
export type { NormalizedGitHubComment } from '@/hooks/useGitHubComments';
interface WorkspaceContextValue {
workspaceId: string | undefined;
/** Real workspace data from API */
@@ -48,6 +55,13 @@ interface WorkspaceContextValue {
/** Repos for the current workspace */
repos: RepoWithTargetBranch[];
isReposLoading: boolean;
/** GitHub PR Comments */
gitHubComments: UnifiedPrComment[];
isGitHubCommentsLoading: boolean;
showGitHubComments: boolean;
setShowGitHubComments: (show: boolean) => void;
getGitHubCommentsForFile: (filePath: string) => NormalizedGitHubComment[];
getGitHubCommentCountForFile: (filePath: string) => number;
}
const WorkspaceContext = createContext<WorkspaceContextValue | null>(null);
@@ -95,6 +109,24 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
enabled: !isCreateMode,
});
// Get first repo ID for PR comments.
// TODO: Support multiple repos - currently only fetches comments from the primary repo.
const primaryRepoId = repos[0]?.id;
// GitHub comments hook (fetching, normalization, and helpers)
const {
gitHubComments,
isGitHubCommentsLoading,
showGitHubComments,
setShowGitHubComments,
getGitHubCommentsForFile,
getGitHubCommentCountForFile,
} = useGitHubComments({
workspaceId,
repoId: primaryRepoId,
enabled: !isCreateMode,
});
const isLoading = isLoadingList || isLoadingWorkspace;
const selectWorkspace = useCallback(
@@ -142,6 +174,12 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
startNewSession,
repos,
isReposLoading,
gitHubComments,
isGitHubCommentsLoading,
showGitHubComments,
setShowGitHubComments,
getGitHubCommentsForFile,
getGitHubCommentCountForFile,
}),
[
workspaceId,
@@ -162,6 +200,12 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
startNewSession,
repos,
isReposLoading,
gitHubComments,
isGitHubCommentsLoading,
showGitHubComments,
setShowGitHubComments,
getGitHubCommentsForFile,
getGitHubCommentCountForFile,
]
);

View File

@@ -0,0 +1,122 @@
import { useMemo, useCallback } from 'react';
import { SplitSide } from '@git-diff-view/react';
import { usePrComments } from './usePrComments';
import {
usePersistedExpanded,
PERSIST_KEYS,
} from '@/stores/useUiPreferencesStore';
import type { UnifiedPrComment } from 'shared/types';
/**
* Normalized GitHub comment for diff view display
*/
export interface NormalizedGitHubComment {
id: string;
author: string;
body: string;
createdAt: string;
url: string | null;
filePath: string;
lineNumber: number;
side: SplitSide;
diffHunk: string | null;
}
interface UseGitHubCommentsOptions {
workspaceId?: string;
repoId?: string;
enabled?: boolean;
}
interface UseGitHubCommentsResult {
gitHubComments: UnifiedPrComment[];
isGitHubCommentsLoading: boolean;
showGitHubComments: boolean;
setShowGitHubComments: (show: boolean) => void;
getGitHubCommentsForFile: (filePath: string) => NormalizedGitHubComment[];
getGitHubCommentCountForFile: (filePath: string) => number;
}
export function useGitHubComments({
workspaceId,
repoId,
enabled = true,
}: UseGitHubCommentsOptions): UseGitHubCommentsResult {
// GitHub comments toggle state (persisted)
const [showGitHubComments, setShowGitHubComments] = usePersistedExpanded(
PERSIST_KEYS.showGitHubComments,
true // Default to shown
);
// Fetch PR comments for the current workspace
const { data: prCommentsData, isLoading: isGitHubCommentsLoading } =
usePrComments(workspaceId, repoId, {
enabled: enabled && !!repoId,
});
const gitHubComments = useMemo(
() => prCommentsData?.comments ?? [],
[prCommentsData?.comments]
);
// Normalize GitHub review comments for file matching
const normalizedComments = useMemo(() => {
const normalized: NormalizedGitHubComment[] = [];
for (const comment of gitHubComments) {
if (comment.comment_type !== 'review') continue;
if (comment.line === null) continue; // Skip file-level comments
normalized.push({
id: String(comment.id),
author: comment.author,
body: comment.body,
createdAt: comment.created_at,
url: comment.url,
filePath: comment.path,
lineNumber: Number(comment.line),
// Use side from API: "LEFT" = old/deleted side, "RIGHT" = new/added side (default)
side: comment.side === 'LEFT' ? SplitSide.old : SplitSide.new,
diffHunk: comment.diff_hunk,
});
}
return normalized;
}, [gitHubComments]);
// Helper to match paths - handles repo prefix in diff paths
// GitHub paths: "frontend/src/file.ts"
// Diff paths: "vibe-kanban/frontend/src/file.ts" (prefixed with repo name)
const pathMatches = useCallback(
(diffPath: string, githubPath: string): boolean => {
return diffPath === githubPath || diffPath.endsWith('/' + githubPath);
},
[]
);
// Get comments for a specific file (handles prefixed paths)
const getGitHubCommentsForFile = useCallback(
(filePath: string): NormalizedGitHubComment[] => {
return normalizedComments.filter((c) =>
pathMatches(filePath, c.filePath)
);
},
[normalizedComments, pathMatches]
);
// Get comment count for a specific file (handles prefixed paths)
const getGitHubCommentCountForFile = useCallback(
(filePath: string): number => {
return normalizedComments.filter((c) => pathMatches(filePath, c.filePath))
.length;
},
[normalizedComments, pathMatches]
);
return {
gitHubComments,
isGitHubCommentsLoading,
showGitHubComments,
setShowGitHubComments,
getGitHubCommentsForFile,
getGitHubCommentCountForFile,
};
}

View File

@@ -143,7 +143,9 @@
"fileTree": {
"searchPlaceholder": "Search files...",
"noResults": "No matching files",
"title": "Files"
"title": "Files",
"showGitHubComments": "Show GitHub comments",
"hideGitHubComments": "Hide GitHub comments"
},
"sections": {
"changes": "Changes",

View File

@@ -143,7 +143,9 @@
"fileTree": {
"searchPlaceholder": "Buscar archivos...",
"noResults": "No hay archivos coincidentes",
"title": "Archivos"
"title": "Archivos",
"showGitHubComments": "Mostrar comentarios de GitHub",
"hideGitHubComments": "Ocultar comentarios de GitHub"
},
"sections": {
"changes": "Cambios",

View File

@@ -143,7 +143,9 @@
"fileTree": {
"searchPlaceholder": "ファイルを検索...",
"noResults": "一致するファイルがありません",
"title": "ファイル"
"title": "ファイル",
"showGitHubComments": "GitHubコメントを表示",
"hideGitHubComments": "GitHubコメントを非表示"
},
"sections": {
"changes": "変更",

View File

@@ -143,7 +143,9 @@
"fileTree": {
"searchPlaceholder": "파일 검색...",
"noResults": "일치하는 파일 없음",
"title": "파일"
"title": "파일",
"showGitHubComments": "GitHub 댓글 표시",
"hideGitHubComments": "GitHub 댓글 숨기기"
},
"sections": {
"changes": "변경사항",

View File

@@ -143,7 +143,9 @@
"fileTree": {
"searchPlaceholder": "搜索文件...",
"noResults": "没有匹配的文件",
"title": "文件"
"title": "文件",
"showGitHubComments": "显示 GitHub 评论",
"hideGitHubComments": "隐藏 GitHub 评论"
},
"sections": {
"changes": "更改",

View File

@@ -143,7 +143,9 @@
"fileTree": {
"searchPlaceholder": "搜尋檔案...",
"noResults": "沒有匹配的檔案",
"title": "檔案"
"title": "檔案",
"showGitHubComments": "顯示 GitHub 評論",
"hideGitHubComments": "隱藏 GitHub 評論"
},
"sections": {
"changes": "變更",

View File

@@ -30,6 +30,8 @@ export const PERSIST_KEYS = {
devServerSection: 'dev-server-section',
// Context bar
contextBarPosition: 'context-bar-position',
// GitHub comments toggle
showGitHubComments: 'show-github-comments',
// Pane sizes
sidebarWidth: 'workspaces-sidebar-width',
gitPanelWidth: 'workspaces-git-panel-width',
@@ -50,6 +52,7 @@ export type PersistKey =
| typeof PERSIST_KEYS.processesSection
| typeof PERSIST_KEYS.changesSection
| typeof PERSIST_KEYS.devServerSection
| typeof PERSIST_KEYS.showGitHubComments
| typeof PERSIST_KEYS.sidebarWidth
| typeof PERSIST_KEYS.gitPanelWidth
| typeof PERSIST_KEYS.changesPanelWidth

View File

@@ -300,7 +300,7 @@ export type GetPrCommentsError = { "type": "no_pr_attached" } | { "type": "cli_n
export type GetPrCommentsQuery = { repo_id: string, };
export type UnifiedPrComment = { "comment_type": "general", id: string, author: string, author_association: string | null, body: string, created_at: string, url: string | null, } | { "comment_type": "review", id: bigint, author: string, author_association: string | null, body: string, created_at: string, url: string | null, path: string, line: bigint | null, diff_hunk: string | null, };
export type UnifiedPrComment = { "comment_type": "general", id: string, author: string, author_association: string | null, body: string, created_at: string, url: string | null, } | { "comment_type": "review", id: bigint, author: string, author_association: string | null, body: string, created_at: string, url: string | null, path: string, line: bigint | null, side: string | null, diff_hunk: string | null, };
export type ProviderKind = "git_hub" | "azure_dev_ops" | "unknown";