Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/src/openclaw/GatewayClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ export class OpenclawGatewayClient {

if (frame.type === "event" && typeof frame.event === "string") {
let matchedWaiter = false;
for (const waiter of [...this.pendingEventWaiters]) {
for (const waiter of this.pendingEventWaiters) {
if (waiter.eventName === frame.event) {
matchedWaiter = true;
this.pendingEventWaiters.delete(waiter);
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/prReview/Layers/PrReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ query PullRequestReviewDashboard($owner: String!, $name: String!, $number: Int!)
headRefName
baseRefOid
headRefOid
reviews(last: 100) {
nodes {
state
body
submittedAt
authorAssociation
author {
login
}
}
}
labels(first: 20) {
nodes { name color }
}
Expand Down Expand Up @@ -300,6 +311,32 @@ function normalizeStatusChecks(raw: unknown): PrReviewSummary["statusChecks"] {
return statusChecks;
}

function normalizeRecentReviews(raw: unknown): PrReviewSummary["recentReviews"] {
if (!Array.isArray(raw)) return [];
const maintainerAssociations = new Set(["COLLABORATOR", "MEMBER", "OWNER"]);
return raw
.map((entry) => {
if (!entry || typeof entry !== "object") return null;
const record = entry as Record<string, unknown>;
if (!maintainerAssociations.has(asString(record.authorAssociation) ?? "")) return null;
const submittedAt = asString(record.submittedAt);
const state = asString(record.state);
const author =
record.author && typeof record.author === "object"
? asString((record.author as Record<string, unknown>).login)
: null;
if (!submittedAt || !state || !author) return null;
return {
authorLogin: author,
state,
body: typeof record.body === "string" ? record.body : "",
submittedAt,
} satisfies PrReviewSummary["recentReviews"][number];
})
.filter((entry): entry is PrReviewSummary["recentReviews"][number] => entry !== null)
.toSorted((a, b) => Date.parse(b.submittedAt) - Date.parse(a.submittedAt));
}

function normalizeDashboardResponse(
raw: unknown,
): Pick<PrReviewDashboardResult, "pullRequest" | "threads"> {
Expand Down Expand Up @@ -343,6 +380,7 @@ function normalizeDashboardResponse(
((pullRequest.commits as any)?.nodes?.[0] as any)?.commit?.statusCheckRollup?.contexts?.nodes ??
[],
);
const recentReviews = normalizeRecentReviews((pullRequest.reviews as any)?.nodes ?? []);

const threads = Array.isArray((pullRequest.reviewThreads as any)?.nodes)
? ((pullRequest.reviewThreads as any).nodes as unknown[])
Expand Down Expand Up @@ -394,6 +432,7 @@ function normalizeDashboardResponse(
.map((entry) => normalizeUser(entry))
.filter((entry): entry is GitHubUserPreview => entry !== null)
.map((user) => ({ user, role: "requestedReviewer" as const })),
recentReviews,
totalThreadCount: threads.length,
unresolvedThreadCount: threads.filter((thread) => !thread.isResolved).length,
headSha: asString(pullRequest.headRefOid),
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ describe("getProviderStartOptions", () => {
claudeAuthTokenHelperCommand: "op read op://shared/anthropic/token --no-newline",
codexBinaryPath: "",
codexHomePath: "",
copilotBinaryPath: "",
copilotConfigDir: "",
openclawGatewayUrl: "",
openclawPassword: "",
}),
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
SunIcon,
GitBranchIcon,
GitMergeIcon,
GitPullRequestIcon,
SearchIcon,
KeyboardIcon,
} from "lucide-react";
Expand Down Expand Up @@ -222,6 +223,17 @@ function CommandsView() {
void navigate({ to: "/skills", search: { create: undefined, name: undefined } });
},
});
cmds.push({
id: "nav-pr-review",
label: "Open PR Review",
keywords: ["pr review", "pull request", "review", "github", "maintainer"],
icon: GitPullRequestIcon,
group: "Navigation",
onSelect: () => {
closePalette();
void navigate({ to: "/pr-review" });
},
});

// ── Project quick-switch (inline, first 5) ──
for (const project of projects.slice(0, 5)) {
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {
ChevronsDownUpIcon,
ChevronsUpDownIcon,
CircleDotIcon,
ExternalLinkIcon,
FolderIcon,
GitBranchIcon,
GitMergeIcon,
Expand Down Expand Up @@ -2290,8 +2289,8 @@ export default function Sidebar() {
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
onClick={() => void navigate({ to: "/pr-review" })}
>
<ExternalLinkIcon className="size-3.5" />
<span className="text-xs">Open Workspace</span>
<GitPullRequestIcon className="size-3.5" />
<span className="text-xs">PR Review</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ProviderSetupCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const PROVIDER_CONFIG = {
installCmd: "npm install -g @github/copilot",
authCmd: "copilot login",
verifyCmd: "gh auth status",
note: undefined,
},
} as const;

Expand Down
84 changes: 82 additions & 2 deletions apps/web/src/components/pr-review/PrReviewShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,34 @@ function resolvePrReviewConfigPath(projectCwd: string, configPath: string): stri
return joinPath(projectCwd, configPath);
}

function formatReviewDecision(decision: string | null | undefined): string {
if (!decision) return "No decision";
return decision.toLowerCase().replaceAll("_", " ");
}

function reviewDecisionTone(decision: string | null | undefined): string {
switch (decision) {
case "APPROVED":
return "text-emerald-600 dark:text-emerald-400";
case "CHANGES_REQUESTED":
case "REVIEW_REQUIRED":
return "text-amber-600 dark:text-amber-400";
default:
return "text-muted-foreground";
}
}

function formatReviewTimestamp(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}

export function PrReviewShell({
project,
projects,
Expand Down Expand Up @@ -360,6 +388,8 @@ export function PrReviewShell({
const blockingWorkflowStepsComputed = (dashboardQuery.data?.workflowSteps ?? []).filter(
(step) => step.status === "blocked" || step.status === "failed",
);
const recentReviews = dashboardQuery.data?.pullRequest.recentReviews ?? [];
const displayedRecentReviews = recentReviews.slice(0, 3);

// Inspector props helper
const inspectorProps = {
Expand Down Expand Up @@ -531,7 +561,58 @@ export function PrReviewShell({
)}
>
<div className="overflow-hidden">
<div className="px-4 py-3 space-y-3">
<div className="space-y-3 px-4 py-3">
<div className="flex flex-wrap items-start gap-x-4 gap-y-2 rounded-xl border border-border/60 bg-muted/30 px-3 py-2.5 text-xs">
<div className="space-y-0.5">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
Review decision
</div>
<div
className={cn(
"font-medium capitalize",
reviewDecisionTone(dashboardQuery.data?.pullRequest.reviewDecision),
)}
>
{formatReviewDecision(dashboardQuery.data?.pullRequest.reviewDecision)}
</div>
</div>
</div>
<div className="space-y-1.5">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
Recent maintainer reviews
</div>
{displayedRecentReviews.length > 0 ? (
<div className="space-y-1.5">
{displayedRecentReviews.map((review) => (
<div
className="rounded-md border border-border/60 bg-muted/30 px-2.5 py-2 text-xs"
key={`${review.authorLogin}:${review.submittedAt}`}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="font-medium text-foreground">
{review.authorLogin}
</span>
<span className="ml-2 capitalize text-muted-foreground">
{review.state.toLowerCase().replaceAll("_", " ")}
</span>
</div>
<span className="shrink-0 text-muted-foreground">
{formatReviewTimestamp(review.submittedAt)}
</span>
</div>
{review.body.trim().length > 0 ? (
<p className="mt-1 line-clamp-2 whitespace-pre-wrap text-muted-foreground">
{review.body}
</p>
) : null}
</div>
))}
</div>
) : (
<div className="text-xs text-muted-foreground">No maintainer reviews yet.</div>
)}
</div>
<PrMentionComposer
cwd={project.cwd}
participants={dashboardQuery.data?.pullRequest.participants ?? []}
Expand All @@ -540,7 +621,6 @@ export function PrReviewShell({
value={reviewBody}
onChange={(value) => {
setReviewBody(value);
// Auto-expand when user starts typing
if (value.trim().length > 0 && !actionRailExpanded) {
setActionRailExpanded(true);
}
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,12 @@ const PROVIDER_AUTH_GUIDES: Record<
verifyCmd: "Test Connection",
note: "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.",
},
copilot: {
installCmd: "npm install -g @github/copilot",
authCmd: "copilot login",
verifyCmd: "gh auth status",
note: "GitHub Copilot must be installed and authenticated before it can appear in the thread picker.",
},
};

function getAuthenticationBadgeCopy(input: {
Expand Down Expand Up @@ -811,6 +817,7 @@ function SettingsRouteView() {
codex: Boolean(settings.codexBinaryPath || settings.codexHomePath),
claudeAgent: Boolean(settings.claudeBinaryPath || settings.claudeAuthTokenHelperCommand),
openclaw: Boolean(settings.openclawGatewayUrl || settings.openclawPassword),
copilot: Boolean(settings.copilotBinaryPath || settings.copilotConfigDir),
});
const [selectedCustomModelProvider, setSelectedCustomModelProvider] =
useState<ProviderKind>("codex");
Expand All @@ -820,6 +827,7 @@ function SettingsRouteView() {
codex: "",
claudeAgent: "",
openclaw: "",
copilot: "",
});
const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
Partial<Record<ProviderKind, string | null>>
Expand Down Expand Up @@ -1187,12 +1195,14 @@ function SettingsRouteView() {
codex: false,
claudeAgent: false,
openclaw: false,
copilot: false,
});
setSelectedCustomModelProvider("codex");
setCustomModelInputByProvider({
codex: "",
claudeAgent: "",
openclaw: "",
copilot: "",
});
setCustomModelErrorByProvider({});

Expand Down Expand Up @@ -2515,6 +2525,7 @@ function SettingsRouteView() {
codex: false,
claudeAgent: false,
openclaw: false,
copilot: false,
});
}}
/>
Expand Down
8 changes: 8 additions & 0 deletions packages/contracts/src/prReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ export const PrReviewSummary = Schema.Struct({
statusChecks: Schema.Array(PrReviewStatusCheck),
participants: Schema.Array(PrReviewParticipant),
reviewRequests: Schema.Array(PrReviewParticipant),
recentReviews: Schema.Array(
Schema.Struct({
authorLogin: TrimmedNonEmptyString,
state: TrimmedNonEmptyString,
body: Schema.String,
submittedAt: Schema.String,
}),
),
totalThreadCount: NonNegativeInt,
unresolvedThreadCount: NonNegativeInt,
headSha: Schema.NullOr(TrimmedNonEmptyString),
Expand Down
Loading