Skip to content

Commit 39e7440

Browse files
committed
Restructure PR review workspace layout
- Add dedicated panes for PR list, file tabs, inspector, and conflict review - Introduce comment composer, thread cards, and mention-aware comment rendering - Improve workspace navigation and focus controls for review workflows
1 parent 5652ade commit 39e7440

14 files changed

Lines changed: 2477 additions & 1947 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { PrUserHoverCard } from "./PrUserHoverCard";
2+
3+
export function PrCommentBody({ body, cwd }: { body: string; cwd: string | null }) {
4+
const lines = body.split("\n");
5+
return (
6+
<div className="space-y-2 whitespace-pre-wrap text-sm leading-6 text-foreground/88">
7+
{lines.map((line, lineIndex) => {
8+
const segments = line.split(/(@[a-zA-Z0-9-]+)/g);
9+
return (
10+
<p key={`${lineIndex}:${line}`}>
11+
{segments.map((segment, segmentIndex) => {
12+
if (/^@[a-zA-Z0-9-]+$/.test(segment)) {
13+
return (
14+
<PrUserHoverCard
15+
cwd={cwd}
16+
key={`${lineIndex}:${segmentIndex}`}
17+
login={segment.slice(1)}
18+
>
19+
{segment}
20+
</PrUserHoverCard>
21+
);
22+
}
23+
return <span key={`${lineIndex}:${segmentIndex}`}>{segment}</span>;
24+
})}
25+
</p>
26+
);
27+
})}
28+
</div>
29+
);
30+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { PrConflictAnalysis } from "@okcode/contracts";
2+
import { useEffect, useState } from "react";
3+
import { ShieldCheckIcon } from "lucide-react";
4+
import { cn } from "~/lib/utils";
5+
import { Badge } from "~/components/ui/badge";
6+
import { Button } from "~/components/ui/button";
7+
import { ScrollArea } from "~/components/ui/scroll-area";
8+
import {
9+
Sheet,
10+
SheetDescription,
11+
SheetHeader,
12+
SheetPanel,
13+
SheetPopup,
14+
SheetTitle,
15+
} from "~/components/ui/sheet";
16+
import { projectLabel } from "~/components/review/reviewUtils";
17+
import type { Project } from "~/types";
18+
19+
export function PrConflictDrawer({
20+
open,
21+
onOpenChange,
22+
project,
23+
conflictAnalysis,
24+
onApplyResolution,
25+
}: {
26+
open: boolean;
27+
onOpenChange: (open: boolean) => void;
28+
project: Project;
29+
conflictAnalysis: PrConflictAnalysis | undefined;
30+
onApplyResolution: (candidateId: string) => Promise<void>;
31+
}) {
32+
const [selectedCandidateId, setSelectedCandidateId] = useState<string | null>(null);
33+
34+
useEffect(() => {
35+
if (!open) return;
36+
setSelectedCandidateId(conflictAnalysis?.candidates[0]?.id ?? null);
37+
}, [conflictAnalysis?.candidates, open]);
38+
39+
const selectedCandidate =
40+
conflictAnalysis?.candidates.find((candidate) => candidate.id === selectedCandidateId) ?? null;
41+
42+
return (
43+
<Sheet onOpenChange={onOpenChange} open={open}>
44+
<SheetPopup className="max-w-[min(1100px,calc(100vw-3rem))]" side="right" variant="inset">
45+
<SheetHeader>
46+
<SheetTitle>Conflict resolution</SheetTitle>
47+
<SheetDescription>
48+
{conflictAnalysis?.summary ?? "Merge conflict analysis is unavailable."}
49+
</SheetDescription>
50+
</SheetHeader>
51+
<SheetPanel className="grid min-h-0 gap-4 xl:grid-cols-[320px_minmax(0,1fr)]">
52+
<div className="space-y-3">
53+
<div className="rounded-2xl border border-border/70 bg-background/92 p-4">
54+
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
55+
Repo focus
56+
</p>
57+
<p className="mt-2 font-medium text-sm text-foreground">{projectLabel(project)}</p>
58+
<p className="mt-1 text-sm text-muted-foreground">{project.cwd}</p>
59+
</div>
60+
{conflictAnalysis?.candidates.length ? (
61+
conflictAnalysis.candidates.map((candidate) => (
62+
<button
63+
className={cn(
64+
"w-full rounded-2xl border px-4 py-4 text-left transition-colors",
65+
selectedCandidateId === candidate.id
66+
? "border-amber-500/30 bg-amber-500/8"
67+
: "border-border/70 bg-background/90 hover:bg-muted/35",
68+
)}
69+
key={candidate.id}
70+
onClick={() => setSelectedCandidateId(candidate.id)}
71+
type="button"
72+
>
73+
<div className="flex items-start justify-between gap-3">
74+
<div>
75+
<p className="font-medium text-sm text-foreground">{candidate.title}</p>
76+
<p className="mt-1 text-xs text-muted-foreground">{candidate.path}</p>
77+
</div>
78+
<Badge
79+
className={cn(
80+
candidate.confidence === "safe"
81+
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
82+
: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
83+
)}
84+
>
85+
{candidate.confidence}
86+
</Badge>
87+
</div>
88+
<p className="mt-3 text-sm text-muted-foreground">{candidate.description}</p>
89+
</button>
90+
))
91+
) : (
92+
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/18 px-4 py-6 text-sm text-muted-foreground">
93+
No candidate resolutions were generated. OK Code will only propose deterministic
94+
resolutions automatically.
95+
</div>
96+
)}
97+
</div>
98+
<div className="min-h-0 rounded-[24px] border border-border/70 bg-background/94">
99+
<div className="flex items-center justify-between gap-3 border-b border-border/70 px-4 py-4">
100+
<div>
101+
<p className="font-medium text-sm text-foreground">
102+
{selectedCandidate?.title ?? "No candidate selected"}
103+
</p>
104+
<p className="mt-1 text-xs text-muted-foreground">
105+
Preview patch before applying any conflict resolution.
106+
</p>
107+
</div>
108+
{selectedCandidate ? (
109+
<Button
110+
onClick={() => {
111+
void onApplyResolution(selectedCandidate.id);
112+
}}
113+
size="sm"
114+
>
115+
<ShieldCheckIcon className="size-3.5" />
116+
Apply candidate
117+
</Button>
118+
) : null}
119+
</div>
120+
<ScrollArea className="min-h-0 h-full">
121+
<div className="p-4">
122+
{selectedCandidate ? (
123+
<pre className="overflow-auto whitespace-pre-wrap rounded-2xl border border-border/70 bg-muted/22 p-4 text-xs leading-6 text-foreground/88">
124+
{selectedCandidate.previewPatch}
125+
</pre>
126+
) : (
127+
<div className="flex min-h-[280px] items-center justify-center text-sm text-muted-foreground">
128+
Select a candidate to preview the patch.
129+
</div>
130+
)}
131+
</div>
132+
</ScrollArea>
133+
</div>
134+
</SheetPanel>
135+
</SheetPopup>
136+
</Sheet>
137+
);
138+
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import type {
2+
NativeApi,
3+
PrConflictAnalysis,
4+
PrReviewConfig,
5+
PrReviewThread,
6+
} from "@okcode/contracts";
7+
import { useState } from "react";
8+
import { MessageSquareIcon, ShieldCheckIcon, SparklesIcon, UsersIcon } from "lucide-react";
9+
import { cn } from "~/lib/utils";
10+
import { Button } from "~/components/ui/button";
11+
import { ScrollArea } from "~/components/ui/scroll-area";
12+
import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
13+
import { SectionHeading } from "~/components/review/ReviewChrome";
14+
import { projectLabel } from "~/components/review/reviewUtils";
15+
import type { Project } from "~/types";
16+
import type { InspectorTab } from "./pr-review-utils";
17+
import { PrThreadCard } from "./PrThreadCard";
18+
import { PrWorkflowPanel } from "./PrWorkflowPanel";
19+
import { PrUserHoverCard } from "./PrUserHoverCard";
20+
21+
export function PrConversationInspector({
22+
project,
23+
dashboard,
24+
config,
25+
conflicts,
26+
workflowId,
27+
onWorkflowIdChange,
28+
selectedFilePath,
29+
selectedThreadId,
30+
onSelectFilePath,
31+
onSelectThreadId,
32+
onResolveThread,
33+
onReplyToThread,
34+
onRunStep,
35+
onOpenRules,
36+
onOpenWorkflow,
37+
onOpenConflictDrawer,
38+
}: {
39+
project: Project;
40+
dashboard: Awaited<ReturnType<NativeApi["prReview"]["getDashboard"]>> | null | undefined;
41+
config: PrReviewConfig | undefined;
42+
conflicts: PrConflictAnalysis | undefined;
43+
workflowId: string | null;
44+
onWorkflowIdChange: (workflowId: string) => void;
45+
selectedFilePath: string | null;
46+
selectedThreadId: string | null;
47+
onSelectFilePath: (path: string | null) => void;
48+
onSelectThreadId: (threadId: string | null) => void;
49+
onResolveThread: (threadId: string, nextAction: "resolve" | "unresolve") => Promise<void>;
50+
onReplyToThread: (threadId: string, body: string) => Promise<void>;
51+
onRunStep: (stepId: string, requiresConfirmation: boolean, title: string) => Promise<void>;
52+
onOpenRules: () => void;
53+
onOpenWorkflow: (relativePath: string) => void;
54+
onOpenConflictDrawer: () => void;
55+
}) {
56+
const [tab, setTab] = useState<InspectorTab>("threads");
57+
58+
if (!dashboard) {
59+
return (
60+
<div className="flex h-full items-center justify-center px-5 text-center text-sm text-muted-foreground">
61+
Select a pull request to inspect conversations, repo rules, and workflow state.
62+
</div>
63+
);
64+
}
65+
66+
const visibleThreads = selectedFilePath
67+
? dashboard.threads.filter((thread) => thread.path === selectedFilePath)
68+
: dashboard.threads;
69+
70+
return (
71+
<div className="flex min-h-0 min-w-0 flex-col bg-background/96">
72+
<div className="border-b border-border/70 px-4 py-4">
73+
<SectionHeading
74+
action={
75+
<Button onClick={onOpenConflictDrawer} size="xs" variant="outline">
76+
<ShieldCheckIcon className="size-3.5" />
77+
Conflicts
78+
</Button>
79+
}
80+
detail={`Repo focus: ${projectLabel(project)}. ${selectedFilePath ? `Filtered to ${selectedFilePath}.` : "Showing all files."}`}
81+
eyebrow="Inspector"
82+
title="Conversations and rules"
83+
/>
84+
<ToggleGroup
85+
className="mt-4"
86+
size="xs"
87+
value={[tab]}
88+
variant="outline"
89+
onValueChange={(values) => {
90+
const nextValue = values[values.length - 1];
91+
if (nextValue === "threads" || nextValue === "workflow" || nextValue === "people") {
92+
setTab(nextValue);
93+
}
94+
}}
95+
>
96+
<Toggle value="threads">
97+
<MessageSquareIcon className="size-3.5" />
98+
Threads
99+
</Toggle>
100+
<Toggle value="workflow">
101+
<SparklesIcon className="size-3.5" />
102+
Workflow
103+
</Toggle>
104+
<Toggle value="people">
105+
<UsersIcon className="size-3.5" />
106+
People
107+
</Toggle>
108+
</ToggleGroup>
109+
</div>
110+
111+
<ScrollArea className="min-h-0 flex-1">
112+
<div className="space-y-4 px-4 py-4">
113+
{tab === "threads" ? (
114+
<>
115+
{selectedFilePath ? (
116+
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-muted/25 px-3 py-3">
117+
<div>
118+
<p className="text-sm font-medium text-foreground">
119+
Filtered to {selectedFilePath}
120+
</p>
121+
<p className="text-xs text-muted-foreground">
122+
Only conversations on the focused file are shown here.
123+
</p>
124+
</div>
125+
<Button
126+
onClick={() => {
127+
onSelectFilePath(null);
128+
onSelectThreadId(null);
129+
}}
130+
size="xs"
131+
variant="outline"
132+
>
133+
Clear focus
134+
</Button>
135+
</div>
136+
) : null}
137+
{visibleThreads.length === 0 ? (
138+
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/18 px-4 py-6 text-sm text-muted-foreground">
139+
No conversations are visible for the current scope.
140+
</div>
141+
) : (
142+
visibleThreads.map((thread) => (
143+
<PrThreadCard
144+
dashboard={dashboard}
145+
key={thread.id}
146+
onReplyToThread={onReplyToThread}
147+
onResolveThread={onResolveThread}
148+
onSelectFilePath={onSelectFilePath}
149+
onSelectThreadId={onSelectThreadId}
150+
project={project}
151+
selectedThreadId={selectedThreadId}
152+
thread={thread}
153+
/>
154+
))
155+
)}
156+
</>
157+
) : null}
158+
159+
{tab === "workflow" ? (
160+
<PrWorkflowPanel
161+
conflicts={conflicts}
162+
config={config}
163+
onOpenRules={onOpenRules}
164+
onOpenWorkflow={onOpenWorkflow}
165+
onRunStep={onRunStep}
166+
onWorkflowIdChange={onWorkflowIdChange}
167+
workflowId={workflowId}
168+
workflowSteps={dashboard.workflowSteps}
169+
/>
170+
) : null}
171+
172+
{tab === "people" ? (
173+
<div className="space-y-4">
174+
<div className="rounded-2xl border border-border/70 bg-background/92 p-4">
175+
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
176+
Participants
177+
</p>
178+
<div className="mt-4 grid gap-3">
179+
{dashboard.pullRequest.participants.map((participant) => (
180+
<div
181+
className="flex items-center gap-3 rounded-2xl border border-border/70 bg-muted/22 px-3 py-3"
182+
key={`${participant.user.login}:${participant.role}`}
183+
>
184+
<img
185+
alt={participant.user.login}
186+
className="size-10 rounded-full border border-border/70"
187+
src={participant.user.avatarUrl}
188+
/>
189+
<div className="min-w-0 flex-1">
190+
<PrUserHoverCard cwd={project.cwd} login={participant.user.login}>
191+
@{participant.user.login}
192+
</PrUserHoverCard>
193+
<p className="truncate text-xs text-muted-foreground">{participant.role}</p>
194+
</div>
195+
</div>
196+
))}
197+
</div>
198+
</div>
199+
200+
<div className="rounded-2xl border border-border/70 bg-background/92 p-4">
201+
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
202+
Repo Rules
203+
</p>
204+
<div className="mt-4 space-y-3">
205+
{config?.rules.blockingRules.map((rule) => (
206+
<div key={rule.id}>
207+
<p className="font-medium text-sm text-foreground">{rule.title}</p>
208+
{rule.description ? (
209+
<p className="mt-1 text-sm text-muted-foreground">{rule.description}</p>
210+
) : null}
211+
</div>
212+
))}
213+
</div>
214+
</div>
215+
</div>
216+
) : null}
217+
</div>
218+
</ScrollArea>
219+
</div>
220+
);
221+
}

0 commit comments

Comments
 (0)